Your LLM Agent's Tool Calls Are Uncompensated Transactions
Akash Moorching, April 2026
At Saved, I spent a bunch of time writing payment workflows. Paying out creators, running subscription charges, handling refunds. Most of them had the same shape. Check a balance, charge a card, send a confirmation email, update the ledger. Four tool calls in a row.
We ran these through an agent framework. Like most of them these days, it promised checkpoints, retries, and resilience, and it delivered. State got saved and failed steps got retried, just like the docs said.
Then one afternoon the fourth step threw an exception. The framework saved a checkpoint and retried, the way it was supposed to. The retry failed too. But the card was still charged.
That charge is a side effect, and it had already gone out into the world. The framework had nothing to say about it. Frameworks track execution. They don't track what your tools did to real systems, and that part is on you.
If you've shipped an agent that touches anything real, you know this feeling. You've probably been patching around it for a while.
The code we all end up writing
If you've built an agent that moves money, there's almost certainly a block in your codebase that looks like this. There was in ours.
const completedSteps: Array<{ tool: string; result: any }> = [];
try {
const balance = await checkBalance(accountId);
completedSteps.push({ tool: "check_balance", result: balance });
const charge = await stripe.charges.create({ amount, source });
completedSteps.push({ tool: "charge_card", result: charge });
const email = await sendConfirmation(userEmail, charge.id);
completedSteps.push({ tool: "send_email", result: email });
const ledgerEntry = await updateLedger(charge.id, amount);
completedSteps.push({ tool: "update_ledger", result: ledgerEntry });
} catch (err) {
// TODO: handle partial failure
for (const step of completedSteps.reverse()) {
try {
if (step.tool === "charge_card") {
await stripe.refunds.create({ charge: step.result.id });
}
if (step.tool === "update_ledger") {
await deleteLedgerEntry(step.result.id);
}
// send_email... uhh, can't unsend. Log it? Slack alert?
// check_balance... doesn't matter, skip
} catch (cleanupErr) {
// if the cleanup fails... log and hope?
console.error("Cleanup failed:", cleanupErr);
await postToSlack(`⚠️ Manual intervention needed: ${step.tool}`);
}
}
}This works fine for one workflow. Then you build a second one with different tools and a different cleanup path, so you copy the pattern over. By the third, someone factors out a helper, and the helper starts growing flags. Tools that can't be undone. Tools that need approval before they run. Before long nobody agrees on what the flags mean. This is roughly where new hires would get stuck. They'd ask which tools were safe to retry, and the only honest answer was to go read the source of each one.
Underneath it all, you're answering the same three questions by hand every time.
- Can this be undone?
- If yes, how?
- What if the undo fails?
The answers drift from one workflow to the next, and the audit trail ends up scattered across Slack threads and log searches.
So I built Unwind
Unwind is a small library that makes you answer those three questions up front, when you define the tool, instead of at 2am when something breaks in production. Every tool gets one of four labels.
const checkBalance = unwind.tool({
name: "check_balance",
effectClass: "idempotent",
execute: async ({ account_id }) => getBalance(account_id),
});
const chargeCard = unwind.tool({
name: "charge_card",
effectClass: "reversible",
execute: async (args) =>
stripe.charges.create({
amount: args.amount,
source: args.source,
idempotency_key: args.__idempotencyKey,
}),
compensate: async (_args, result) =>
stripe.refunds.create({ charge: result.id }),
});
const sendEmail = unwind.tool({
name: "send_email",
effectClass: "append-only",
execute: async (args) => email.send(args),
});
const deleteAccount = unwind.tool({
name: "delete_account",
effectClass: "destructive",
execute: async (args) => db.accounts.delete(args.account_id),
approvalGate: async (args) => requestHumanApproval(args),
});Four labels. Four sets of rules.
Idempotent. No real side effect. Calling it twice does the same thing as calling it once. Unwind skips these during rollback because there's nothing to undo. checkBalance, getUser, lookupPrice.
Reversible. The effect can be undone mechanically. TypeScript won't compile if you mark a tool reversible without writing its compensate function, so if the code builds, the rollback exists. chargeCard, createHold, transferFunds.
Append-only. It happened and you can't take it back. An email went out, a webhook fired, a row landed in an immutable log. Unwind doesn't pretend to undo these. It records what escaped, like the recipient, payload, and timestamp, so an operator can see the blast radius without digging through logs. sendEmail, postWebhook, writeAuditLog.
Destructive. Irreversible, and serious enough that the agent shouldn't fire it without a human watching. Unwind blocks the call behind an approval callback you write, and the tool waits until the gate clears. deleteAccount, revokeAccess, terminateSubscription.
What I like here is that the labels are documentation the compiler enforces. You read a tool definition and you know what it does, how it gets undone, and whether it needs a human.
Compensation in practice
When a workflow falls over, you call one function.
const summary = await unwind.compensate(runId);Unwind walks back through the completed steps in reverse and applies whatever each tool's effect class calls for. When it finishes, the operator sees something like this.
Compensation summary for run_7f3a2b:
✓ Compensated: charge_card → refunded (re_abc123)
⚠ Uncompensatable: send_email to user@acme.com
subject: "Your card was charged"
sent at: 2025-04-15T14:23:07Z (irreversible)
○ Skipped: check_balance (idempotent, no side effect)
✗ Failed: (none)Every question you'd ask during an incident is answered right there. The charge got refunded. The email already went out, and you know who got it and when. The balance check did nothing, so there's nothing to undo. Nothing failed.
Without it, the same incident is a Slack ping that just says ⚠️ Manual intervention needed: charge_card. Then an engineer spends twenty minutes bouncing between Stripe and the logs to find out whether the refund actually went through. The structured summary turns that into about five seconds of triage.
Rollback can fail too. Stripe has a bad day, or the network hiccups. When that happens, the summary shows it.
✗ Failed: charge_card → refund attempt failed
error: stripe_api_timeout
original charge: ch_xyz789, $49.00
action required: manual refundThe event store keeps enough context that you can retry the failed rollback later, or hand it to a human with everything they need to fix it by hand.
“I can write this in 50 lines”
Sure, it's fifty lines of code, so why pull in a dependency? For one workflow with three tools, you're right, you can. The hand-rolled version starts to break in three places.
Shared tools across workflows. Our chargeCard tool showed up in payouts, subscription billing, and refunds. The surrounding cleanup was different each time, but the charge tool's own rollback, calling Stripe's refund API, was always the same. Without a shared definition you copy that refund logic into every workflow, and the copies drift.
Compensation ordering. Say six steps succeed and the seventh fails. Rollback runs in reverse. If one step's rollback depends on another finishing first, like releasing a hold only after reversing a transfer, the order matters, and a hand-rolled version gets subtle fast. Unwind reads its event store, so it knows what ran in what order and replays in the right sequence.
Auditability. An ad hoc try/catch gives you console logs. Then compliance asks what happened to the charge on account X at 3:47pm on Tuesday, and you don't want the answer to involve a log search. Unwind writes every call and every rollback outcome to an append-only event store with structured fields you can query.
You hit complexity faster than you'd expect. Once you factor in branching logic, individual cleanup for each tool, handling failures during compensation, and logging, a workflow with five tools can easily balloon to 80 to 120 lines of custom try/catch code. With Unwind that logic lives in the tool definition, three to five lines per tool, written once. A new workflow that reuses those tools adds no rollback code at all. Hand-rolled scales with the number of workflows. Unwind scales with the number of unique tools, which grows a lot slower.
How it fits your stack
Ultimately, Unwind doesn't run your agent, schedule your retries, or store your state. It only wraps tool calls. Whatever you use today stays in charge.
Raw Anthropic SDK
With the raw Anthropic SDK, you convert your tools to Anthropic's schema, send each tool_use block through Unwind, and call compensate if anything throws.
import { UnwindClient } from "@unwind/core";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const unwind = new UnwindClient({ store: "sqlite://unwind.db" });
// Register your tools (definitions from above)
const tools = [checkBalance, chargeCard, sendEmail];
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
tools: unwind.anthropicTools(tools),
messages,
});
let step = 0;
for (const block of response.content.filter(b => b.type === "tool_use")) {
const result = await unwind.handleToolUse(runId, step++, block, tools);
// Feed result back into the conversation as a tool_result message
messages.push({
role: "user",
content: [{
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(result),
}],
});
}
// On failure anywhere in the loop:
const summary = await unwind.compensate(runId);unwind.anthropicTools() gives you the JSON schema Anthropic expects. unwind.handleToolUse() runs the tool, records the call and its result, and returns the output. That's the whole integration.
LangGraph
With LangGraph, you drop Unwind into the tool node. LangGraph keeps handling state, checkpoints, and branching. Unwind takes over the one moment a tool actually changes something.
import { StateGraph } from "@langchain/langgraph";
import { UnwindClient } from "@unwind/core";
const unwind = new UnwindClient({ store: "sqlite://unwind.db" });
// Wrap your existing LangGraph tool functions
const toolNode = async (state) => {
const toolCall = state.messages.at(-1).tool_calls[0];
const unwindTool = unwind.resolve(toolCall.name);
try {
const result = await unwind.execute(
state.runId, state.step++, unwindTool, toolCall.args
);
return {
messages: [new ToolMessage({
content: JSON.stringify(result),
tool_call_id: toolCall.id,
})],
};
} catch (err) {
// LangGraph catches the error and routes to your failure node
// Your failure node calls unwind.compensate(state.runId)
throw err;
}
};
const graph = new StateGraph({ channels: schema })
.addNode("agent", agentNode)
.addNode("tools", toolNode)
.addNode("failure", async (state) => {
const summary = await unwind.compensate(state.runId);
return { compensationSummary: summary };
})
.addEdge("agent", "tools")
.addConditionalEdges("tools", routeOnError, {
success: "agent",
error: "failure",
});And if LangGraph resumes from a checkpoint, Unwind's event store already knows which tools ran, so your idempotent calls won't fire twice.
Temporal
With Temporal, you wrap each tool call in an Activity and call compensate from your workflow's failure handler.
import { proxyActivities } from "@temporalio/workflow";
import { UnwindClient } from "@unwind/core";
// Activities
export async function executeUnwindTool(
runId: string, step: number, toolName: string, args: any
) {
const unwind = new UnwindClient({ store: process.env.UNWIND_STORE_URL });
const tool = unwind.resolve(toolName);
return unwind.execute(runId, step, tool, args);
}
export async function compensateRun(runId: string) {
const unwind = new UnwindClient({ store: process.env.UNWIND_STORE_URL });
return unwind.compensate(runId);
}
// Workflow
const { executeUnwindTool, compensateRun } = proxyActivities<typeof activities>({
startToCloseTimeout: "30s",
retry: { maximumAttempts: 3 },
});
export async function expenseApprovalWorkflow(input: WorkflowInput) {
const runId = workflowInfo().workflowId;
let step = 0;
try {
await executeUnwindTool(
runId, step++, "check_balance", { account_id: input.accountId }
);
await executeUnwindTool(
runId, step++, "charge_card", { amount: input.amount, source: input.source }
);
await executeUnwindTool(
runId, step++, "send_email", { to: input.userEmail, subject: "Charged" }
);
} catch {
const summary = await compensateRun(runId);
// Temporal records the summary in workflow history
// Route to human task queue if summary contains failures
}
}Temporal still handles retries, timeouts, and durability. Unwind answers the question Temporal doesn't. When an activity succeeds but the workflow fails three steps later, something has to deal with the side effect that activity left behind.
Where this gets hard
There's still work to be done. Not every tool stays in one class for its whole life. A payment hold is reversible for seven days, then it settles and becomes append-only. A message is reversible until the recipient reads it. Right now Unwind locks each tool to one class when you define it. My workaround is to mark it reversible and have compensate check the window and throw if it's expired. The throw lands in the summary as a failed compensation with a clear reason. Time-bounded effect classes are on the roadmap, but they haven't shipped yet.
Classification can also be a total judgment call. Whether a database upsert is idempotent or reversible depends on whether the previous value matters. Whether a Slack ping is append-only or idempotent depends on whether a duplicate would confuse anyone. Unwind makes you pick, which beats not thinking about it, but it won't make the call for you. When I'm unsure I classify conservatively. Append-only is safer than idempotent, and destructive is safer than reversible.
Before you install
Even if you never install Unwind, try this. Open the file with your tool definitions and write the right class next to each one. You'll probably find your gaps within ten minutes. Reversible tools with no rollback. Append-only effects nobody logs. Destructive calls with no approval gate. The audit is worth doing on its own. And if what you find worries you, that's what the library is for.
Unwind is open source, MIT licensed, written in TypeScript.
npm install @unwind/coregithub.com/amoorching/unwind ↗
I hope you found this post interesting. Feel free to reach out to me on Twitter/X or email me at akash[dot]moorching[at]gmail[dot]com to discuss any thoughts :)