Chapters 01 · 02 · 03 · 04 · 13 · 15

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.

Node.js + TypeScriptPostgres serverlessHSM managed key custodyDrizzle ORM192 tables

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.

External clients ↓BrowserAgent Portal · ReactMCP ClientAny LLM · coding agent · customExternal AgentAP2 inboundHTTPS · Bearer authExpress.js · server/index.ts/api/agent-portalPrincipal RBAC/api/tool1,100+ platform tools/mcp/agentpak_… key/ap2/v1/paymentJWS mandate inboundService layer ↓agent-provisioningidentity + channels + cardagent-walletfiat ledgeragent-crypto-walletCDP signingx402 serviceEIP-3009 USDCmcp-serverper-agent tool surfaceap2 mandate chainES256 sign + verifyapproval-enginevelocity caps + widgetsStorage ↓PostgreSQLserverless · 192 tablesDrizzle ORMIn-memory Mapwidget approvalsRedis plannedManaged HSMcrypto private keysExternal providers ↓Card issuervia PCI proxyBanking partnercard · global accountsTelephonyphone · SMS · OTPEmail providerIMAP · SMTPCrypto custodyUSDC · BasePCI vaulttokenization proxy

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

Client masterbalance pool
↓ credit (allocate)
Agent fiat walletagent_wallet_transactions
↓ debit (card-load) | ↓ debit (rail-draw)
Virtual card
cards.balance
Payout rail
ACH · SEPA · Pix · UPI · SWIFT

Crypto rail · x402

Master CDP walletX402_WALLET_ADDRESS
↓ USDC transfer
Agent CDP walletper-agent USDC on Base
↓ EIP-3009 transferWithAuth
Merchantx402 endpoint

The ledger is the truth

Balance is never stored. It's computed by aggregation against the append-only agent_wallet_transactions table:

balance formulapostgresql
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

TypeDirectionWhen
credit+Funds allocated from client master
debitCard load · payout draw · x402 settlement
holdReserves 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.

entity relationshipserd
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
    emailInboxId
    email provider
  • phoneNumber
    phoneSid
    telephony · 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
Security note. The raw API key (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:

server/wallet/agent-wallet.storage.tstypescript
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,});});}
Known gap. The Principal-facing 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.

PathSourceEndpointReference format
Manual top-up (Principal)Principal funds via portalPOST /agent-portal/agents/:id/wallet/creditportal:{principalId}
Manual top-up (Admin)Operator actionPOST /admin/agent-identities/:id/wallet/add-fundsadmin:{adminId}
Bank inboundWire/ACH to global accountwebhook → allocateWalletFundsbank:{txId}
Settlement creditCross-client agent paymentinternal · ap2_mandatesap2:{mandateId}
Card refundMerchant reversalwebhook → addFunds(type=refund)card_refund:{authId}
Opening balanceAt provisioningprovisionAgentIdentity({ 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:

// capability cascade
Layer 1: Banking API capabilities // what the client account supports
Layer 2: Payout Products tab // what the client has enabled
Layer 3a: agent_identities.payout_rails // per-agent rail subset
Layer 3b: agent_identities.allowed_countries // per-agent country narrowing
Layer 3c: payout_rails[i].routings // per-rail routing sources
Layer 3d: agent_identities.*_limit_cents // per-agent velocity caps
Effective rails for THIS agent

Routing sources

Where a payout can be initiated from for a specific agent + rail:

transferAdmin one-off transfer
apiProgrammatic via Tool API
payout_linkRecipient-driven payout link
ap_automationAP invoice pipeline
agent_identityAgent'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 tableWhat it representsAppend-only?
agent_wallet_transactionsFiat ledger entriesyes
payoutsOutbound rail paymentsyes
cards.transactionsCard auths + capturesyes
agent_inbox_messagesInbound SMS / email / voiceyes
agent_outbox_messagesOutbound messagesyes
agent_identity_activity_logMCP tool calls + approvalsyes
ap2_mandate_auditEvery sign / verify / executeyes

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:

card spendpostgresql
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).

LayerTechnologyNotes
RuntimeNode.js + TypeScriptSingle Express process · tsx server/index.ts
DatabasePostgreSQL (serverless)HTTP driver dev · WebSocket prod · Drizzle ORM
Cache / locksIn-memory Map + pg advisory locksRedis planned for widget state
Crypto signingManaged HSM (server wallets)Keys in HSM — never exposed
Card issuingCard issuer (pluggable)Provider selected by clients.banking_provider
Phone / SMSTelephony provideragent-phone-provisioning.ts
EmailEmail providerIMAP / SMTP · agent-email-provisioning.ts
Outbound proxyPCI vaultvault-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

MetricSourceAlert threshold
Agent wallet balance < 0Impossible by construction (SERIALIZABLE tx)
Master CDP USDC balancegetMasterUsdcBalance()< 10 USDC
Master CDP ETH balancegetMasterEthBalance()< 0.01 ETH
MCP session countmcpSessions.size> 1000
Widget approval queue depthlistAllApprovals().filter(pending)> 50 pending
AP2 mandate failuresap2_mandate_audit (result='error')spike

Compare AgentWallet