Agent-First Pay v0.8.0: Retry Without Paying Twice

by Agent-First Kit Contributors

v0.8.0 rebuilds afpay around the one question an autonomous payer cannot answer for itself: 'did that send go through?' An agent-supplied idempotency key makes a resend replay the first result instead of paying again; spend reservations hold the money in flight and expose reservation_ids for later reconciliation; EVM chain_id and Solana cluster are pinned at send time and fail closed on mismatch; and structured errors carry retry_after_ms so an agent can pace its own backoff. Underneath, the runtime is reorganized into six modes — cli, pipe, interactive, tui, rpc, rest — that all answer the same code-tagged protocol, and every one can be asked for its own schema.

An agent sends a payment. The request times out. Now it has to decide something a human would rarely get wrong but an automated loop gets wrong constantly: did the money move? Retry, and it might pay twice. Give up, and it might strand a task that actually succeeded. Every payment tool that agents drive eventually runs into this wall, and the honest answer used to be “check the chain yourself and hope.”

Agent-First Pay v0.8.0 is largely a rewrite, and the thing it rebuilds around is exactly that question. The release reorganizes the runtime into six modes, adds a full-screen TUI and an HTTP REST server, and pulls the whole codebase onto Agent-First Data 0.22 — but the reason to upgrade is that a send is now something an agent can safely retry.

An idempotency key makes the resend a no-op

Every Send and CashuSend now takes an optional idempotency_key: an opaque string the agent chooses, up to 128 characters, held for 24 hours.

{"code":"send","id":"r1","to":"0xabc…","amount":{"value":"250000","unit":"gwei"},"idempotency_key":"invoice-7731"}

Send that once and the wallet broadcasts. Send the same body again — after a timeout, a dropped socket, a process restart — and afpay does not broadcast again. It replays the first terminal output verbatim:

{"code":"sent","id":"r1","wallet":"base-ops","transaction_id":"0x…","amount":{"value":"250000","unit":"gwei"},"reservation_ids":[41]}

The key is bound to the request body, not just the id. If the agent reuses a key with a different body — a new amount, a new recipient — afpay refuses with idempotency_conflict rather than guessing which one you meant. So the retry that should be safe is safe, and the mistake that should be caught is caught.

This is the primitive the rest of the release is built on. An agent no longer needs to reason about network delivery to avoid a double-spend; it needs to reuse a key.

Reservations hold the money in flight

Idempotency handles the retry. Spend reservations handle everything that can go wrong between “we decided to pay” and “the network confirmed it.”

When a send passes the spend limits, afpay reserves the amount against those limits before it touches the network, and the successful Sent (or CashuSent) output now carries the reservation_ids it confirmed:

{"code":"sent","id":"r1","wallet":"base-ops","transaction_id":"0x…","amount":{"value":"250000","unit":"gwei"},"reservation_ids":[41]}

Reservations carry a per-network TTL, so a send that never resolves does not silently hold down an agent’s daily allowance forever — it expires and the room comes back. And because the ids surface in the output and cross-link into the history record, an operator has a handle to pull when something gets stuck. A new operator-only ReconcileReservation input takes a reservation id, an action, and a required free-form reason, and reports what it changed:

{"code":"reconciled","id":"op1","reservation_id":41,"action":"cancel","previous_status":"pending","new_status":"cancelled","trace":{"duration_ms":2}}

The previous_status is there on purpose: an audit log should be able to reconstruct what the operator was looking at when they acted, not just what they did.

The network is pinned before the money moves

A subtler way to lose money is to send it to the right address on the wrong network. An agent that believes it is talking to Base can, with the same address and the same key material, broadcast on Arbitrum, Optimism, or mainnet.

v0.8.0 closes that by pinning the target network at send time and failing closed. An EVM Send accepts an optional chain_id; if it is set and the wallet’s configured chain does not match, afpay refuses with wrong_chain instead of broadcasting:

{"code":"send","id":"r2","to":"0xabc…","amount":{"value":"1000000","unit":"gwei"},"chain_id":8453}

Solana sends get the same treatment against the configured cluster. The principle is the one that runs through the whole release: when the agent’s assumption and the wallet’s reality disagree, the money stays put and the agent gets a specific error code back — never a best-effort guess with funds attached.

The same fail-closed instinct now governs which endpoints afpay will even talk to. Opt-in URL allowlists cover Cashu mints, the Bitcoin backends (esplora / core / electrum), the Lightning endpoints, the Solana and EVM RPC URLs, and wallet_config_set. Leave a list empty and everything is permitted as before; populate it and afpay will only reach hosts you named.

