Examples

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-agent session per stretch of work between the pauses.

Run it

Install

git clone https://github.com/Vigilator/examples
cd examples/langgraph-js
bun install

Configure

cp .env.example .env

Fill in VIGILATOR_API_KEY and ANTHROPIC_API_KEY. MODEL is passed to ChatAnthropic.

Run with polling

bun run start

The 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/vigilator

Register 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:

src/agent.ts
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:

src/vigilator.ts
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:

src/webhook.ts
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 a HumanRequest; the bridge needs nothing else.
  • Pass argsSchema (here from z.toJSONSchema) whenever edit is allowed, so the inbox renders a typed form.
  • Swap MemorySaver for SqliteSaver from @langchain/langgraph-checkpoint-sqlite to resume from another process - see Durable resume for the pattern.

On this page