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.

native
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.

bash
pip install pushary-langgraph
two calls
from 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"]}

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=...).

long wait
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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

$99/ month, every framework included
Start free

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?
Yes. pushary-langgraph for Python and @pushary/langgraph for TypeScript, on PyPI and npm. Both give you connect(), a blocking ask (ask_human, or createAskHumanTool in TypeScript), and pushary_interrupt or pusharyInterrupt, which parks the graph on LangGraph's own interrupt until your user answers.
Should I use ask_human or pushary_interrupt?
Use ask_human when the answer comes within a minute; it waits 55 seconds by default. Use pushary_interrupt with a callback URL when your user may answer later. The graph parks on a persistent checkpointer, and your code resumes the matching thread when the signed webhook arrives.
What happens if nobody answers?
The risky step does not run. ask_human returns approved false, and pushary_interrupt returns None for an expired or cancelled request. Pushary sends no webhook for those, so check the request on a timer if you wait on the webhook. Keep “nobody answered” apart from “your user said no”. The saved answer and the graph checkpoint are separate, and neither resumes the graph by itself.
Can my own end-users approve on their own phones?
Yes. Your code picks who answers, using your own user id. They install the free Pushary app and open your connect link once, with no API key or paid plan of their own.
Does this work with plain LangChain agents?
Yes. ask_human is a normal function, so you can call it inside any LangChain tool or callback, the same way you call it in a LangGraph node. In TypeScript, createAskHumanTool gives you a ready-made tool for createReactAgent.
Why is my LangGraph interrupt not working?
Usually the graph has no checkpointer, you resumed with plain input, or the interrupt sits in a tool or subgraph. Without a checkpointer there is nowhere to park, so the graph runs straight through. Plain input starts a new run; resume with Command(resume=value). An interrupt in a tool or subgraph does not show on the parent graph's output. Side effects that happen twice mean the node re-ran from the top.

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 free

Partner plan, $99 a month. Your users never make a Pushary account or pay us.