Human-in-the-loop for LangGraph and LangChain
Human-in-the-loop for LangGraph agents, answered on a phone.
Your LangGraph node asks your user on their phone, then waits or parks on an interrupt until they answer.
What LangGraph gives you
interrupt() and the checkpointer
With a checkpointer and a thread id, interrupt() pauses the graph and hands control back to your code. Later, Command(resume=...) supplies the answer and runs the interrupted node again. Where the answer comes from, and side effects that are safe to run twice, are still yours.
def approval_node(state):
answer = interrupt({"question": "Approve the refund?"})
return {"approved": answer == "yes"}
# somewhere else, eventually:
graph.invoke(Command(resume="yes"), config)Where it stops
- interrupt() tells nobody. The graph is parked and no notification goes out.
- Something has to call Command(resume=...). That trigger, and the screen the person answers on, are yours to build.
- MemorySaver loses the pause when the process dies. Any real wait needs a persistent checkpointer.
- The checkpoint records that the graph stopped. It does not record who decided or what they saw.
The fix
Pushary gets your user's answer.
interrupt() parks the graph. Pushary sends the question to your user's phone, waits for Approve or Deny, saves the answer and sends it to your server as a signed webhook, so your code can resume the right thread.
connect() returns a link your user opens once. Inside the node, ask_human sends the question and waits for the answer, 55 seconds by default. The node returns approved only on an explicit yes, so route the risky step on that branch.
pip install pushary-langgraphfrom pushary_langgraph import connect, ask_human
# Once per user: show them this link.
link = connect("user_123")
# Ask from inside a LangGraph node. No answer means no.
def approve_transfer(state):
d = ask_human("Approve this transfer?", external_id=state["user_id"], node="approval")
return {"approved": d["approved"]}- LangGraph setup: checkpoints, interrupts and resuming after approval
- Listed in LangChain's integrations
For long waits, park on an interrupt
With a callback_url, pushary_interrupt parks the graph on LangGraph's own interrupt() and returns at once. Nothing runs while your user decides. Pushary sends the answer to your server as a signed webhook. Your worker checks it and resumes the thread with Command(resume=...).
from pushary_langgraph import pushary_interrupt
def review_order(state):
answer = pushary_interrupt(
f"Submit order {state['order_id']}, revision {state['revision']}?",
external_id=state["customer_id"],
idempotency_key=state["review_operation_id"],
type="confirm",
node="submit-order",
callback_url="https://your-app.example/pushary/callback",
)
return {"approved": answer == "yes"}How it works
From install to the first answer
Connect your user once. Put the approval on the risky step. Pushary sends the question, waits for the answer and saves it.
- 01
Install the adapter
Run pip install pushary-langgraph for Python, or npm i @pushary/langgraph for TypeScript. Put your API key in PUSHARY_API_KEY.
- 02
Connect your user once
connect() returns a link. Your user opens it on their phone with the free Pushary app. Yes or no questions get Approve and Deny on the lock screen.
- 03
Ask in the node before the risky step
Call ask_human for a short wait, or pushary_interrupt with a callback URL for a long one. Route to the risky step only on a yes.
- 04
Act on the answer
Approve takes the risky branch. Deny, expiry or no answer never does. A choice or a typed answer is data for the next step, never permission to act.
A question can be a yes or no, a choice from a list, or a typed answer. Every answer is saved.
Before you ship
Things that bite on LangGraph
- The whole node runs again on resume
- This is the one that catches people. LangGraph re-runs the node from the top, not from the interrupt() call. Any side effect before the interrupt happens twice. Charge a card and then ask in the same node, and you charge twice. Keep that work safe to run twice, or move it to its own node.
- Two interrupts in one node match by order
- Two interrupt() calls in the same node get their resume values by position, not by name. Add a branch that skips the first one, and the second silently receives the wrong answer.
- A blocking ask and an interrupt are different trade-offs
- ask_human holds the request open and can run out of time. An interrupt needs a persistent checkpointer, a saved thread id, and code that checks the answer and resumes the graph. A callback URL alone gives you none of that.
The Partner plan
Your agent asks your users, not just you
Ship your agent to your users. Each of them approves its risky actions from their phone, under your brand.
Your users never make a Pushary account or pay us.
Your users answer on their own phones
Connect each user once with a link. They install the free Pushary app and answer from the lock screen, with no account of their own.
No answer means no
Run the action only after an explicit Approve. A request that is still waiting, expired or cancelled never counts as a yes. Pushary keeps every request, so your code can pick it up again after a restart.
Reaches them on their phone
The app comes first, because only the app puts Approve and Deny on the lock screen. Browser notifications come next. You build no delivery system.
Every answer saved
Every answer is saved with who answered and when. You can search and export it, and with a callback URL, each answer is also sent to your server as a signed webhook.
FAQ
Questions, answered
Is there a LangGraph package for Pushary?
Should I use ask_human or pushary_interrupt?
What happens if nobody answers?
Can my own end-users approve on their own phones?
Does this work with plain LangChain agents?
Why is my LangGraph interrupt not working?
Let your LangGraph agent ask before it acts.
Connect a user once, ask with one call, and run the action only on a yes. Every answer is saved.
Start freePartner plan, $99 a month. Your users never make a Pushary account or pay us.