LangGraph human-in-the-loop: interrupt, approve, and resume from a phone
Two working patterns for LangGraph human-in-the-loop with Pushary: a blocking decisions.ask tool inside a node, and interrupt() with a webhook-driven resume.
To add human-in-the-loop to a LangGraph agent you have two working patterns. Pattern A calls decisions.ask() from inside a node and blocks until a person answers on their phone. Pattern B pauses the graph with LangGraph's native interrupt() and resumes it with Command(resume=answer) when Pushary's webhook delivers the human's decision. Both are fail-closed, both write to a durable ledger, and the right one depends on how long the wait can be.
Key takeaways
- LangGraph gives you the pause:
interrupt()checkpoints the graph. It sends no notification, so the person who should decide never learns the graph is waiting. Pushary supplies the person. - Pattern A (blocking ask in a node) is the simple path for short waits. Pattern B (interrupt plus webhook resume) parks the graph with zero compute for long waits.
- Two footguns are worth writing down: nodes re-run from the top on resume, and the Pushary callback carries
{correlationId, answer, answeredAt}, so you keep your own correlationId-to-thread map.
Pattern A: a blocking ask inside a node
For approvals a person answers within a couple of minutes, call decisions.ask() directly in the node that guards the action. The graph holds on that node until the answer comes back.
pip install pusharyimport os
from pushary import PusharyServer
px = PusharyServer(api_key=os.environ["PUSHARY_API_KEY"])
# Connect the user's phone once, keyed to your own id.
px.enroll("user_123")
def approve_transfer(state):
decision = px.decisions.ask(
question=f"Approve transfer of {state['amount']}?",
external_id=state["user_id"],
type="confirm",
)
return {"approved": decision["approved"]}decisions.ask sends the question to that user's phone, waits durably, and returns a decision where anything short of an explicit approval reads as not approved. The external_id is your own id for your user, connected once with a keyless one-tap link, which is what lets the agent ask the person whose money or data is on the line instead of whoever is watching a terminal.
Pattern B: interrupt, then resume from the webhook
When the wait can be long, holding a run open is the wrong shape. LangGraph already solves the parking half: interrupt() checkpoints the graph into your Postgres or SQLite saver and returns control. What it does not do is tell anyone. Pair it with a decision created for a webhook resume:
import os
from langgraph.types import interrupt, Command
CORRELATION_TO_THREAD = {} # your own store, e.g. a table
def request_approval(state):
# On resume the node re-runs from the top, so a stable idempotency key makes the
# second create return the same decision instead of paging the person twice.
d = px.decisions.create(
question="Approve this deploy?",
external_id=state["user_id"],
type="confirm",
wait=False,
callback_url=os.environ["PUSHARY_CALLBACK_URL"],
idempotency_key=f"{state['user_id']}:approve-deploy",
)
CORRELATION_TO_THREAD[d["decisionId"]] = state["thread_id"]
# interrupt() pauses the graph on the first pass and returns the resumed answer
# on re-run, so capture it and return a normal dict state update.
answer = interrupt({"pushary_decision": d["decisionId"]})
return {"approved": answer in ("yes", "approve", True)}And the callback route verifies the signature, then resumes the graph:
import json, os
from pushary import verify_webhook_signature, SIGNATURE_HEADER
@app.post("/pushary/callback")
async def callback(req):
raw = await req.body()
# Fail closed on a bad signature: a forged POST must not resume the graph.
if not verify_webhook_signature(raw, req.headers.get(SIGNATURE_HEADER), os.environ["PUSHARY_WEBHOOK_SECRET"]):
return Response(status_code=401)
event = json.loads(raw) # {correlationId, answer, answeredAt}
thread_id = CORRELATION_TO_THREAD[event["correlationId"]]
graph.invoke(Command(resume=event["answer"]), {"configurable": {"thread_id": thread_id}})The graph slept in its checkpointer the whole time. No worker held a connection open, and a deploy in between would not have lost anything, because the pending decision lives in Pushary's ledger and the graph state lives in yours.
The two footguns
Nodes re-run from the top on resume. When you resume an interrupted node, LangGraph executes the node body again from its first line. Any decisions.create before the interrupt runs twice. The fix is a deterministic idempotency key derived from stable inputs (the user, the node name, the question), so the second run returns the same decision instead of paging the person again.
The callback tells you the what, and you keep the where. Pushary's webhook body is {correlationId, answer, answeredAt}. It does not echo your routing state back, so persist the mapping from correlationId to your thread_id in your own store when you create the decision, and look it up in the callback. An in-memory dict works in development; production wants a table.
Which pattern to pick
Reach for Pattern A when the person is likely to answer while a request can reasonably stay open, which covers most in-session confirmations. Reach for Pattern B when approvals can take an hour, a day, or arrive from someone in another timezone, because parked graphs cost nothing while they wait. Many teams run both, with quick confirms inline and heavyweight approvals parked.
Where to go next
The LangGraph integration page is the condensed version. Plain LangChain agents use the same call inside an @tool function, covered on the LangChain page. The human-in-the-loop hub maps the whole pattern across frameworks.
Frequently asked questions
How do I add a human-in-the-loop notification to a LangGraph agent?
LangGraph's interrupt() pauses a node and persists state, and it does not notify anyone or deliver the decision to a phone. Wrap the pause with Pushary's decisions.ask({ question, externalId, type }): it sends the approval to a person's phone, blocks on a fail-closed answer, survives restarts, and records an audit trail. Resume the graph with the returned decision.
Should I use a blocking tool or interrupt-and-resume?
Use a blocking decisions.ask() call inside a node when the wait is short and holding the run open is fine. Use interrupt() plus a webhook-driven Command(resume=answer) when the wait can be long, because the graph parks in its checkpointer with no compute held open.
Why does my approval fire twice after a resume?
Because a LangGraph node re-runs from its top on resume, so code before the interrupt executes again. Give the decision a deterministic idempotency key derived from the user, the node, and the question, and the re-run returns the same decision instead of paging the person twice.