My ai-agent-platform has a hand-written agent runtime: the model proposes tool calls, a guard pipeline checks permissions, and risky tools (like deleting records) pause for a human to approve before they run.
Frameworks like LangGraph give you that loop, state saving and pause/resume out of the box. So I rebuilt one flow on LangGraph to see what it would actually save me, using the same tools, the same fake services and the same scripted (offline) model. The biggest lesson didn't come from the line count. It came from one test.
The flow on LangGraph
START → agent → tools → agent → … → END
agentnode: calls the model and stores its proposed tool callstoolsnode: for each proposed call, it checks the allow-list and the user's permissions, validates the arguments, pauses withinterrupt()if the tool needs approval, then executes it with a timeout- A checkpointer saves state per thread, and the run resumes with
Command(resume={"decision": "approve"})
The happy path worked nicely: a destructive call paused, ran exactly once after approval, and a rejection turned into an observation the agent could react to. A parity test confirmed both runtimes ended with the same status, output and side effects.
The test that caught it
What happens when one model turn proposes two tool calls, one that needs no approval and one that does?
calendar_create_event: safe, runs immediatelydelete_records: destructive, needs approval
async def test_side_effects_before_interrupt_rerun_on_resume() -> None:
both = call("calendar_create_event", "c1", **EVENT)
both.tool_calls.append(call("delete_records", "c2", table="orders", where="1=1").tool_calls[0])
graph, services, started = make([both, reply("done")])
await go(graph)
assert started["calendar_create_event"] == 1
await resume(graph, "t1", decision="approve")
assert started["calendar_create_event"] == 2, "node re-ran from the top"
assert len(services.calendar.events) == 1, "idempotency key deduped the repeat"
The calendar tool executed twice. It ran once before the pause, then again after approval.
Why it happens
When you call interrupt() inside a node and later resume, LangGraph doesn't continue from the line where it paused. It re-runs the whole node from the top, and interrupt() returns the resume value the second time around. This is documented behaviour, not a bug. The docs say to design side effects to be idempotent. But it means any side effect in the node before the interrupt happens again.
In my test the calendar still ended up with only one event, and only because every tool call already carried an idempotency key:
def idempotency_key(thread_id: str, call: ToolCall) -> str:
blob = json.dumps([thread_id, call.id, call.name, call.arguments], sort_keys=True)
return hashlib.sha256(blob.encode()).hexdigest()[:32]
The fake calendar service ignores a second request with the same key. Without that key, the agent would have created a duplicate event. In real life that could be a duplicate email, charge or database write.
My hand-written runtime avoids this structurally. It saves each call's outcome before moving on, and a resumed run only processes calls that don't have a result yet.
Four ways to guard against it
- Make every side effect idempotent. Derive a key from (thread, call ID, tool name, arguments) and have the tool or service dedupe on it. This is the safety net you want anyway, because retries cause the same problem.
- Put side-effecting calls in their own node, after the approval node, so nothing with side effects runs before an
interrupt()in the same node. - Use one node per tool call, so a resume only re-runs the call that paused.
- Wrap side effects in a LangGraph
@task. LangGraph caches task results and reuses them when it replays after a resume, so the work isn't repeated (docs). I didn't test this option in my comparison, and the idempotency key is still worth having for ordinary retries.
What LangGraph actually saved
| | Code lines | |---|---| | LangGraph version | 162 | | Hand-built runtime | ~770 |
That looks like a 4.7× win, but it isn't a fair comparison. The hand-built version also does durable runs with leases, crash-safe resume across processes, retries, loop detection, token and cost budgets, audit logs, tracing and cancellation. My honest estimate: LangGraph removes about 150–200 lines of loop, state and pause/resume plumbing. The security checks were custom code either way. LangGraph has no authorization model, so permissions and approval rules still had to be written inside the node.
When I'd pick which
- LangGraph: branching multi-step graphs, sub-agents, parallel fan-out, fast prototypes, or a team that already uses it
- Hand-built: when approvals, permissions and audit are product features, like a queryable "pending approvals for this tenant" list, and when exactly-once side effects and crash recovery need to be explained and tested explicitly
Caveats
This was one scenario, run offline with a scripted model and LangGraph's in-memory checkpointer, so it says nothing about model quality. I haven't yet tested a persistent checkpointer or a crash in the middle of a tool call on the LangGraph side. The code, tests and full write-up are in the repo (comparison/langgraph_flow.py, docs/framework-comparison.md).
Takeaway
Pause/resume primitives are powerful, but "resume" can mean "run it again". Before you put a human approval step in an agent, check exactly what re-executes on resume, and make every side effect safe to repeat.