Pushary
Blog
Guides

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.

AG
Aadil Ghani
Founder, Pushary
Jul 17, 20263 min read
Share

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 pushary
import 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.

AG
Aadil Ghani
Founder, Pushary

Building Pushary so an AI agent can reach you on your phone and wait for a yes before it does something you would not want.

Read next

Guides

How to add human-in-the-loop to a Vercel AI SDK agent

The AI SDK gives your agent tools. One of those tools can be a real person. Here is the whole integration, from install to fail-closed decision.

Jul 17, 20263 min readAadil Ghani
Guides

How to approve Claude Cowork tasks from your phone

Paste one connector link, add standing instructions, and Cowork's questions arrive as push notifications you answer from the lock screen.

Jul 10, 20263 min readAadil Ghani
Guides

An audit trail for Claude Cowork: what did your AI coworker decide while you were out?

Delegation without a record is hope. Keep every Cowork question and answer in an immutable, exportable trail, next to every other agent you run.

Jul 10, 20264 min readAadil Ghani

Get a push the moment your agent needs you

Approvals, done alerts, and a kill switch for Claude Code, Codex, Cursor, and the rest. It takes a couple of minutes to set up.