Human-in-the-loop¶
When your graph calls LangGraph's interrupt(...), or uses LangChain's
HumanInTheLoopMiddleware (deepagents' interrupt_on=... uses it), the turn
pauses and waits for a person. Every LangStage surface shows the pause, collects a
decision, and resumes the same thread with it. This page covers the shared
vocabulary and how each surface presents it.
Decision verbs¶
LangStage uses one vocabulary, the HumanInTheLoopMiddleware verbs. The legacy
LangGraph HumanInterrupt spellings are accepted as aliases, in any case.
| Verb | What it does | Payload | Accepted alias |
|---|---|---|---|
approve |
Run the action as proposed. | {"type": "approve"} |
accept |
edit |
Run it with changed arguments. | {"type": "edit", "edited_action": {"name": "…", "args": {…}}} |
none |
reject |
Don't run it, optionally with a reason. | {"type": "reject", "message": "…"} (message optional) |
ignore |
respond |
Answer with text instead of running it. | {"type": "respond", "message": "…"} |
response |
- What the interrupt allows. An
interruptframe carriesallowed_decisions, always in the canonical verbs. It comes from the interrupt's own configuration (the middleware'sreview_configs[*].allowed_decisions, or a legacyHumanInterrupt'sconfig), so an approve-only interrupt advertises exactly["approve"]. All four are listed only when the interrupt says nothing. - Aliases are translated only where needed. When the pending interrupt is a
HumanInTheLoopMiddlewarerequest, core rewrites an alias (accept) to its canonical verb (approve) before resuming, because the middleware rejects the alias. Any other interrupt, such as your owninterrupt(...)or a legacyHumanInterrupt, gets the payload exactly as sent. - One decision per action. An interrupt can ask about several actions at once
(
action_requests). Send one decision per action, in order.
Checking a verb before you resume¶
Core doesn't refuse a disallowed verb on resume, so a surface (or your code)
should check first. The helpers are top-level in langstage_core:
from langstage_core import DECISION_ALIASES, DECISION_VERBS, is_allowed_decision, normalize_decision
allowed = ["reject", "approve"] # frame["allowed_decisions"]
print(normalize_decision("accept", allowed)) # approve (alias -> canonical)
print(normalize_decision("edit", allowed)) # None (not allowed here: refuse it)
print(is_allowed_decision("ignore", allowed))# True (ignore == reject)
print(DECISION_VERBS, DECISION_ALIASES)
normalize_decision(verb) with no allowed list just maps an alias to its
canonical verb.
Resuming from Python¶
Resume by running the next turn on the same thread_id with resume=. It takes
the {"decisions": [...]} envelope, or a Command from create_resume_input(...).
This example uses the keyless tool demo, whose "ask me" interrupt allows
respond and approve:
import asyncio
from langstage_core import create_resume_input
from langstage_core.agui import build_agent, iter_event_frames
from langstage_core.demo.tools import create_tool_demo_agent
agent = build_agent(create_tool_demo_agent()) # build once, reuse across turns
async def main():
async for frame in iter_event_frames(agent, "ask me", thread_id="s1"):
if frame["type"] == "interrupt":
print("allowed:", frame["allowed_decisions"])
if frame["type"] == "complete":
print("outcome:", frame["outcome"]) # interrupted
decision = create_resume_input(decisions=[{"type": "approve"}])
async for frame in iter_event_frames(agent, "", thread_id="s1", resume=decision):
if frame["type"] == "content":
print(frame["content"], end="")
if frame["type"] == "complete":
print("\noutcome:", frame["outcome"]) # complete
asyncio.run(main())
build_agent attaches an in-memory checkpointer when your graph has none, so the
paused thread is still there for the resume. Keep the same agent object: a new
in-memory checkpointer would not know the thread.
The task engine takes the same decisions: a delegated task that interrupts parks
in review_needed until you call await runner.resume(task_id, [{"type": "approve"}]).
See Shared core.
Over HTTP, an AG-UI client resumes with RunAgentInput.resume (ag-ui-langgraph
0.0.43 and later). See Serve over AG-UI.
How each surface presents a pause¶
| Surface | What you see | How you answer |
|---|---|---|
Web (langstage) |
An "Action Requires Approval" dialog listing each action, its description and arguments. | Approve, Reject, and Edit (per action, arguments as JSON). Each button appears only if the interrupt allows that verb. On the task board, a paused task sits in review; open it to approve or reject. |
Terminal (langstage-cli) |
Each action with every argument as name=value. Control characters are escaped, so an argument can't forge prompt text. |
An arrow-key menu: approve all, reject all, or type a custom decision as JSON. A plain interrupt("What is your name?") asks for a free-text value and resumes with exactly that value. |
JupyterLab (langstage-jupyter) |
The action inline in the chat sidebar. | Approve, Reject and Edit buttons, shown per allowed_decisions. |
VS Code panel (langstage-vscode) |
An approval card with each action and its arguments. | Approve, Reject (optional reason), Respond (text), Edit (a JSON editor prefilled with the arguments). See The LangStage panel. |
VS Code @langstage (Copilot chat) |
Each action (name, description, arguments) in the chat response. | Approve, Reject, Respond…, Edit… buttons, or @langstage /approve, /reject [reason], /respond <text>, /edit <json>. See VS Code. |
langstage-agui -m, langstage chat, langstage-jupyter --ask, langstage-vscode-sidecar --message |
The pending action on stderr. | These are one-shot runs, so they exit 2 ("paused"; see Exit codes). Resume from Python, a UI, or the sidecar's --repl. |
langstage-vscode-sidecar --repl |
The action and its allowed verbs. | Type :decision <verb> or just the bare verb: approve, reject [<text>], respond <text>, edit <json>. |
In scripts, the terminal needs a person at the keyboard to approve. If stdin
isn't a terminal, langstage-cli stops rather than approve something nobody saw
(exit 2, "paused", from 0.6.36). Pass --no-interactive to auto-approve on
purpose.
Try it without an agent or key
langstage-cli -a langstage_core.demo.tools:graph "ask me" in a terminal, or
langstage run --agent langstage_core.demo.tools:graph and type "ask me", or
langstage-vscode-sidecar --demo=tools --repl and type ask me, then approve.