Your agent just learned to spend money. Now make sure it only spends it once.
Blog Article

Your agent just learned to spend money. Now make sure it only spends it once.

Mitchell Alderson7/21/202610 min read

Your AI Shopping Agent™️ works. It helps a customer pick the right product, charges their card, places the order, hands-free. Then one afternoon the payment gateway times out mid-charge, and your agent does the reasonable thing: it retries. Now the customer's been charged twice, and your support inbox is the first to find out.

This is the hypothetical failure and the very real paign I want to walk you through: why a payment inside an agent loop breaks in a way a normal API call doesn't, why the obvious fix, retry it, makes it worse, and the roughly forty lines it took to turn a fragile agent into a durable one that survives the failure without paying twice. There's a companion video that shows the build live if you'd rather watch a quick example.

Retry Landmines

A retry is safe when the call is idempotent: reading weather data twice costs you nothing.

Payments aren't that. The failure you actually fear isn't the call erroring before it charges. It's the call charging and then failing on the way back; the network drops the response, the process dies, the SDK throws, so your code never learns that the money has already moved. Retry now, and you've paid twice.

One note before the code. The agent below uses a deliberately dumb payment tool: a mock that fails on a timer and never touches a real processor. That's on purpose. The double-charge problem is identical whether the charge is a Stripe call, an x402 settlement, or a fake, because the fix lives in how you run the tool, not inside it.

The pattern here transfers as-is. What doesn't transfer is the payment-specific part: which idempotency key your processor wants, how it dedupes, what a timeout actually means for a charge. That lives in your payment API's docs, so keep them open in the next tab.

The fragile version

Here's the agent before any of this is handled. A normal loop: call the model, and if it asked for a tool, run the tool and feed the result back.

// agent.ts — the naive loop
const response = await client.messages.create({
model: MODEL,
max_tokens: 1024,
tools: toolDefinitions,
messages,
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason !== "tool_use") {
return response.content;
}
// No try/catch — an uncaught tool failure kills the whole run.
for (const block of response.content) {
if (block.type !== "tool_use") continue;
const result = await executeTool(block.name, block.input);
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: result });
}

And the whole loop runs inside the HTTP request:

// index.ts — the request blocks until the agent finishes… or throws
async POST(req) {
const { prompt } = await req.json();
const content = await runAgent(prompt);
return Response.json({ content });
}

Two things break here, and they compound.

First, one sporadic failure takes down the entire run. executeTool throws, nothing catches it, the error bubbles up through runAgent, and the request handler returns a 500. Every model call and every tool result the agent accumulated before the failure is gone.

A single transient blip that resolves on its own if you just try again in a second costs the user the whole interaction.

Second, and worse: the obvious fix makes it dangerous. If you wrap the request in a retry, you don't re-run the failed step; you re-run the loop from the top. That re-calls the model (nondeterministic, and you're paying for the tokens again) and re-executes every tool that already succeeded earlier in the loop, including any charge that already went through.

So the naive fix for "the run died" is the thing that double-charges your user. You're stuck choosing between losing runs and billing people twice.

Wrap the tool, don't rewrite it

The real solution here is in the infrastructure: stop running the agent inside the request and let a durable function run it instead, where every model call and every tool call is a step that's independently retried and, once it succeeds, memoized.

Starting with the tool call, we don’t want to change the business logic; the durable function should support the existing executeTool function by wrapping each one in an inngest.createFunction.

// inngest/tool-functions.ts — one durable function per tool, keyed by name
export const toolFunctions = Object.fromEntries(
toolDefinitions.map((def) => [
def.name,
inngest.createFunction(
{ id: `tool-${def.name}`, retries: 5, triggers: [{ event: `tool/${def.name}.requested` }] },
async ({ event, step }) =>
// existing tool call function, memoized once it succeeds
step.run(`tool/${def.name}`, () => executeTool(def.name, event.data.input)),
),
]),
);

That retries: 5 is the entire fix for transient failure. The flaky charge now gets up to five attempts, and the moment it succeeds, step.run records the result so it's never executed again. The function returns that result directly. No completion event to fire, because the caller is going to invoke it and get the value back.

You might ask why each tool gets its own function instead of just wrapping the call in step.run inline in the loop. For the narrow thing we're solving (don't double-charge on replay), you don't need one. The memoization is the fix, and step.run inside the agent function gives you that on its own. If minimal is the goal, inline it and move on.

The function boundary earns its keep when the tool moves money. A separate function carries its own retry budget — that retries: 5 is scoped to the charge, not shared with the model calls and every other tool in the loop — and its own flow control, so concurrency, throttle, or rateLimit can sit on the money-mover without touching the rest of the agent.

