Skip to content

Architecture

embco is four layers with one rule between them: the model may shape a decision, but it never releases money. The money moves only when the shop contract accepts the call.

System overview

System overview: ERPNext feeds the agent (reflexes, controls, rules, the model, the gate); the services pay through Circle and the ShopPayables contract on Arc; the journal records decisions and payments; the owner approves, pauses and sets limits on the contract System overview: ERPNext feeds the agent (reflexes, controls, rules, the model, the gate); the services pay through Circle and the ShopPayables contract on Arc; the journal records decisions and payments; the owner approves, pauses and sets limits on the contract

Trust boundaries

Actor Can do Cannot do
Owner (MetaMask wallet) Create the shop contract, set limits, approve supplier wallets, pause or resume, change the agent's address No limit applies to the owner, and every change the owner makes is public on chain
Agent (Circle wallet of the shop: a smart contract account whose gas Circle pays; shops made before that have one EOA) Pay an approved supplier, within the per-payment and weekly cap, once per invoice; pause; as the owner's delegate on a chain, bring USDC from the owner's Unified Balance to the owner's own wallet on Arc Raise a limit, approve a payee, pay an unapproved wallet, pay twice, resume after a pause, send the owner's Unified Balance anywhere but the owner's wallet on Arc
Supplier Sign a challenge with its wallet to prove the address is theirs Change a payee on its own: the owner still approves it
AI model Decide what to pay and when, send the payment itself and act on what happened, and talk to the owner Override HOLD, pay what the checks do not allow, or bypass the contract

Principles

  1. Money is Decimal, never float, and every amount is an exact integer on chain (6 decimals).
  2. The core talks only to LedgerAdapter, never to a specific ERP.
  3. The model starts a payment, but never alone: the checks must allow it and the contract must accept it.
  4. The limits live in a contract the agent cannot change.

The agent lives in agent/: its brain (agent.py), tools/, reflexes/, guardrails/ and memory/. What it stands on lives in services/ (ERP, Circle, payments, the hosted service). The contracts live in contracts/.

Module map

