LangGraph.js
A LangGraph.js support agent in TypeScript whose tools pause for a question and for an email approval.
A support agent built with LangGraph.js and the TypeScript SDK. Two of its three tools call interrupt() before doing anything: ask_human poses a question (respond), send_email proposes an email (approve / edit / reject). Source: Vigilator/examples/langgraph-js.
What you'll see
- Inbox - an interrupt whose action request is the question, with a Respond box; then an interrupt carrying the email with Approve, Edit (a typed form from the tool's zod schema) and Reject.
- Live View - a
support-agentsession per stretch of work between the pauses.
Run it
Install
git clone https://github.com/Vigilator/examples
cd examples/langgraph-js
bun installConfigure
cp .env.example .envFill in VIGILATOR_API_KEY and ANTHROPIC_API_KEY. MODEL is passed to ChatAnthropic.
Run with polling
bun run startThe agent looks the customer up, asks what discount it may offer, and pauses. Answer in the inbox; it drafts an email and pauses again. Edit and approve; the run finishes with your version.
Or resume by webhook
bun run webhook # Hono receiver on port 8000
svix listen http://localhost:8000/webhooks/vigilatorRegister the relay's URL for interrupt.answered, put the signing secret in .env, restart, then POST /runs.
How it works
Each tool passes a HumanRequest to interrupt() and receives a HumanDecision when the run resumes:
export const sendEmail = tool(
async (args) => {
const decision = interrupt<HumanRequest, HumanDecision>({
name: "send_email",
args,
description: "The agent wants to email a customer. Approve it, edit it, or reject it.",
allowedDecisions: ["approve", "edit", "reject"],
argsSchema: z.toJSONSchema(EmailSchema),
});
if (decision.decision === "reject") return `Not sent - ${decision.reason ?? "rejected by the reviewer"}`;
const email = decision.decision === "edit" ? decision.args : args;
return `Email sent to ${email.to}`;
},
{ name: "send_email", description: "...", schema: EmailSchema },
);The bridge turns the request into a Vigilator interrupt - one action request with the same name, args, schema and allowed decisions - and the answered interrupt back into the decision. toResume accepts both a polled Interrupt and the data of an interrupt.answered event, since they share the same fields:
export function toResume(answered: Answered | InterruptAnsweredEvent["data"]): HumanDecision {
const [request] = answered.actionRequests;
switch (request.decision) {
case "approve": return { decision: "approve" };
case "edit": return { decision: "edit", args: request.editedArgs as JsonObject };
case "reject": return { decision: "reject", reason: request.responseText ?? undefined };
case "respond": return { decision: "respond", answer: request.responseText ?? "" };
}
}The webhook receiver is the Hono route from the TypeScript SDK page plus one callback that resumes the paused thread:
webhooks.on("interrupt.answered", (event) => {
const threadId = threadIdFrom(event.data.externalId);
if (threadId && pending.get(threadId) === event.data.id) {
pending.delete(threadId);
void drive(threadId, new Command({ resume: toResume(event.data) }));
}
});Adapting it
- Add tools that call
interrupt()with aHumanRequest; the bridge needs nothing else. - Pass
argsSchema(here fromz.toJSONSchema) whenevereditis allowed, so the inbox renders a typed form. - Swap
MemorySaverforSqliteSaverfrom@langchain/langgraph-checkpoint-sqliteto resume from another process - see Durable resume for the pattern.