It's reusable and independently observable, too: the charge is its own function in the dashboard with its own run history. step.run can't reach across runs like that. Its memoization is scoped to the single run it lives in.

Now we modify the agent loop shape to match. The model call goes inside a step.run so it's memoized, and instead of calling the tool inline, the agent invokes the tool's function and gets its result back directly:

// agent.ts — model call memoized, tool calls invoked durably
const response = (await step.run("call-model", () =>
client.messages.create({ model: MODEL, max_tokens: 1024, tools: toolDefinitions, messages }),
)) as Anthropic.Message;
// Invoke each tool's own durable function and collect the results.
const toolResults: Anthropic.ToolResultBlockParam[] = await Promise.all(
response.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")
.map(async (block) => {
const content = await step.invoke(`run-${block.name}`, {
function: toolFunctions[block.name],
data: { input: block.input, toolCallId: block.id },
});
return { type: "tool_result", tool_use_id: block.id, content };
}),
);

step.invoke calls the tool's function and returns its value straight to the line that asked for it. There's no event to match back, so when the model asks for two tools at once, Promise.all runs them in parallel and each result lands where it belongs by construction; there’s no toolCallId correlation to get wrong, and no timing window where a completion fires before anyone's listening for it.

Finally, the entry point stops blocking. The request just hands the run to Inngest and returns immediately; the agent executes durably in the background:

// index.ts (stage 2) — hand off the run, return right away
async POST(req) {
const { prompt } = await req.json();
const content = await runAgent(prompt);
const { ids } = await inngest.send({ name: "agent/run.requested", data: { prompt } });
return Response.json({ eventId: ids[0] });
}

The thing I want to flag, because it's the whole point of the DX: converting stage one to stage two didn't touch a line of the tool's actual logic. executeTool runs exactly as it did before; all we changed is where and how it's called.

The loop is still a recognizable agent loop: same messages array, same Anthropic SDK calls. That's the point: the conversion wraps whatever you already run. This example is the raw Anthropic SDK, but the move is identical if your agent lives in LangChain, Mastra, or the Vercel AI SDK: wrap the model and tool calls in steps, invoke them durably, and leave the agent logic alone.

But won't it still charge twice?

This is the right question to ask. We just saw how retries could end up creating the double-charge risk in the first place.

The replay case is handled. When a step completes successfully, step.run memoizes its result and on any later replay of the run, that step returns its stored value instead of executing again.

So if the charge succeeds and then something downstream fails (a later tool, a model call, the process crashing), Inngest replays the function and the charge step just hands back its memoized result. It does not re-run. That kills the whole-loop-replay double-charge that made the naive retry so dangerous.

The edge case is honest and worth stating plainly. Step retries give you at-least-once execution of the step body.

There's a narrow window where the charge succeeds on the payment provider's side but the step never records completion. The process dies right after the external call returns, or the call charges and then times out so executeTool throws.

On retry, the step body runs again, and a non-idempotent payment API will charge again. Memoization protects a step that finished; it can't reach back and neutralize a side effect that already escaped before the step was recorded.

The fix is a stable idempotency key, and the nice part is you already have one. Every tool call the model makes carries a unique toolCallId (block.id), and the loop is already passing it through to the tool function as event.data.toolCallId.

const content = await step.invoke(`run-${block.name}`, {
function: toolFunctions[block.name],
data: { input: block.input, toolCallId: block.id },
});

Thread it into the payment call as the provider's idempotency key: the header Stripe, x402 facilitators, and most payment APIs expose for exactly this. The charge dedupes even when the step body runs twice. The retry becomes safe end to end.

In our example code, our tool call is a mock function that doesn’t call any real API provider, so you’ll have to consult your specific payment API for exactly how to use an idempotency key.

// inngest/tool-functions.ts — pass the idempotency key through to the tool
step.run(`tool/${def.name}`, () =>
executeTool(def.name, event.data.input, event.data.toolCallId), // ← use as the charge's idempotency key
);

Try It

Clone the repo, point ANTHROPIC_BASE_URL at whatever you're running, and give the agent a prompt that forces the payment tool.

Then make it fail on purpose: flip the kill switch mid-run, or kill the process between the charge and the response, and watch the run resume from the last completed step instead of starting over or billing twice. That's the difference between an agent that demos and one you'd put in front of real money.

Before you close this tab, go look at what your own agent does when a tool call fails after the money moves. If the honest answer is 'it retries and hopes,' you've got a double-charge waiting to happen, and now you know the shape of the fix. The repo has the rest.

Example Repo

Related content

Build better
agents today

Add Inngest to your project in minutes. Free to start, no credit card required.