Anlyon is a hosted layer that makes production calls on behalf of AI agents. This post is about one failure that shaped more of its design than any other, and about the parts the design still cannot solve.
Here is the sequence.
- An agent decides a customer should get a $120 refund and asks for it.
- Stripe receives the request and issues the refund.
- The HTTP response never arrives. The connection times out at thirty seconds.
- The agent sees an error and tries again.
Step 4 is the problem. Nobody in that sequence knows whether step 2 happened. The retry is a coin flip between "the refund the customer was owed" and "a second refund they were not".
If you have built payment systems before, this is the two generals problem wearing a new hat. What is new is who is sending the retry. A model told that a tool call errored will, almost always, call it again. It does not know what an idempotency key is for. If the first call fails, it may helpfully retry with the amount written differently, or with a note added, which is a different request under any dedup scheme. The retry loop that used to live in a library you chose now lives in a model you prompt.
Why "add an idempotency key" is not the whole answer
It is the first thing to do, and it is not enough. An idempotency key deduplicates between two parties. In the sequence above there are three: the agent, your execution layer, and Stripe.
- A key between the agent and your layer stops the agent's repeat from reaching Stripe twice, provided the agent sends the same key and the same request. A model that varies the request on retry defeats it.
- A key between your layer and Stripe stops your layer's repeat from refunding twice, inside Stripe's retention window for that key and only for an identical request.
Neither key answers the actual question, which is whether the first request was applied. A key lets you safely send again. It does not tell you what happened the first time. If you send again after the provider's key window has closed, or to a provider that has no key, the key does nothing.
So the design has to start from a different place. It has to start from admitting what it does not know.
What Anlyon records: unknown, not failed
With Anlyon the agent does not call Stripe. It invokes a named action, and Anlyon makes the request with a credential the model never sees. That puts Anlyon in the one position where it can see the difference between a request that never left and a request that may have been applied, and it records them as different outcomes.
| What happened | Outcome |
|---|---|
Stripe answered 2xx | succeeded |
The request provably never left, or Stripe answered 4xx | failed |
Timeout, connection reset after the request was written, or a 5xx | unknown |
Stripe answered 409 | unknown, because it can mean the idempotency key is still in use, which proves nothing either way |
unknown is the outcome this post is about. It means the available evidence cannot establish the result, and the request may have been applied. This is what the agent gets back:
{ "status": "unknown", "grade": "unknown" }
The invocation's error carries this text, and it is written for a model as much as for a person:
The provider may have applied effect eff_01JABCDEF; Anlyon is reconciling it. Do not retry; inspect the effect.
And the effect itself, read while unknown:
{
"outcome": "unknown",
"stage": "reconciling",
"verification": "unverified",
"unresolvedExposure": true
}
These responses are copied from a run, not typed by hand.
Then, deliberately, nothing retries the write. A dispatch is claimed once and the database refuses a second dispatch attempt for the same effect. A worker that dies after sending leaves the effect unknown. It is reconciled, never dispatched again.
It would be easy to retry. It would also be how one refund becomes two.
The money stays held
Every governed action reserves its impact against a shared limit before dispatch. While the outcome is unknown, that reservation is neither consumed nor released:
{ "consumed": 0, "reserved": 12000, "unknownExposure": 12000 }
Amounts are integer minor units, so that is the $120. Other agents on the same limit have $120 less room until this settles. The hold survives restarts and period boundaries. Releasing allowance for an operation that may have happened is precisely the mistake this design exists to avoid, and a dependent effect does not start either: unknown never satisfies a dependency.
Four ways out of unknown
1. The scheduled read-back
When Anlyon records an unknown outcome it schedules a lookup. The lookup reads and never writes. For Stripe, it asks for the refund by id if a response ever arrived, otherwise it searches by the anlyon_effect metadata every refund carries. A match settles the effect:
{
"stage": "settled",
"outcome": "succeeded",
"grade": "confirmed",
"verification": "provider",
"attempts": [{ "kind": "dispatch" }, { "kind": "reconcile" }]
}
Stripe saw one write and one read. The limit then reads consumed: 12000, reserved: 0.
2. Reconcile on demand
You do not have to wait for the schedule:
const { data: effect } = await operator.actions.reconcileEffect('eff_01JABCDEF');
console.log(effect!.outcome, effect!.grade, effect!.verification);
It stays available while the environment is halted, so stopping everything never strands held money. An effect that is already settled answers 409: a settled outcome is not reconciled again.
3. Replay, where the provider makes it safe
A replay repeats the lost write under the same provider identity. It is only safe when the provider deduplicates on an idempotency key and you are inside that key's window. Anlyon persists the provider key before the first write, so a replay sends exactly the key Stripe already saw:
await operator.actions.replayEffect('eff_01JABCDEF');
A replay is a write. It passes every control the original passed: the approval must still hold, the preview must still be current, the environment must not be halted. It needs a credential with actions:resolve, which no key gets by default, and the key that requested the effect cannot replay it.
Which adapters offer it is the honest part:
| Action | Key sent to the provider | Replay of a lost write |
|---|---|---|
stripe.refund | The effect's idempotency key, persisted before the write | Inside 24 hours |
resend.email_send | The effect's idempotency key | Inside 24 hours |
github.file_update | None. The reviewed blob SHA is sent as a precondition instead | Not offered |
| A declared action on any other HTTP API | None | Refused before anything is sent |
A replay of a declared action answers 409 REPLAY_REFUSED with reason replay_unsupported, and nothing reaches the destination. Reconciliation of a declared action repeats the read-back you declared, never the write.
4. A person, labelled as such
When no lookup can settle it, someone checks Stripe and records what they found:
await operator.actions.addEffectNote('eff_01JABCDEF', 'Asked Stripe support, ticket 81233.');
await operator.actions.resolveEffect('eff_01JABCDEF', {
outcome: 'succeeded',
reason: 'Refund re_3Pq visible in the Stripe dashboard for pi_3Pabc.',
});
A manual succeeded grades acknowledged, never confirmed, and keeps verification: "manual" everywhere it is shown. A manual failed releases the allowance. The reason is required and stays on the record with who wrote it and when. The key that made the call can never resolve it, so an agent cannot declare its own uncertain refund failed and go again.
Not finding it never becomes failed
This is the rule that took the longest to settle.
A lookup that finds nothing is absence of evidence. Metadata can be removed after the fact. A commit can be force-pushed away. A provider's list endpoint can lag. So when the read-back comes up empty, the effect stays unknown with its money held. In a test where a write timed out and three read-backs then found nothing, the evidence on each one reads:
The read-back did not match the declared verify rule. That does not prove the write failed, so the outcome stays unknown.
After dispatch, exactly one thing settles an effect failed without a person: the provider's own record of this operation in a failed state. A refund Stripe reports as failed is that. Failing to find the refund is not. Failing to replay it is not.
The alternative, where three empty lookups release the hold and the agent runs again, is the comfortable choice. It is also the one that eventually issues the second refund at three in the morning when nobody is watching.
Retrying on purpose
None of this means you can never run the action again. It means doing so is a decision, made visible:
await operator.actions.invoke('refund-order', input, {
retryOf: 'inv_01JABCDEF',
idempotencyKey: 'refund:4417:retry-1',
});
A retry needs its own key and names the invocation it retries. It is allowed only when the original is failed, on its own or resolved as failed. An unknown original must be resolved first. A succeeded one cannot be retried this way at all. Each invocation can be retried once, and the link shows on both records.
Sending the same key again, on the other hand, never reaches Stripe. It is answered from the invocation as it is now, with an X-Idempotent-Replay: current-state header, including hours or days later. The key stays on the invocation for as long as the invocation exists.
Rehearsing it
You cannot make Stripe lose a response on demand, so the Stripe adapter can do it for you. Give a test-mode action faultDrill: "drop_response_after_send" and Anlyon sends the refund, then discards Stripe's answer, exactly as a connection lost after the write would. The effect goes unknown, the hold stays, nothing is sent again, and the reconciler settles it from Stripe's record about thirty seconds later. Every preview of a drilled action says "Fault drill", so nobody approves one without seeing that it is one.
await anlyon.actions.create({
name: 'refund-drill',
adapter: {
type: 'stripe.refund',
config: { account: 'acct_1Nxyz', mode: 'test', currency: 'usd', faultDrill: 'drop_response_after_send' },
credentialSecret: 'STRIPE_TEST_KEY',
},
});
It is the only way to see the whole path, write, lost response, hold, read-back, settle, without waiting for the network to do it to a real customer.
What this does not do
Better to list these than to have you find them.
- It is not exactly-once. No system that talks to a third party over HTTP can promise that. What Anlyon promises is narrower: a governed write is sent at most once, what it does not know is recorded as not known, and nothing runs again until evidence or a person settles it.
- The idempotency key between you and Anlyon does not make the provider idempotent. It deduplicates your calls to Anlyon. The provider-side key is a separate thing, and only Stripe and Resend get one.
- Replay exists for two adapters, for 24 hours. GitHub file updates use a blob SHA precondition and cannot be replayed safely. A declared action on any other API sends no provider key, so a replay is refused and only the read-back runs. If your provider offers neither an idempotency key nor a way to read the operation back, Anlyon can tell you
unknown, hold the allowance, and wait for a person. It cannot settle it alone. unknowncan stayunknown. That has a cost: held allowance, and someone withactions:resolvelooking at a dashboard. We chose that cost over the alternative, and you should know you are choosing it too.- It governs calls routed through Anlyon. A tool your own code calls directly with its own key gets none of this.
- It is hosted. There is no self-host today.
Where to go from here
If you want to integrate it, start in the console and define the refund action from the guide below: console.anlyon.com.
If you would rather read the implementation first, the two pages behind this post are Idempotency and retries and When the outcome is unknown. The step-by-step version for a refund action is the agent payment retries guide, and the receipt grades are in Receipts and grades.
We answer every question about this, including the ones that start with "this is wrong because".
