Penholder ← penholder.ai

LangGraph interrupt() vs a Write-Gate: Where Your Agent's Approval Checkpoint Should Live

Reference · Updated 26 August 2026

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.

LangGraph — 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.

Pause lives in: the LangGraph checkpointer (graph state), keyed by thread_id.
OpenAI Agents SDK — 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.

Pause lives in: the Agents SDK RunState, in the orchestrator process.
HumanLayer

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.

Pause lives in: HumanLayer's service, at the agent's function-call boundary.
MCP elicitation

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.

Pause lives in: the MCP session (client ↔ server), session-scoped.

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:

Side by side

Honestly, across the axes that matter once the write reaches a system of record:

ControlPreventive (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()YesOnly with a durable checkpointer, same frameworkNo — node/toolNoNo (LangGraph)No (graph state)
OpenAI needsApprovalYesNo — RunState in-processNo — tool callNoNo (Agents SDK)No
HumanLayerYesPartial — its service holds the requestNo — function callNoYesNo (approval log, tied to the call)
MCP elicitationYesNo — session-scopedNo — tool callNoNo (MCP session)No
Write-gate (this layer)YesYesYesYesYesYes
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:

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

  1. LangGraph — interrupt() reference (GraphInterrupt, Command(resume=…), checkpointer + thread_id, node re-execution on resume).
  2. OpenAI Agents SDK — Human-in-the-loop guide (needsApproval, RunToolApprovalItem, RunState resume) and Guardrails and human review.
  3. HumanLayer — humanlayer.dev (@hl.require_approval(), Slack/Email/Discord routing, escalation and timeouts, framework-agnostic).
  4. Model Context Protocol — Elicitation specification (elicitation/create, Form and URL modes) and WorkOS: MCP elicitation.
  5. The provenance point — AI Incident Database, Incident 1152 and Fortune (the agent misreported what it had done).