Agent-First Pay v0.8.0: Retry Without Paying Twice
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:
| Mode | Start | For |
|---|---|---|
| cli | afpay <subcommand> | one command, local or forwarded via --rpc-endpoint |
| pipe | afpay --mode pipe | a long-lived JSONL session for an agent |
| interactive | afpay --mode interactive | a human REPL with completion and QR helpers |
| tui | afpay --mode tui | a full-screen terminal workflow over the same command interface |
| rpc | afpay --mode rpc | an encrypted gRPC daemon (AES-256-GCM PSK), 127.0.0.1:9400 |
| rest | afpay --mode rest | an 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.