Human-in-the-loop for LangChain
Human-in-the-loop for LangChain agents, on a phone.
Wrap one call in a LangChain tool and your agent asks a real person on their phone.
The integration
Add it to your LangChain agent
Wrap decisions.ask() in a LangChain @tool and give it to your agent. When the model reaches a step a person should sign off on, the question is delivered to that person's phone, the call blocks until they respond, and the answer comes back fail-closed. Run pip install pushary, or use @pushary/server in TypeScript. A LangChain tool is just a function, so the call drops in with no wrapper. If you also build LangGraph graphs, pushary-langgraph adds a ready-made ask_human and a durable interrupt on the same call. The external_id is your own id for the end-user, connected once with a keyless one-tap link.
import os
from langchain_core.tools import tool
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")
@tool
def ask_human(question: str) -> str:
"""Ask a real person to approve before acting."""
d = px.decisions.ask(question=question, external_id="user_123", type="confirm")
return "approved" if d["approved"] else f"not approved ({d['status']})"
# agent = create_agent(model, tools=[ask_human])Why you need it
LangChain pauses the agent. Pushary reaches the human.
A LangChain tool runs wherever the agent runs, and a print or an input() only works while a developer is watching the process. decisions.ask() reaches the person who should decide on the device they carry, waits durably, and records the response in an audit trail. The same call works inside a LangGraph node, because a node is a plain function.
What LangChain already gives you
Middleware that interrupts before a tool
LangChain 1.0 moved human-in-the-loop into middleware. You configure which tools require approval and the agent interrupts before those calls, handing back a pending request that you resolve with accept, edit, or reject. It is a cleaner shape than wrapping every tool by hand, and it runs on the same interrupt machinery underneath.
agent = create_agent(
model=model,
tools=[issue_refund],
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={"issue_refund": True},
),
],
checkpointer=checkpointer,
)Where it stops
- It needs a checkpointer, so you are running the durable graph runtime whether you wanted it or not.
- The interrupt reaches your code, not a person. Delivering it to a human is entirely on you.
- Nothing is written down. There is no queryable record of the approvals that middleware gated.
Before you ship it
Things that bite on LangChain
- No checkpointer means no interrupt
- The middleware silently does nothing useful without a checkpointer configured, because there is nowhere to park the state. Tools run straight through and it looks like the config was ignored.
- The edit path lets a human rewrite tool arguments
- Accepting is easy. Editing means the human changes the arguments before the tool runs, which is powerful and is also a route straight into your tool with human-supplied input. Validate edited arguments the same way you validate model output.
- Tool name keys are exact strings
- interrupt_on is keyed by tool name. Rename the tool and the gate silently stops applying, with no error and no warning. It is worth a test that asserts the guarded tool actually interrupts.
How it works
From ask to resume
Connect the phone once, then ask a human from the tool or step that guards the action. The waiting, the delivery, and the record are handled for you.
- 01
Install the SDK
Run pip install pushary for Python, or npm i @pushary/server for TypeScript, and set PUSHARY_API_KEY.
- 02
Connect the phone
Call px.enroll(external_id) once per end-user to connect their phone with a keyless one-tap link.
- 03
Wrap decisions.ask in an @tool
Define an @tool function whose body calls px.decisions.ask() and pass it in your agent's tools list. The docstring tells the model when to use it.
- 04
Act on the decision
The tool returns the human's response, fail-closed. A declined, expired, or unanswered confirm means the agent does not take the guarded action.
A decision is a yes or no confirm, a multiple-choice select, or a free-text input. Every one is stored with its outcome and kept in the audit log.
The Partner plan
Your agent asks your users, not just you
Ship the agent to real users and let each of them approve their own decisions from their phone, under your brand.
Your users never see a Pushary login or bill.
Your users answer on their own phones
The externalId is your own id for the end-user. Connect their phone once with a keyless one-tap link that opens the Pushary app, and every approval lands with the right person. No account and no key on their side.
Durable, fail-closed waits
The agent stays open on a durable wait backed by a decisions ledger. If nobody answers in the window you set, the decision comes back denied, so a timeout or a restart never turns into an unapproved action.
Phone-first delivery
Pushary reaches the person on the device they carry: the app first, because it is the only channel that puts Approve and Deny on a lock screen, then PWA push notifications, then Slack if you route it there. You do not build a delivery pipeline.
Signed webhooks and an audit trail
Every decision is written to a durable ledger you can query and export, and resolved decisions post a signed webhook to your callback URL. You have the record of who approved what and when.
FAQ
Questions, answered
Do I need a special package for LangChain?
Does this work with create_agent and older agent runtimes?
What happens when nobody responds?
Can my end-users answer instead of me?
Is there a TypeScript path?
How does this compare to the LangChain human-in-the-loop middleware?
Let your LangChain agent ask a human.
Connect a user with one tap, ask a human with one call, and act on a fail-closed decision. Every decision is saved to a durable ledger you can audit.
Partner plan, $99 a month. Your users never see a Pushary login or bill.