Skip to content
Get started
Get started
Agent SDKs: Claude, OpenAI, Mastra & LangGraph
guide · workflow engine

Your agent SDK, inside a transaction.

Four agent frameworks, one integration shape. Each gives you an excellent agent loop. None gives you a way to undo what that loop did to the outside world. That is the part this page adds.

These SDKs are harnesses: they run the model, dispatch tools, manage context. That is a hard problem and they solve it well. What they do not solve is the problem one layer out, where a single agent turn is one step of a business process that also charges cards, opens pull requests and waits for a person.

This page covers the Claude Agent SDK, the OpenAI Agents SDK, Mastra and LangGraph. Read one section and you have read all four, because the seam is identical every time: the agent turn becomes one .step(), and that step declares what to do if a later step fails.

The agent framework ownsbunqueue owns
The loopModel calls, tool dispatch, contextNothing, it stays out of the way
ConversationSession and transcriptWriting that session id to disk
EffectsRuns your tool functionsThe inverse of each one
FailureSurfaces the errorUnwinding everything that already happened
A person in the middleNoA gate that parks the run for days

The seam is small. An agent turn becomes one .step(), and that step declares what to do if a later step fails.

Terminal window
bun add @anthropic-ai/claude-agent-sdk

query() returns an async iterable of messages. Two of them matter for durability: the init message carries the session id, and the result message carries the final text and the same id. Journal the id and a later turn can continue the same conversation.

import { query } from '@anthropic-ai/claude-agent-sdk';
async function agentTurn(prompt: string, cwd: string, resume?: string) {
const q = query({
prompt,
options: {
model: 'claude-sonnet-5',
cwd,
maxTurns: 6,
allowedTools: ['Read', 'Write', 'Edit'],
permissionMode: 'bypassPermissions',
...(resume ? { resume } : {}),
},
});
let sessionId = '';
let text = '';
for await (const message of q) {
if (message.type === 'system' && message.subtype === 'init') sessionId = message.session_id;
if (message.type === 'result' && message.subtype === 'success') {
text = message.result;
sessionId = message.session_id;
}
}
return { sessionId, text };
}

An upgrade bot creates a branch, opens a draft PR, lets the agent edit the code, then waits for a human. If the review says no, the branch and the PR have to go away:

const flow = new Workflow<{ repo: string }>('dependency-upgrade')
.step('create-branch', async (ctx) => {
forge.branches.add(`${ctx.input.repo}#bot/upgrade`);
return { branch: 'bot/upgrade' };
}, {
compensate: async (ctx) => { forge.branches.delete(`${ctx.input.repo}#bot/upgrade`); },
})
.step('open-draft-pr', async () => {
forge.prs.set('PR-7', 'open');
return { pr: 'PR-7' };
}, {
compensate: async () => { forge.prs.set('PR-7', 'closed'); },
})
.step('agent-edit', async () => {
const r = await agentTurn(
'Edit VERSION.txt so it reads exactly "lodash 4.17.21". Then reply with the single word DONE.',
cwd,
);
return { sessionId: r.sessionId, reply: r.text };
})
.waitFor('code-review', { timeout: 600_000 })
.step('merge', async (ctx) => {
const decision = ctx.signals['code-review'] as { approved: boolean };
if (!decision.approved) throw new Error('review rejected the upgrade');
return { merged: true };
}, { retry: 1 });

While the run is parked at the gate it holds no worker slot. Its durable state is in SQLite, with one lightweight timer in memory while a timed gate’s process is alive. The review can arrive an hour later, or after the engine has been recreated and recovered following a redeploy.

Note that agent-edit declares no compensate. It edited a working copy on a branch that is about to be deleted, so there is nothing to undo, and a step without an inverse simply carries no compensation record. Only steps that changed something outside the branch need one.

Unedited output of scripts/agent-sdks/claude-agent-live.ts, which executes exactly the code above against the live API:

bun scripts/agent-sdks/claude-agent-live.ts
[S1] branch created (1 open)
[S1] draft PR opened
[S1] agent session 229a3785 replied: DONE
[S1] parked at the review gate, PR is open
[S1] file on disk: lodash 4.17.21
[S1] compensate: PR closed
[S1] compensate: branch deleted
[S1] rollbackStatus=completed prs=closed branches=0
[S2] turn 1 session 3c6cd752 → OK
[S2] turn 2 session 3c6cd752 → FLAMINGO
[S2] same session=true remembered=true
────────────────────────────────────────
PASS S1 rejected review unwinds around a live agent turn
PASS S2 journaled session id continues the conversation
2/2 passed
────────────────────────────────────────

The agent really did edit the file, the review really did reject it, and the unwind closed the PR before deleting the branch, which is reverse start order.

Resume continues the conversation, not just the process

Section titled “Resume continues the conversation, not just the process”