One session table can’t be flooded

afpay’s encrypted gRPC transport now runs a per-session handshake — a random salt with a TTL-bounded replay cache — so a captured message can’t be replayed into a fresh session. Handshakes themselves are rate-limited, because the session table is state and unbounded session creation is a denial-of-service waiting to happen. This is the unglamorous half of being a trust surface: the interesting attacks are on the money, but the cheap attacks are on the bookkeeping.

Structured errors an agent can act on

An error is only useful to an autonomous caller if it says what to do next. Every failure is now a structured Output::Error with a machine error_code, a human error, a retryable flag, and — on the transient codes (busy, rate_limited, temporary_network_error) — a retry_after_ms:

{"code":"error","error_code":"rate_limited","error":"provider throttled","retryable":true,"retry_after_ms":1500,"trace":{"duration_ms":3}}

The agent doesn’t have to reimplement the daemon’s backoff; the daemon tells it how long to wait. A Request wrapper adds a dry_run field to every input, so an agent can validate a payment — limits, allowlists, chain pinning, all of it — and get back a dry_run output describing exactly what would happen, without moving anything.

Every mode answers the same protocol

Under all of this, the runtime is reorganized into six modes that share one code-tagged wire protocol:

ModeStartFor
cliafpay <subcommand>one command, local or forwarded via --rpc-endpoint
pipeafpay --mode pipea long-lived JSONL session for an agent
interactiveafpay --mode interactivea human REPL with completion and QR helpers
tuiafpay --mode tuia full-screen terminal workflow over the same command interface
rpcafpay --mode rpcan encrypted gRPC daemon (AES-256-GCM PSK), 127.0.0.1:9400
restafpay --mode restan HTTP API (POST /v1/afpay, Bearer token), 127.0.0.1:9401

The TUI and the REST server are both new. The TUI drives the same command set as the interactive REPL, so nothing a human does in the full-screen view is outside the protocol. The REST mode puts the whole payment surface behind an ordinary HTTP request with a Bearer token, for curl, containers, and clients in any language — and, like the gRPC mode, it enforces spend limits independently rather than trusting the caller.

Because the modes share a protocol, they share discoverability. Input::Schema is answerable in cli, pipe, and rpc; REST exposes it at /v1/schema; and outputs carry a schema_version. An agent that has never seen afpay before can ask afpay what it speaks.

When the ledger and the network disagree

One output deserves its own paragraph because it is the case v0.8.0 refuses to paper over. If a payment is broadcast successfully but the local spend-ledger then fails to record the confirmation — a storage error mid-flight — afpay emits accounting_inconsistent, not sent:

{"code":"accounting_inconsistent","id":"r1","transaction_id":"0x…","reservation_ids":[41],"confirm_errors":["…"],"hint":"funds left the wallet; ledger not updated — reconcile before retrying"}

The funds left the wallet; the ledger does not reflect them. An agent must treat this as a hard failure and never retry — the side effect already happened — and an operator must reconcile. Fee math that used to silently truncate or saturate now uses checked arithmetic, and accounting stays consistent across the confirm, cancel, and wallet-id paths, so this state is rare. But when it does occur, afpay names it precisely instead of returning a cheerful sent that would invite exactly the double-spend the rest of the release works to prevent.

Upgrading

v0.8.0 is a breaking release. The protocol is now code-tagged and reorganized, the CLI and modes are restructured, and the crate moves onto Agent-First Data 0.22 (builder APIs, structured CLI exits, config editing through the document layer, and a protocol-v1 --version that reports the display name and build SHA). Regenerate any hardcoded request shapes from the live schema — afpay will hand you its own — rather than porting old JSON by hand.

Two habits are worth adopting on the way in. First, set an idempotency_key on every Send and CashuSend your agent issues; it is the single change that turns a timed-out payment from a gamble into a safe retry. Second, if you run the gRPC or REST daemon exposed beyond localhost, populate the URL allowlists and treat accounting_inconsistent as the stop-and-reconcile signal it is.

Install or upgrade:

brew install agentfirstkit/tap/afpay        # macOS / Linux
scoop install afpay                          # Windows (agentfirstkit bucket)
cargo install agent-first-pay                # any platform, from crates.io

Then afpay skill install to give your agent the behavior rules that go with the new safety primitives — stay under the limits, reuse the idempotency key, read the structured output. The full protocol lives in the CLI reference and the architecture guide.