In a LangGraph workflow, how would you pause before an irreversible action, present the human with enough context to make a decision, resume safely after approval, and handle rejection, timeout, or a stale approval?
Pause with LangGraph interrupt immediately before the side effect, persist the run with a checkpointer, and show a structured request tied to a request ID, version, and expiry. Resume the same thread with Command(resume=...), then revalidate the request and use an idempotent, conditionally accepted action; rejection, timeout, and stale approvals become explicit terminal or re-review outcomes.
How to think about it
If an agent is about to issue a $48,000 wire or delete production data, I stop it at a LangGraph interrupt() immediately before the side effect, persist the graph with a checkpointer, and show a structured approval request tied to a request ID, version or hash, and expiry. The human resumes the same thread_id with Command(resume=...); the graph treats approve, reject, and timeout as explicit outcomes, revalidates the request against authoritative state, and executes only an idempotent, conditionally accepted action.
Why this is the LangGraph mechanism
The interviewer is probing for durable execution, not just a yes-or-no prompt.
interrupt() pauses the graph and exposes a value to the caller. That value becomes the payload for an approval UI, queue, or internal operations console. A checkpointer saves the graph state while it waits. The thread_id identifies that saved execution, so a later resume continues the same workflow instead of starting a second one.
The important boundary is this:
- Gather and present the proposed action.
- Pause with
interrupt(). - Receive a decision.
- Revalidate the action.
- Perform the irreversible side effect.
The side effect belongs after the interrupt. When LangGraph resumes an interrupted node, the node starts again from its beginning and runs until it reaches the interrupt. Code before the interrupt must therefore be safe to repeat. A database read is usually fine. Sending the wire is not.
The approval payload should describe the exact action, not merely say “the agent wants permission.” For a wire, show the payee, amount, currency, destination account with sensitive digits masked, invoice, reason, policy checks, evidence links, the requesting user or agent, and an expiry time. Show the proposed change rather than hidden chain-of-thought. An operator needs evidence and consequences, not a transcript of every token the model considered.
A concrete wire-transfer example
Suppose an accounts-payable agent proposes a $48,000 USD wire to Acme Logistics for invoice INV-8472. The request expires after 15 minutes because the payment details can change while somebody is checking email.
The relevant LangGraph calls look like this. send_if_current is application-specific pseudocode, not a LangGraph API. Its job is to compare the stored revision, enforce expiry, and submit the wire exactly once.
def review_and_send_wire(state):
request = state["wire_request"]
decision = interrupt({
"kind": "wire_approval",
"request_id": request["request_id"],
"revision": request["revision"],
"payee": request["payee"],
"amount": request["amount"],
"currency": request["currency"],
"invoice": request["invoice"],
"reason": request["reason"],
"evidence_urls": request["evidence_urls"],
"expires_at": request["expires_at"],
})
if (
decision.get("request_id") != request["request_id"]
or decision.get("revision") != request["revision"]
):
return {"status": "stale"}
choice = decision.get("decision")
if choice == "reject":
return {"status": "rejected", "reason": decision.get("reason")}
if choice == "timeout":
return {"status": "expired"}
if choice != "approve":
return {"status": "invalid_decision"}
if not wire_store.is_current_and_unexpired(
request["request_id"], request["revision"]
):
return {"status": "stale"}
result = wire_store.send_if_current(
request_id=request["request_id"],
expected_revision=request["revision"],
amount=request["amount"],
currency=request["currency"],
idempotency_key="wire:" + request["request_id"],
)
return {"status": result}
The graph must be compiled with a checkpointer. In production, that means a durable backend rather than an in-memory checkpointer that disappears when the worker restarts.
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "wire-8472"}}
graph.invoke({"wire_request": request}, config)
graph.invoke(
Command(resume={
"decision": "approve",
"request_id": "req-8472",
"revision": 3,
}),
config,
)
The first invocation pauses and returns an interrupt record containing the approval payload. The UI sends the decision to a backend, and the backend resumes the same graph with the same thread_id. The approval service should not send the wire directly. It should only submit the decision. The graph remains the place that validates and routes the outcome.
A conditional edge after this node can send approved to the transfer node, rejected to an audit-and-notify node, and stale or expired back to a new approval request. The exact graph shape is less important than making every outcome explicit.
| Outcome | Result |
|---|---|
| Approved and current | Execute once |
| Rejected | No side effect; record who rejected it |
| Timeout | Mark expired; optionally request a fresh approval |
| Stale | Do not execute; refresh the facts and ask again |
| Invalid decision | Fail closed and alert the workflow owner |
Rejection, timeout, and stale approval
Rejection is not an exception. It is a valid business result. Store the approver, timestamp, reason, and request revision, then end the risky branch without retrying until somebody creates a new request.
An interrupt is a pause, not a timer. Do not assume the graph will wake itself after 15 minutes. A scheduler or workflow worker should find approval requests whose deadline has passed and resume the same thread with a synthetic decision such as {"decision": "timeout", ...}. That follows the same code path as a human response and keeps timeout behavior auditable.
A human can also approve an old screen after the request has changed. That is a stale approval. Bind the decision to a request ID and revision, or to a hash of the material facts. On resume, compare those values with the persisted state and with the authoritative payment store. The amount used for execution must come from trusted state, not from fields supplied by the browser.
The final write needs a conditional check as well. A separate “is this current?” read followed by a send still has a race: another process can change the request between the two operations. The payment service or database must enforce the revision check atomically. The idempotency key then protects against a worker crash after the provider accepted the wire but before LangGraph recorded success.
The senior-level trade-off
Human approval adds latency, operational cost, and approval fatigue. Putting a gate in front of every tool call creates a rubber-stamp ceremony; after the 40th low-risk lookup, nobody reads the screen. I would use policy-based gating: automatic execution for reversible, low-value actions; approval for high-value, external, destructive, or legally sensitive actions.
The approval screen should also show what happens if the operator does nothing. “Expires in 15 minutes” is useful. “Waiting” is not.
The most common failure appears at 3 a.m. as a duplicate payment or an approval that seems to vanish. Duplicate effects usually mean a side effect happened before interrupt(), or the external operation lacked an idempotency key. A missing or different thread_id, or a non-durable checkpointer, produces the other symptom: the UI says approved, but the resumed graph cannot find the paused execution.
Treat the resume payload as untrusted input. Validate its shape, keep the action details in server-side state, and fail closed on unknown decisions. Also avoid wrapping interrupt() in a broad try and except; its control flow is intentionally used to pause execution.
What they’ll ask next
What happens if the approver and the timeout worker respond at the same time?
Make approval completion a single conditional state transition. The first valid transition from pending wins. The other response rereads the durable record, sees approved or expired, and becomes a no-op or conflict. Do not rely on two application workers politely arriving in order.
How do you prevent a duplicate transfer after a worker restart?
Use an idempotency key derived from the request ID, and make the external payment provider or an intermediary store honor it. Record the provider’s operation ID. On retry, query that operation before submitting another one. LangGraph persistence prevents lost workflow state; it does not make an arbitrary side effect idempotent.
What if the vendor bank account changes while the request is waiting?
Increment the request revision or invalidate the request in the authoritative store. A later approval then fails the revision check and produces stale, never a transfer. Rebuild the context and ask for approval again.
One line to say in the room
“I put interrupt() directly before the irreversible effect, persist the run and resume the same thread, bind approval to a versioned request with an expiry, then revalidate and execute atomically with idempotency; reject, timeout, and stale approvals all fail closed.”