The second scenario is the durability claim, made concrete. Turn one is told a codeword. The session id goes through the journal. Turn two reads that id back and asks for the codeword:

const flow = new Workflow('agent-two-phase')
.step('turn-1', async () => {
const r = await agentTurn('Remember this codeword: FLAMINGO. Reply with just: OK', cwd);
return { sessionId: r.sessionId, text: r.text };
})
.step('turn-2', async (ctx) => {
const prev = ctx.steps['turn-1'] as { sessionId: string };
// The id came out of the journal, not out of a variable in this process.
const r = await agentTurn('What was the codeword? Reply with just the word.', cwd, prev.sessionId);
return { sessionId: r.sessionId, text: r.text };
});

same session=true remembered=true in the output above. Because the id is on disk rather than in a closure, the same continuation works after recover() picks the run up on a restarted process.

Terminal window
bun add @openai/agents

The shape is the same, with one extra wrinkle worth being careful about: here the side effects happen inside the agent’s tools, not in your step body. The step needs to know what its tools did so its compensate handler can undo it.

Collect what the tools did, return it, undo it

Section titled “Collect what the tools did, return it, undo it”
import { Agent, run, tool } from '@openai/agents';
import { z } from 'zod';
type Effect = { refundId: string };
let effects: Effect[] = [];
const issueRefund = tool({
name: 'issue_refund',
description: 'Issue a refund to the customer',
parameters: z.object({ orderId: z.string(), amount: z.number() }),
execute: async ({ orderId, amount }) => {
const refundId = ledger.issue(`rf_${orderId}`, amount);
effects.push({ refundId }); // remember it
return `refund ${refundId} issued`;
},
});
const agent = new Agent({
name: 'refund-agent',
instructions: 'Refund the customer when the claim is valid.',
tools: [issueRefund],
});
const flow = new Workflow<{ orderId: string }>('refund-flow')
.step('agent-turn', async (ctx) => {
effects = [];
const result = await run(agent, `Refund order ${ctx.input.orderId}, 49 euro.`);
return { text: result.finalOutput, effects: [...effects] }; // journal it
}, {
compensate: async (ctx) => {
const record = ctx.steps['agent-turn'] as { effects: Effect[] } | undefined;
for (const e of record?.effects ?? []) ledger.reverse(e.refundId); // undo it
},
})
.step('notify-customer', async () => {
throw new Error('mailer unreachable');
});

The mailer fails, the unwind runs, and the refund the model decided to issue is reversed. The model chose the action; the workflow owns the consequence.

run() returns a history you can hand back as the input of the next call, which makes the second step a continuation rather than a restart. Journal it and that continuation survives a crash:

import { type AgentInputItem, run } from '@openai/agents';
const flow = new Workflow('two-turn-agent')
.step('turn-1', async () => {
const r = await run(agent, 'What is the capital of France?');
return { text: r.finalOutput, history: r.history };
})
.step('turn-2', async (ctx) => {
const prev = ctx.steps['turn-1'] as { history: AgentInputItem[] };
const r = await run(agent, [...prev.history, { role: 'user', content: 'And its population?' }]);
return { text: r.finalOutput, turns: r.history.length };
});

This is the same trick as the Claude Agent SDK’s session id, with the transcript carried explicitly instead of by reference.

const flow = new Workflow('purge-with-approval')
.step('agent-purge', async () => {
const r = await run(agent, 'Purge the stale records.');
return { text: r.finalOutput, deleted: [...store.deleted] };
}, {
compensate: async (ctx) => {
const rec = ctx.steps['agent-purge'] as { deleted: string[] } | undefined;
store.restored.push(...(rec?.deleted ?? []));
},
})
.waitFor('operator-approval', { timeout: 86_400_000 })
.step('commit', async (ctx) => {
const decision = ctx.signals['operator-approval'] as { approved: boolean };
if (!decision.approved) throw new Error('operator rejected the purge');
return { committed: true };
}, { retry: 1 });

retry: 1 matters here. A deliberate throw should be believed the first time, not retried five times with backoff before the rollback starts.

Terminal window
bun add @mastra/core @ai-sdk/anthropic

Mastra agents take a Vercel AI SDK model, so the model side is whatever you already use. Tools are declared with createTool, and the effect collection pattern is the same as the OpenAI SDK.

