AI agent safety · Paymentsby WitLabs2026Preview
Twokey
Approvals for AI agents that move money. The agent proposes, deterministic policy code decides, and anything that adds risk waits for a human approval sealed with SHA-256 to the exact terms.

At a glance
4 lanes
- Every action lands in one lane: lowers risk, inside the mandate, adds risk, or breaks a rule
- Approvals are sealed with SHA-256; if the terms change, the approval is void
- Same idempotency key never pays twice; an unknown outcome is never retried
What it is
Let AI agents pay routine bills on their own while a new payee, a changed bank account or an amount over the limits always waits for a person, and fraud is blocked outright.
Why it exists
Business email compromise cost $3.05B in 2025 (FBI IC3), mostly through one email saying a vendor's bank details changed. Accounts-payable agents now read that email, and agents with wallets have already been talked into sending money to attackers. A model can't be the thing that decides whether money moves.
How it works
A human signs a mandate once: per-payment and daily caps, known payees and the bank accounts on file. When the agent proposes an action, input is validated and a pure classify() function sorts it into a lane. Risk-reducing and in-mandate actions run; anything that adds risk pauses for an approval bound by hash to the amount, payee, account and expiry; hard invariants are refused. Only the executor holds payment credentials, and every decision goes into a hash-chained audit log.
Decisions worth explaining
The model proposes, code decides
classify() is pure, deterministic policy code with no model in the loop. A prompt injection can change what the agent asks for, but not which lane the request lands in.
Approve exact terms, not a request
An approval is sealed with SHA-256 to the amount, payee, account and expiry. If the agent re-computes $14,000 to $14,000.50 after approval, the hash no longer matches, the approval is void and Twokey asks again.
The agent never holds a key
The agent gets guard() and nothing else: no decide(), no full approval hash and no payment credentials. Approvals happen in a separate approver UI behind its own token, so the agent can't approve itself even over HTTP.
Results
- TypeScript SDK (createTwokey with guard, decide, executeApproved) with memory and node:sqlite stores.
- Fastify approval API with an HTML approval card; money kept in bigint minor units; strict input validation.
- End-to-end demo: an accounts-payable agent handles a spoofed bank-change email, a voided approval and a refund, then prints a hash-chained audit log.
Stack
From the code
function classifyPay(a: PayVendor, mandate: Mandate, state: PolicyState): Verdict {const inv = moneyInvariant(a.amount, mandate);if (inv) return inv;const known = mandate.knownPayees[a.payeeId];if (known && known.bankFingerprint !== a.bankFingerprint) {return blocked('INV-FINGERPRINT', `bank details for ${a.payeeId} do not match the signed mandate`);}const checks: Check[] = [{ name: 'payee_known', pass: known !== undefined },{ name: 'per_payment_cap', pass: a.amount.amount <= mandate.perPaymentCap },dailyCapCheck(a.amount.amount, mandate, state),];return checks.every((c) => c.pass)? classified('WITHIN_MANDATE', 'R-WM-PAY', checks): classified('EXPAND', 'R-EX-PAY', checks);}