One Express process.
Five money providers. One ledger of truth.
AgentWallet is a module inside the Payouts.com platform. Every agent is a row in agent_identities scoped to a clientId, owned by one or more Principals, and connected to fiat + card + crypto + comms in one provisioning step.
The whole system, one diagram.
Express is the single Node process. A handful of external providers handle money, identity, comms, and AI. Postgres is the system of record; an HSM holds crypto keys.
One process, by design. No microservices. No queue. No Redis (yet). The advisory lock on master-balance allocation lives in Postgres. Widget approval state lives in a single in-memory Map — and yes, it's lost on restart. The Redis migration is tracked.
Two rails. One ledger.
An agent has a fiat wallet (Postgres double-entry) and an optional USDC wallet (HSM-managed on Base). The fiat side flows through the client master balance and out to card or payout rails. The crypto side flows through the master CDP wallet and out via x402 / EIP-3009.
Fiat rail
Crypto rail · x402
The ledger is the truth
Balance is never stored. It's computed by aggregation against the append-only agent_wallet_transactions table:
SELECT SUM(amount) FILTER (WHERE type IN ('credit','refund','release','transfer_in','settlement_credit','settlement_refund')AND status = 'completed') -SUM(amount) FILTER (WHERE type IN ('debit','hold','transfer_out','settlement_debit')AND status = 'completed') AS balanceFROM agent_wallet_transactionsWHERE agent_identity_id = $1;
Transaction types
| Type | Direction | When |
|---|---|---|
| credit | + | Funds allocated from client master |
| debit | − | Card load · payout draw · x402 settlement |
| hold | − | Reserves funds for a pending pre-auth (no money moves) |
| release | + | Reverses a hold when pre-auth is abandoned |
| refund | + | Returns money from card or payout rail to the wallet |
| transfer_in / transfer_out | ± | Direct agent-to-agent transfer in same client |
| settlement_credit / debit / refund | ± | Cross-client agent settlement |
One row per agent. Twelve foreign-key children.
The core entity is agent_identities. It owns identity, ownership, channels, card, limits, crypto, and AP2 keys. Everything else hangs off it via agentIdentityId.
clients (1)└─(1:N)─ agent_identities├─(1:N)─ agent_wallet_transactions // fiat ledger├─(1:N)─ agent_principal_links ───▶ principals ├─(1:N)─ agent_inbox_messages ├─(1:N)─ agent_outbox_messages ├─(1:N)─ agent_identity_activity_log ├─(1:N)─ acp_spt_issuances // shared payment tokens├─(1:N)─ acp_card_token_issuances // raw card tokens (PCI vault)└─(0:1)─ cards // virtual cardprincipals (1) ├─(1:N)─ agent_portal_sessions └─(1:N)─ agent_portal_invitations agent_portal_roles (per client) ├─(1:N)─ agent_portal_role_permissions └─(1:N)─ agent_portal_team_members ─────▶ principals ap2_mandates └─(1:N)─ ap2_mandate_audit
The agent_identities table
Grouped into six concerns. Each is independently nullable so an agent can be provisioned with or without a card, crypto, channels, etc.
Identity & ownership
- iduuid · gen_random_uuid()
- clientIdFK → clients
- slugURL-safe · unique
- purposesales · ops · finance · …
- statusactive · paused · frozen · archived
API credentials
- apiKeyPrefixfirst 12 chars · pak_…
- apiKeyFingerprintSHA-256 · 32 hex
- apiKeyLastFourUI hint
- apiKeyLastRotatedAttimestamp
- mcpEnabledboolean
Channels
- email
emailInboxIdemail provider - phoneNumber
phoneSidtelephony · E.164 - whatsappEnabledWhatsApp Business
- whatsappSenderSidprovider SID
Limits & policy
- perTxnLimitCentsint · per single tx
- dailyLimitCentsint · rolling 24h
- monthlyLimitCentsint · rolling 30d
- allowedCountriestext[] · ISO-2
- policyjsonb · Zod-validated
Crypto wallet
- cryptoWalletAddressEVM 0x… on Base
- cryptoWalletNetworkbase · base-sepolia
- cryptoAutoTopupEnabledboolean
- cryptoAutoTopupAmountUsdc"1.00"
- cryptoAutoTopupThresholdUsdc"0.10"
AP2 keys
- ap2PublicKeyJwkjsonb · ES256 JWK
- ap2PrivateKeyEncryptedAES-256-GCM
- ap2KeyIdJWS kid
- erc8004Enabledon-chain identity opt-in
- erc8004TokenIdtoken ID
pak_…) is returned exactly once: at creation or rotation. apiKeyFingerprint is the durable lookup token. Card PAN/CVV are never stored — fetched live from the provider SDK and held client-side for 30 seconds.Wallet service. Two implementations. One ledger.
Two wallet implementations coexist: the legacy WalletStorage and the canonical agent-wallet.storage.ts. New code uses the canonical path. Both write to agent_wallet_transactions.
allocateWalletFunds — the canonical credit path
This is the function that enforces master-balance headroom. It uses a Postgres advisory lock to serialize concurrent allocations against the same client master:
async function allocateWalletFunds(params) {return db.transaction(async tx => {// 1. Acquire advisory lock on the clientawait tx.execute(sql`SELECT pg_advisory_xact_lock(${hashClientId})`);// 2. Re-read master balance INSIDE the lockconst master = await getClientMasterBalance(tx, clientId);if (master.available < amountCents) {throw new Error('INSUFFICIENT_MASTER_BALANCE');}// 3. Insert ledger credit (atomic with the lock)await tx.insert(agentWalletTransactions).values({agentIdentityId, clientId, type: 'credit', amount: amountCents / 100, status: 'completed', reference, description,});});}
POST /api/agent-portal/agents/:id/wallet/credit endpoint calls addFunds directly — an unconditional credit that doesn't acquire the advisory lock or verify master headroom. Only the admin path POST /api/admin/agent-identities/:id/wallet/add-funds exercises the gate. Aligning the portal endpoint with allocateWalletFunds is tracked in Roadmap → Chapter 17.Why aggregate? Why not store balance?
- Single source of truth. The ledger row IS the change. No reconciliation between a balance column and the entries that made it.
- Audit-perfect by construction. Every cent is a row. Balance discrepancies become "find the missing row," not "which is right."
- Concurrent-safe. Inserts are atomic. Aggregate reads run inside the same SERIALIZABLE transaction as the headroom check — no negative-balance race.
Funding paths. Six ways money lands.
An agent's wallet receives funds through six distinct paths. Each writes a row to agent_wallet_transactions with a reference that traces back to the source.
| Path | Source | Endpoint | Reference format |
|---|---|---|---|
| Manual top-up (Principal) | Principal funds via portal | POST /agent-portal/agents/:id/wallet/credit | portal:{principalId} |
| Manual top-up (Admin) | Operator action | POST /admin/agent-identities/:id/wallet/add-funds | admin:{adminId} |
| Bank inbound | Wire/ACH to global account | webhook → allocateWalletFunds | bank:{txId} |
| Settlement credit | Cross-client agent payment | internal · ap2_mandates | ap2:{mandateId} |
| Card refund | Merchant reversal | webhook → addFunds(type=refund) | card_refund:{authId} |
| Opening balance | At provisioning | provisionAgentIdentity({ walletOpeningBalanceCents }) | provision:{agentId} |
Payout rails — what an agent can draw on
Capabilities cascade through four layers. The intersection is the agent's effective rail set:
Routing sources
Where a payout can be initiated from for a specific agent + rail:
| transfer | Admin one-off transfer |
| api | Programmatic via Tool API |
| payout_link | Recipient-driven payout link |
| ap_automation | AP invoice pipeline |
| agent_identity | Agent's own API key / MCP session |
One activity feed. Every system, in order.
The Activity tab in the Agent Portal shows a unified timeline merging six sources: ledger entries, payouts, card transactions, inbound/outbound messages, MCP tool calls, and approvals. Each row references the underlying record.
Sources merged into the unified feed
| Source table | What it represents | Append-only? |
|---|---|---|
| agent_wallet_transactions | Fiat ledger entries | yes |
| payouts | Outbound rail payments | yes |
| cards.transactions | Card auths + captures | yes |
| agent_inbox_messages | Inbound SMS / email / voice | yes |
| agent_outbox_messages | Outbound messages | yes |
| agent_identity_activity_log | MCP tool calls + approvals | yes |
| ap2_mandate_audit | Every sign / verify / execute | yes |
Card-level filter
To answer "how much did Agent 009 spend on its card last month," the unified feed filters agent_wallet_transactions by cardId IS NOT NULL and aggregates:
SELECT SUM(amount) AS card_spendFROM agent_wallet_transactionsWHERE agent_identity_id = $1AND card_id IS NOT NULLAND status = 'completed'AND created_at >= date_trunc('month', now());
Deployment. Single Express process.
One Node.js + TypeScript process. One Postgres database (serverless). A handful of external providers. No message queue. No Redis (yet).
| Layer | Technology | Notes |
|---|---|---|
| Runtime | Node.js + TypeScript | Single Express process · tsx server/index.ts |
| Database | PostgreSQL (serverless) | HTTP driver dev · WebSocket prod · Drizzle ORM |
| Cache / locks | In-memory Map + pg advisory locks | Redis planned for widget state |
| Crypto signing | Managed HSM (server wallets) | Keys in HSM — never exposed |
| Card issuing | Card issuer (pluggable) | Provider selected by clients.banking_provider |
| Phone / SMS | Telephony provider | agent-phone-provisioning.ts |
| Email provider | IMAP / SMTP · agent-email-provisioning.ts | |
| Outbound proxy | PCI vault | vault-outbound-route.yaml |
Observability namespaces
Every log line is prefixed with a bracketed namespace so a single grep tells you which subsystem spoke:
| [Agent Portal] | server/routes/agent-portal/agents.routes.ts |
| [Agent Wallet] | server/wallet/agent-wallet.storage.ts |
| [x402] | server/services/acp/agent-crypto-wallet.service.ts |
| [MCP Server] | server/mcp/** |
| [AP2] | server/services/ap2/** |
| [Approvals] | server/services/approval-engine/** |
Metrics that page on-call
| Metric | Source | Alert threshold |
|---|---|---|
| Agent wallet balance < 0 | Impossible by construction (SERIALIZABLE tx) | — |
| Master CDP USDC balance | getMasterUsdcBalance() | < 10 USDC |
| Master CDP ETH balance | getMasterEthBalance() | < 0.01 ETH |
| MCP session count | mcpSessions.size | > 1000 |
| Widget approval queue depth | listAllApprovals().filter(pending) | > 50 pending |
| AP2 mandate failures | ap2_mandate_audit (result='error') | spike |