An agent loop is not a program with one entry point. It crashes, gets retried, gets resumed from a checkpoint, gets run twice by an over-eager scheduler. All of that is survivable when the agent's actions are reads. It stops being survivable the moment the agent can move money.
The specific shape of the bug: an agent turn decides to send a payment, the HTTP call succeeds, the process dies before the response is recorded, the turn is retried, and the payment goes out twice. Nothing in the agent's own reasoning can prevent this — by the time the retry happens, the evidence that the first attempt succeeded is gone.
keeperguard closes that hole by making the request key a function of the decision rather than something generated at call time. Two turns that reached the same conclusion produce the same key, and the second one cannot broadcast.
bin/demo.ts. The timestamps on the left are the
run's own millisecond offsets — the pause before the transaction lands is the chain, not an edit.
mp4 · webmeth_getBalance at blocks 49185789 / 49185790 — not the API's own report
An earlier reference run produced
0xdef6e31b…
and is committed to the repo as run-2026-07-27.jsonl, 8 records, chain verifies.
success && !wouldRevert — this catches bad addresses, insufficient
balance and reverts before broadcast.Idempotency-Key: sha256 of the
canonicalised decision, formatted as a UUID.X-Poll-Interval-Hint, recording
transactionHash and transactionLink.Every phase appends to a hash-chained JSONL ledger, so an operator who was not
watching can verify afterwards that nothing was inserted, edited or dropped: each record
commits to sha256(prevHash + canonicalJSON(record)). The demo deliberately
edits a record to show verification failing when it should.
idempotencyKey({ intent: "transfer", agent: "pico", chainId: 8453,
recipientAddress: "0x90EE…", amount: "0.00001",
reason: "keeperguard reference execution", epoch: "2026-07-27" })
// → 2425d7ef-9b69-8d20-83ad-6a27c6cd9d28, every time, from any process
Field order does not matter — the decision is canonicalised before hashing, so an agent
that rebuilds the object differently still converges on the same key. Two fields are
load-bearing: reason is part of the hash, because an agent that re-derives the
same transfer for a genuinely different reason has made a new decision and should be
allowed to spend again; epoch is the explicit "this is a new occurrence" marker
— a date bucket, an invoice id, a block number. That is the knob that makes a recurring
payment possible without making a repeated payment possible.
A guarantee that lives only in one JSONL file is one rm away from being no
guarantee at all — so the demo goes around its own ledger and checks KeeperHub's server-side
idempotency too. Both rows below were observed live, not read from docs.
| Layer | Same key + same body | Same key + different body |
|---|---|---|
| keeperguard ledger | phase: replayed, no request sent | new decision → new key |
| KeeperHub API | 202, original executionId | 409 idempotency_conflict |
Stated because a guard whose edges you don't know is worse than no guard. The rolling daily cap is enforced against one ledger — two guards on separate ledger files do not see each other's spend. Ledger integrity is tamper-evident, not tamper-proof: the chain proves an edit happened, it does not prevent one; anchoring the head hash onchain is the obvious next step and is not implemented. The local replay short-circuit persists for the lifetime of the ledger, while KeeperHub's own idempotency window is 24 hours — past that, the local ledger is the only thing between a repeated decision and a second transaction. Gas sponsorship is an observation from these runs, not a guarantee; do not size a treasury against it.
MIT licensed, 15 tests, no network needed to run them:
bun install && bun test. The docs PR is a separate contribution to
KeeperHub itself — the headless/agent onboarding path (SIWE sign-in, the undocumented
signature_required step-up on API keys, and which address actually has to be
funded), every claim re-verified live.