LangGraph interrupt() vs a Write-Gate: Where Your Agent's Approval Checkpoint Should Live
Framework human-in-the-loop — LangGraph's interrupt(), the OpenAI Agents SDK's needsApproval, HumanLayer, MCP elicitation — is genuinely preventive: it pauses before the action, not after. That's the right instinct. The question is not whether to gate; it's where the checkpoint lives. Framework HITL holds the pause inside the orchestrator's runtime. A write-gate moves it to the write boundary of the system of record — so the held write survives a restart, works across any framework, is re-checked against live state at the commit, and is recorded independently. Most agents don't need that. The ones writing to a system of record you'd get paged over do.
The frameworks, accurately
None of these are strawmen. Each pauses the agent before a consequential step and hands control to a human — the definition of preventive governance. What differs is the layer the pause sits at, and what survives when things go wrong.
interrupt()
Called inside a graph node, interrupt() raises a GraphInterrupt that halts execution and surfaces a value to the client; you resume with Command(resume=value), and that value becomes the return of interrupt(). It requires a checkpointer (InMemorySaver in dev, PostgresSaver in prod) and a thread_id — the paused state is a frozen checkpoint keyed by that thread. On resume, the node re-executes from its start, so any code before the interrupt runs again; idempotency is on you.
needsApproval
Mark a tool needsApproval: true (or an async predicate). When the model calls it, the run does not execute the tool — it pauses and records a RunToolApprovalItem, returning an interrupted result. You serialize the RunState, obtain a decision, and resume from that state. Worth knowing: it pauses and surfaces the interruption, but sends no email and fires no Slack — you build the notification and queue infrastructure yourself.
Framework-agnostic: annotate a high-risk function with @hl.require_approval(); HumanLayer blocks the call and routes an approval request to Slack, Email, or Discord, with escalation paths, timeouts, and routing, and feeds a denial back into the agent's context. Operationally the most complete of the four — real human-routing infrastructure, not just a pause primitive.
An MCP server pauses mid-session and sends an elicitation/create request; the client presents it to the user — Form mode (structured input against an optional JSON schema) or URL mode (for sensitive input that must not pass through the client) — and returns the response. Standardized in the Model Context Protocol so any compliant client can render the prompt.
Where the checkpoint lives
Three of the four hold the pause inside the agent's own runtime. LangGraph's checkpointer is durable only if you wired durable storage, and it is LangGraph-shaped and keyed by thread_id — invisible to any other agent or framework. The OpenAI RunState lives in the Agents SDK's process. MCP elicitation is scoped to the session. HumanLayer externalizes the routing — a real improvement — but the thing it gates is still the tool call: "may this function run?", decided in HumanLayer's service, at the agent's function boundary.
A write-gate moves the checkpoint one layer down — to the write boundary of the system of record. The proposed write lands as a durable PENDING row in the record's own governance layer, and a human approves a specific before → after value at the database, not a function-call payload. (For the full mechanic — intercept, hold, approve, commit fail-closed, record — see The Write-Gate Pattern.) Five things follow from moving the checkpoint there:
- It survives the process — and the framework. A checkpointer survives a restart only if it's durably backed and you resume the same graph on the same framework. A write-gate's
PENDINGlives in the system of record: kill the agent, swap LangGraph for a cron job or a different SDK, and the held write is still there for a human to act on. - It re-checks live state at the commit.
interrupt()andneedsApprovalapprove "run this function" — they don't re-read the target row when the human finally clicks approve. If the row changed between propose and approve, the framework doesn't know. A write-gate is fail-closed on conflict: a stale baseline is rejected rather than silently overwriting the newer value. - It's framework-agnostic AND commit-bound. HumanLayer is framework-agnostic but gates the call; a write-gate is framework-agnostic and gates the commit. One gate at the write, honored no matter which agent or stack proposed it.
- It's recorded independently. The lesson from the July 2025 Replit incident — where an agent deleted a production database during a freeze and then misreported what it had done — is that a framework's own trace is the actor's account. A write-gate records the proposal, the human decision, and the commit in a tamper-evident, hash-chained log you don't have to take the agent's word for.
- A non-engineer can approve it.
interrupt()surfaces a JSON payload for a developer. A write-gate surfaces the actual cell or row change —1,240,000 → 1,880,000— in terms a finance or ops reviewer can judge without reading the graph.
Side by side
Honestly, across the axes that matter once the write reaches a system of record:
| Control | Preventive (pauses before the action)? | Survives process + framework restart? | At the system-of-record commit (not the tool call)? | Fail-closed on conflict (stale baseline)? | Framework-agnostic? | Independent tamper-evident record? |
|---|---|---|---|---|---|---|
LangGraph interrupt() | Yes | Only with a durable checkpointer, same framework | No — node/tool | No | No (LangGraph) | No (graph state) |
OpenAI needsApproval | Yes | No — RunState in-process | No — tool call | No | No (Agents SDK) | No |
| HumanLayer | Yes | Partial — its service holds the request | No — function call | No | Yes | No (approval log, tied to the call) |
| MCP elicitation | Yes | No — session-scoped | No — tool call | No | No (MCP session) | No |
| Write-gate (this layer) | Yes | Yes | Yes | Yes | Yes | Yes |
framework HITL agent → tool call → [ pause in orchestrator memory ] → resume → commit (interrupt()/needsApproval/elicitation: checkpointer / RunState / session) write-gate agent → write ─▶ [ PENDING at the system-of-record commit ] │ human reviews before → after approve ─▶ commit (fail-closed on conflict) reject ─▶ nothing lands │ every step ─▶ tamper-evident, independent log
When framework HITL is enough
Most of the time, it is — and adding a write-gate would be over-engineering. Reach for interrupt(), needsApproval, HumanLayer, or MCP elicitation, and stop there, when: you're on a single framework and intend to stay; the pause can be ephemeral (a lost checkpoint just means the agent re-asks); the human can trust the function payload as shown; the target isn't a system of record you'd get paged over; and no one needs an independent, tamper-evident audit of the decision. That's a large share of agent workflows. Don't buy a commit-boundary control for a conversational pause.
When you need a write-gate
The write-gate earns its place on a narrow band — writes to a system of record where the framework pause is the wrong layer:
- The write hits a production database, warehouse, or ledger that must survive restarts and outlive whichever framework proposed it.
- Approval must bind to the actual commit and catch a baseline that changed between propose and approve — not just green-light a function.
- A non-engineer (finance, ops, risk) approves the data change, not a JSON tool payload.
- An auditor or regulator needs an independent, tamper-evident maker/checker record — one the acting agent cannot write to or misreport.
A write-gate does not replace framework HITL; it sits under it. Use interrupt() for the conversational or tool-level pause, and a write-gate for the commit to the system of record. Different layers, both preventive. For where each belongs, see Preventive vs Reactive AI Agent Governance.
FAQ
Isn't LangGraph interrupt() with a Postgres checkpointer already durable?
Durable, yes — but durability is one axis. The Postgres-backed checkpoint still lives in LangGraph's state, keyed by thread_id, and you must resume the same graph on the same framework. It doesn't move the approval to the system-of-record commit, doesn't re-check the target row for a changed baseline at approve time, and isn't an independent record another team or auditor can trust. A write-gate is about where the checkpoint sits, not just whether it persists.
Can I just use HumanLayer?
For routing approvals to the right human via Slack/email with escalation and timeouts, HumanLayer is excellent and framework-agnostic. But it gates the function call, not the database commit: it doesn't re-read the target row at approve time, and its approval log is tied to the agent's call rather than to the system of record. Pair it with a write-gate when the write lands in a system of record you'd get paged over.
Does a write-gate replace interrupt() / needsApproval?
No — it's a different layer, and they compose. Framework HITL handles the in-agent pause (ask a clarifying question, confirm a plan). The write-gate handles the commit to the system of record (hold the write PENDING, approve the specific before→after, fail-closed). Keep both.
MCP elicitation is at the server — isn't that already close to the write?
Closer, but still the tool-call layer and scoped to the session: the server asks the client for confirmation before invoking a tool, and the pause disappears with the session. A write-gate holds the proposed write in the system of record itself, so it outlives the session and any single agent, and commits fail-closed against live state.
Is this only for databases?
No — any system of record with a write boundary: a relational database, a data warehouse, a ledger, a governed spreadsheet. The pattern is "gate at the commit to the record," wherever that record lives.
Penholder
Penholder is a write-gate. An agent's write to Postgres, MySQL, a warehouse (BigQuery, Snowflake, Redshift, ClickHouse), SQL Server, Oracle, or a governed spreadsheet lands PENDING and commits only after a human approves — fail-closed on conflict, with a tamper-evident log. Framework-agnostic, at the write boundary. See how it works →
Sources
- LangGraph —
interrupt()reference (GraphInterrupt,Command(resume=…), checkpointer + thread_id, node re-execution on resume). - OpenAI Agents SDK — Human-in-the-loop guide (
needsApproval, RunToolApprovalItem, RunState resume) and Guardrails and human review. - HumanLayer — humanlayer.dev (
@hl.require_approval(), Slack/Email/Discord routing, escalation and timeouts, framework-agnostic). - Model Context Protocol — Elicitation specification (
elicitation/create, Form and URL modes) and WorkOS: MCP elicitation. - The provenance point — AI Incident Database, Incident 1152 and Fortune (the agent misreported what it had done).