Package Responsibility
agent/agent.py, prompt.py, think.py, plan.py The brain. When a reflex wakes it, the model (today Claude Haiku 5.5, at high effort) reads a short briefing and calls tools until every open invoice has its decision: pay now, pay on a date, hold, or ask the owner, each with a reason, plus alarms and notes for later and a summary for the owner. It pays during the session and reads what happened (paid with its transaction, refused by the contract, or Circle's own error), then acts on it: looks up why, holds, or tells the owner what only they can fix. Capped per session (turns, tokens, 10 payments) and per day (USD 1 of model use by default, estimated from the tokens and the public prices; past it the agent is not woken until the next day, and the owner is told once). A payment it sent stands even if the session is cut off; its other choices count once the session finishes. The rules still decide what may be paid; the agent decides what is paid and when, and pays it. On by default, and nothing is paid without it; EMBCO_AUTONOMY=observe decides and explains without paying
services/telegram The agent on Telegram, a process of its own (embco telegram). A chat is linked with a one-time code from the dashboard (hashed, 10 minutes, once); any other chat can do nothing. /stop turns the whole agent off and pauses the contract from the agent's wallet, /pause pauses payments while the agent keeps watching and answering, /status tells its state: none of them go through the model, and only the dashboard turns it on again. Any other text is a message to the agent, whose answers come back to the chat. The agent's questions about invoices arrive once: with two buttons (yes, pay it / no, hold it) when only the owner's yes is missing, or a plain yes / no on an invoice the rules hold (a yes cannot pay it: it answers the step the agent proposed), where a press from the linked chat, while the question is still open, is only a message to the agent; as a reminder to use the dashboard, followed by the agent's question, when the owner's wallet is needed (approve a wallet, raise a limit, add money, authorize or resume payments). What the agent writes to the owner is one topic, short and without full hashes: a longer text goes back to the model to be rewritten. The bot token never reaches a log or an error
agent/tools What the agent can call. To read: the open invoices (with the last state of each payment), a search over them (supplier, due date, what the checks say), one invoice in full, a supplier's profile, an item's price history, the cash position (with the owner's USDC on other chains), the state of a payment, and its own wallet (balance for network fees, whether the contract has it as its agent, whether the contract is paused). To act: pay now (sent at once, with the result back to the agent), deposit the owner's USDC on another chain into their Unified Balance through their ShopFunding there, estimate and bring funds from the Unified Balance to their wallet on Arc (which chains and how much are the agent's choice), schedule (on that day the agent is woken to pay), hold, ask the owner, set an alarm, keep a note, keep and edit its memory files (the standard memory tool, memory_20250818), finish. To talk: reply to the owner's message, notify the owner of something to act on (at most 3 per session), and email a supplier from the shop's ERPNext, only after the owner agreed. Every call, with the values the model chose and what came back (done, with its transaction, or refused and why), is kept with its session and shown in the activity the moment it returns
agent/guardrails/gate.py Refuses a payment the rules do not allow (HOLD, ASK, already paid, over this week's room in the contract) before anything is sent, and the refusal goes back to the agent
agent/reflexes/wake.py When to wake the agent: an invoice new, changed or without its decision, a payment failed or blocked (not again for a result it already saw), the day it planned to pay something, a due date close with nothing planned, an owner answer or supplier signature, a supplier waiting on the shop that gets an email in the ERP (services/shops/supplier_reach.py), a limit change, an alarm it set, the daily round. What the agent did itself is not a reason: an invoice it paid does not wake it when it changes, an alarm about it does not ring, its own payments are not a change of money, and a hold it chose that only lost reasons since still holds. No reason, no model call
services/erp Domain models, LedgerAdapter protocol, erpnext/ adapter (client, mappers, adapter). Reads, plus one write through PaymentWriter: a payment final on chain becomes a submitted Payment Entry (on testnet, a draft marked TESTNET, so test USDC never closes a real invoice), drafted by ERPNext from the invoice and checked against what was paid (party, amount, invoice) before it is written, with the transaction hash and payee wallet in its custom fields. The same transaction is never written twice
agent/guardrails/controls Context builder and one small module per control: three-way match, payee wallet, duplicates, price anomaly, payment limit, supplier status, and for an invoice not in USD the exchange rate. Each returns PASS, HOLD (an unproven wallet, a duplicate bill, an invoice or supplier on hold in the ERP, no exchange rate or one more than 3 days old), ASK (a first payment to a new wallet, a bill for more than was received) or NOTE (evidence for the agent to weigh: receipt, quantities ordered, price, limit, disabled supplier). A wallet never paid before is held until the supplier signs a challenge with it (proof the address is right and theirs), then the owner is asked to approve, since only a person knows whether the supplier really asked for the change. The question says who set the wallet in the ERP and when, from the supplier's change history
services/hub, services/shops The hosted service behind the dashboard, a process of its own (embco hub). A shop's owner signs in with their wallet; the hub creates the shop's two Circle wallets before its contract, so the contract is created with its agent: a smart contract account that is the agent and sends every transaction, with its gas paid by Circle Gas Station, and an EOA in the same wallet set that only signs the owner's Unified Balance spends as their delegate (a smart contract account cannot). A shop made before this has one EOA that does both and pays its own gas. It stores each shop's ERPNext connection encrypted, tests it, and starts, stops and restarts the shop's agent through systemd
services/signing EIP-712 wallet ownership message bound to embco and Arc testnet, and signer recovery (ordinary wallets; smart contract wallets need EIP-1271 on chain)
agent/guardrails/rules Fixed combining rule (HOLD, then ASK, then PAY; a NOTE changes nothing and goes to the agent as something to weigh), policy config, the weekly-budget planner and owner answers: an approved ASK becomes PAY, a rejected one HOLD, only while the decision is unchanged. The owner answers with the CLI, on the invoice in ERPNext (an owner-only field, accepted only from EMBCO_OWNER_USERS per the ERP's change history, once per question), or, for a new wallet, by approving it in the contract. HOLD is never overridden
agent/memory Memory between runs, stored as changes: every run, each new or changed decision, the policy when it changes, invoices that leave the unpaid list, owner answers, wallet challenges and the signatures that answer them, AI explanations, and each payment (blocked, submitted, complete, failed, or recorded in the ERP). One append-only, hash-chained sequence; it grows with events, not with runs. DecisionJournal protocol, SQLite store. Apart from the journal, files.py: the agent's memory files under <shop>/memories/ (what the owner wants, each supplier, the shop), read and edited with the memory tool on the SDK's filesystem backend. owner.md changes only in a session where the owner wrote; no wallet address, flat Markdown files only, at most 30 of 3000 characters; each session works on a copy kept only if it finishes
agent/reflexes One cycle (verify memory, decide, apply owner answers, remember, issue wallet challenges, explain, follow payments already sent and write the final ones in the ERP, then the agent's session), the interval loop that survives an ERP outage and stops on a tampered memory, and the text report
services/settings, services/cli Settings from the environment or .env, checked once, secrets never shown; the embco command: run, watch, answer, challenge, sign-wallet, history, verify, create-wallet, shop, hub, telegram
agent/explain A plain-language summary and next step for each HOLD or ASK decision, through the model with structured output (today Claude Haiku 5.5, at low effort; with Claude Opus and Sonnet 5.x it also gets the server-side refusal fallback). Explained once per decision and stored; a failure is logged and retried next cycle; never changes a decision. Off unless EMBCO_EXPLAIN=on
services/circle Circle developer-controlled wallets over REST: a fresh entity secret ciphertext (RSA-OAEP, SHA-256) on every write, idempotency keys, wallet creation (EOA or smart contract account), contract execution and transaction state. Reads are retried when the connection drops; errors never carry credentials
services/fx The day's exchange rate for an invoice not in USD, from Frankfurter (free, no key, central bank sources, can run on our own server), asked once per currency and day. The rules convert the amount due to USDC at that rate, rounded to the cent; the journal keeps the invoice's own amount and the rate, and the Payment Entry records both amounts, from the company's USD account, with ERPNext booking any rounding as an exchange difference
services/payments Pays one invoice when the agent calls pay now, and gives back what happened, refusals and Circle errors word for word: re-reads the invoice and supplier from the ERP (same amount and currency, valid wallet; an invoice not in USD only when the ERP has its one USD account to record it), skips what the contract already marks paid or what does not fit under its weekly cap, lists for the dashboard any wallet the owner has not approved in the contract yet, including invoices asked about only because their wallet is new (approving the wallet there then counts as the owner's answer, saved in the journal like any other; a wallet approved before the question answers nothing) (EMBCO_APPROVALS_FILE: wallet, invoice and amount only, no supplier names), simulates the exact ShopPayables.pay call through the Arc node and names the contract's error if it would fail, sends the same bytes through Circle with an idempotency key per invoice and attempt, follows it to a final state, records every step, then writes it into the ERP. At most 3 failed transactions per invoice. Amounts become exact 6-decimal integers or are refused
services/unified_balance The owner's Unified Balance (Circle Gateway) through Circle's Unified Balance Kit 1.8.0 and its Circle Wallets adapter, in a small Node script (kit/, pnpm, Node 22) the agent runs once per call, with Circle's credentials only in its environment. At start it gives the agent's Circle wallet the same address on each source chain (deriveWalletByAddress); then it reads the owner's balance per chain and whether the agent is a delegate there, estimates fees, and spends with the split the model chose, always minted to the contract owner's wallet on Arc through Circle's Forwarding Service. The dashboard gets the same per-chain view after each check
dashboard/ Static owner page (ethers 6.17.0 vendored, no build). Connects MetaMask, adds Arc Testnet, creates the shop from the factory, and lets the owner set the allowance and limits, approve suppliers (with one click for the wallets the agent is waiting to pay, from the agent's file), choose the chains the agent can pay from (each one turned on creates the owner's funding contract there with the shop's limits, approves it and authorizes the agent in Gateway, in one batched confirmation when the wallet supports EIP-5792; Circle's Unified Balance Kit 1.8.0 with its viem adapter, bundled once into vendor/), pause or resume the agent, and read suppliers and payments from the contract's events. Money is parsed and shown as exact 6-decimal integers. Chain reads go through the site's own /rpc proxy, which adds the node credential on the server, so the page never holds one. The shop console is a sidebar of pages: Bills (due this week, overdue, what can pay them, and one row per bill with the agent's reason that opens its steps), Payments, Suppliers, Treasury (where the owner's USDC is, and what the agent's planned payments leave in the wallet over two weeks), the agent's activity (a live console: what woke it, each tool it called and what came back, at the time it returned, then its decisions and what it said) and Settings. Earn, cross-chain funds and the connections (ERPNext and the shop contract) open over the page. The chat with the agent stays closed until the owner opens it. There the agent writes like a colleague: one topic per message, at most 300 characters, suppliers by name, nothing the activity already shows; its questions show as cards with two answers (yes, pay it / no, hold it). Text carrying the model's tool-call markup is refused before it reaches the owner. When the owner's sign-in ends (it lasts 12 hours and survives a restart of the hub, which keeps only a hash of each session token), every page says so, with a button to sign in again. Served with a strict Content Security Policy
contracts/ (Solidity) ShopFunding, one per owner on each source chain (Base Sepolia, Ethereum Sepolia), created from ShopFundingFactory: the agent may only move the owner's USDC there into the owner's own Gateway balance, within a per-move and a weekly cap. ShopPayables, one per shop, created by its owner from ShopPayablesFactory. The money stays in the owner's wallet; the agent can only pay approved suppliers, once per invoice, within a per-payment and a weekly cap; owner or agent can pause, only the owner resumes or changes limits. Unit and fuzz tests, plus a fork test against Arc Testnet's real USDC

ERP independence

The core never imports ERPNext. Adding Odoo or SolidInvoice means a new adapter under services/erp/ that satisfies the same protocol.

Model independence

The agent talks to the AI model through two interfaces: Brain (agent/agent.py) for its decisions and Explainer (agent/explain/base.py) for the explanations. Today the only implementation is Claude, by Anthropic, which needs an ANTHROPIC_API_KEY. Another provider means a new class that satisfies the same interfaces. The rules, the gate and the shop contract do not change: they never depend on which model is behind them.