import { anthropic } from '@ai-sdk/anthropic';
import { Agent } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
let effects: string[] = [];
const refundTool = createTool({
id: 'issue_refund',
description: 'Issue a refund',
inputSchema: z.object({ orderId: z.string(), amount: z.number() }),
// Mastra hands the validated input straight to `execute`. It is NOT wrapped in
// `{ context }`, which is the shape older examples on the internet still show.
execute: async ({ orderId }) => {
const id = `rf_${orderId}`;
ledger.issued.push(id);
effects.push(id);
return { refundId: id };
},
});
const agent = new Agent({
id: 'refunder', // required by Mastra's types, distinct from `name`
name: 'refunder',
instructions: 'Refund the customer.',
model: anthropic('claude-sonnet-5'),
tools: { refundTool },
});
const flow = new Workflow('mastra-refund')
.step('agent-turn', async () => {
effects = [];
const r = await agent.generate('Refund order ORD-9, 49 euro.');
return { text: r.text, effects: [...effects] };
}, {
compensate: async (ctx) => {
const rec = ctx.steps['agent-turn'] as { effects: string[] } | undefined;
for (const id of rec?.effects ?? []) ledger.reverse(id);
},
})
.step('notify', async () => { throw new Error('mailer unreachable'); });

The name advertised to the model is the key in the tools object, refundTool here, not the tool’s id. Mastra resolves a call made by id as well, so both work at runtime, but only the key appears in the tool schema. Worth knowing when you are reading a trace and wondering why issue_refund never shows up in it.

Terminal window
bun add @langchain/langgraph @langchain/core

LangGraph is the odd one out, because it is a graph rather than an agent loop, and it has its own checkpointers for graph state. What a checkpointer stores is where the graph got to. What it does not store is how to undo the resources the graph created on the way, which is the gap worth closing.

Compile the graph once, then invoke it inside a step:

import { StateGraph, Annotation, START, END } from '@langchain/langgraph';
const State = Annotation.Root({
tenant: Annotation<string>,
resources: Annotation<string[]>,
});
const graph = new StateGraph(State)
.addNode('create-db', async (s) => {
provisioner.create(`db:${s.tenant}`);
return { resources: [...(s.resources ?? []), `db:${s.tenant}`] };
})
.addNode('create-bucket', async (s) => {
provisioner.create(`bucket:${s.tenant}`);
return { resources: [...(s.resources ?? []), `bucket:${s.tenant}`] };
})
.addEdge(START, 'create-db')
.addEdge('create-db', 'create-bucket')
.addEdge('create-bucket', END)
.compile();
const flow = new Workflow<{ tenant: string }>('langgraph-provision')
.step('run-graph', async (ctx) => {
const out = await graph.invoke({ tenant: ctx.input.tenant, resources: [] });
return { resources: out.resources };
}, {
compensate: async (ctx) => {
const rec = ctx.steps['run-graph'] as { resources: string[] } | undefined;
// Reverse, because the graph created them in order.
for (const r of [...(rec?.resources ?? [])].reverse()) provisioner.destroy(r);
},
})
.step('bill', async () => { throw new Error('billing provider rejected the tenant'); });

Because the graph accumulates its resources into state, the step gets the undo list for free. Billing fails and the bucket is destroyed before the database, matching the order they were created in.

Whichever SDK you use, the mistake is the same: putting the compensation logic where it can only see process memory.

WrongRight
Handler closes over a local arrayHandler reads ctx.steps[...]
Effects tracked in a module variableEffects returned by the step
Undo keyed on an in-memory counterUndo keyed on ctx.forwardIdempotencyKey

The first column passes every test you write on your laptop and does nothing at all the first time a pod is replaced mid-run.

Built-in toolsConversation carried byRuns as
Claude Agent SDKRead, Write, Edit, Bash, Grep, WebSearchA session id you resumeA subprocess harness
OpenAI Agents SDKNone, you supply every toolA history array you pass backIn process
MastraNone, createToolThreads and memory, or explicit messagesIn process
LangGraphNone, nodes are functionsGraph state, plus optional checkpointersIn process

None of them is a workflow engine and none claims to be. All four drop into a .step() the same way.

Nothing on this page is illustrative.

ExampleHow it was verified
Claude Agent SDK saga and session resumescripts/agent-sdks/claude-agent-live.ts, live API, 2/2 passed, output above
OpenAI Agents SDK rollback, history, approvaltest/workflow-agent-sdks.test.ts
Mastra tool rollbacksame file
LangGraph graph rollbacksame file

Every framework in that file is the real package, installed from npm. The agent loops, tool dispatch and argument validation are genuinely theirs. What is substituted is only the model: a scripted Model for the OpenAI SDK, and MockLanguageModelV3 for Mastra. That keeps the suite deterministic and lets it run with no network. LangGraph needs no substitution at all, since its nodes are plain functions.

The Claude Agent SDK is the exception. query() spawns the Claude Code harness and needs both a network and credentials, so it cannot run in the isolated gate. Its verification is the live script above, whose output is pasted unedited. What the offline suite covers for it is the part that is ours: journaling the session id, and unwinding the effects around the agent turn.

Next: Durability and idempotency keys for what survives a restart, or Rollback for the unwind rules in full.