LangGraph
A hand-built LangGraph agent whose refund tool is approved, edited or rejected in the Vigilator inbox.
A refund agent built as a plain StateGraph: an agent node calls the model, a tools node runs its tool calls. lookup_order runs freely; issue_refund is gated, so the tools node raises a LangGraph interrupt() and the run pauses until a reviewer decides in Vigilator. Source: Vigilator/examples/langgraph.
What you'll see
- Inbox - an interrupt titled issue_refund needs a decision carrying the refund's arguments and the transcript so far, with Approve, Edit and Reject. The tool allows editing, so the interrupt ships a JSON schema for its arguments and Edit opens a typed form.
- Live View - a
refund-agentsession showing the lookup call and its result. It ends when the agent pauses and a new one opens when the run resumes - see why.
Run it
Install
git clone https://github.com/Vigilator/examples
cd examples/langgraph
uv syncConfigure
cp .env.example .envFill in VIGILATOR_API_KEY (from Integrations → API keys) and ANTHROPIC_API_KEY. MODEL accepts any init_chat_model string if you prefer another provider.
Run with polling
uv run main.pyThe agent looks the order up, asks to refund it, and prints the interrupt id. Decide it in the inbox - try Edit and lower the amount - and the script picks the decision up on its next poll, runs the refund with your arguments, and prints the agent's reply.
Or resume by webhook
uv run fastapi dev webhook.py # the agent and the receiver, port 8000
svix listen http://localhost:8000/webhooks/vigilator # relay deliveries to localhostAdd the relay's URL as an endpoint under Integrations → Webhooks subscribed to interrupt.answered, put its signing secret in .env, then POST /runs to start a run. Your decision is delivered to the receiver, which resumes the run in the background. See testing locally for tunnels and test events.
How it works
The tools node collects the gated calls into one interrupt(), shaped like LangChain's human-in-the-loop payload - one action_requests entry per call and a review_configs entry naming the allowed decisions:
GATED = {"issue_refund": ["approve", "edit", "reject"]}
def tools(state):
calls = state["messages"][-1].tool_calls
gated = [c for c in calls if c["name"] in GATED]
decisions = {}
if gated:
response = interrupt({
"action_requests": [{"name": c["name"], "args": c["args"]} for c in gated],
"review_configs": [{"action_name": c["name"], "allowed_decisions": GATED[c["name"]]} for c in gated],
})
decisions = {c["id"]: d for c, d in zip(gated, response["decisions"])}
return {"messages": [_run_tool(call, decisions.get(call["id"], {"type": "approve"})) for call in calls]}The bridge maps that payload one-to-one onto a Vigilator interrupt: each action request keeps its name, args and allowed decisions, the transcript is attached as messages, and externalId is set to langgraph:<thread_id> so the outcome can be routed back:
request = InterruptsPostRequest(
title=f"{name} needs a decision",
description=...,
externalId=f"langgraph:{thread_id}",
messages=to_vigilator_messages(transcript),
actionRequests=[ActionRequest(name=name, args=args, argsSchema=schema, allowedDecisions=[...])],
)When the interrupt is answered - by polling get_interrupt or from the webhook - the decisions become the Command that resumes the graph. interrupt() returns it inside the tools node, which runs approved calls as proposed, edited calls with the reviewer's editedArgs, and turns rejections into an error tool message the model can explain to the customer:
def to_langgraph_resume(decided):
decisions = []
for action in decided:
if action.decision == "approve":
decisions.append({"type": "approve"})
elif action.decision == "edit":
decisions.append({"type": "edit", "edited_action": {"name": action.name, "args": action.edited_args}})
elif action.decision == "reject":
decisions.append({"type": "reject", "message": action.response_text or "Rejected by the reviewer."})
elif action.decision == "respond":
decisions.append({"type": "respond", "message": action.response_text or ""})
return Command(resume={"decisions": decisions})Everything before interrupt() runs again when the graph resumes. The tools node keeps that part pure and executes tools only after the decision is in.
Adapting it
- Add tools to
TOOLSand list the ones needing review inGATEDwith the decisions they allow. A question for a human is a tool whose only allowed decision isrespond- see Ask a human. - Keep the
action_requests/review_configsshape and the bridge works unchanged. If you usecreate_agent, its middleware already produces it - see LangChain. - Swap the in-memory checkpointer for a database one to resume from another process - see Durable resume.