Examples

Mastra

A Mastra billing agent whose refund is approved in Vigilator, after it asks a human how much to refund.

A billing agent built with Mastra and the TypeScript SDK. Mastra pauses a run in two ways - a tool with requireApproval: true suspends before it executes, and a tool can call suspend() to ask for data - and both map cleanly onto Vigilator: the first onto approve / reject, the second onto respond. Suspended runs are persisted in LibSQL, which is what lets a webhook receiver resume them. Source: Vigilator/examples/mastra.

What you'll see

  • Inbox - first an interrupt whose action request is the agent's question, with a Respond box; then an interrupt for issueRefund with Approve and Reject.
  • Live View - a billing-agent session per stretch of work, with the tool chips and their results.

Run it

Install

git clone https://github.com/Vigilator/examples
cd examples/mastra
bun install

Configure

cp .env.example .env

Fill in VIGILATOR_API_KEY and ANTHROPIC_API_KEY. MODEL takes any provider/model string Mastra understands.

Run with polling

bun run start

The agent asks how much to refund and pauses. Answer in the inbox; it proposes the refund and pauses again for approval. Approve, and the refund runs; reject with a reason, and the agent tells the customer it was declined.

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. You can restart the receiver between the interrupt and the decision - the suspended run is in mastra.db, not in memory.

How it works

The refund tool is gated with requireApproval; the question tool suspends itself and returns the answer when it is resumed:

src/mastra/tools/ask-human.ts
export const askHuman = createTool({
  id: "ask-human",
  description: "Ask a human colleague a question and wait for their answer.",
  inputSchema: z.object({ question: z.string() }),
  outputSchema: z.object({ answer: z.string() }),
  suspendSchema: z.object({ question: z.string() }),
  resumeSchema: z.object({ answer: z.string() }),
  execute: async ({ question }, context) => {
    const answer = context?.agent?.resumeData?.answer;
    if (!answer) {
      await context?.agent?.suspend({ question });
      return;
    }
    return { answer };
  },
});

A HITL map says what a reviewer may do per tool - keyed by the tool's name as the model sees it, which is its key in the agent's tools object. The bridge normalises a suspension from generate() or from listSuspendedRuns() into one shape and opens the interrupt; a respond-only tool becomes a question named after the question text:

src/vigilator/interrupts.ts
export const HITL = {
  issueRefund: { allowedDecisions: ["approve", "reject"], description: "The agent wants to refund a customer." },
  askHuman: { allowedDecisions: ["respond"], description: "The agent has a question." },
};

export async function resumeWithDecision(agent, call, decision, { runId, memory }) {
  switch (decision.decision) {
    case "approve": return agent.approveToolCallGenerate({ runId, toolCallId: call.toolCallId, memory });
    case "reject":  return agent.declineToolCallGenerate({ runId, toolCallId: call.toolCallId, reason: decision.reason, memory });
    case "respond": return agent.resumeGenerate({ answer: decision.answer }, { runId, toolCallId: call.toolCallId, memory });
  }
}

The webhook receiver holds nothing in memory. The interrupt's externalId is mastra:<threadId>:<runId>, so on delivery it asks Mastra's storage for the suspended run and resumes it:

src/webhook.ts
webhooks.on("interrupt.answered", (event) => {
  const ids = parseExternalId(event.data.externalId);
  if (!ids) return; // a test event, or not ours
  void resumeRun(ids.threadId, ids.runId, toDecision(event.data));
});

async function resumeRun(threadId, runId, decision) {
  const { runs } = await agent.listSuspendedRuns({ threadId, resourceId: RESOURCE_ID });
  const run = runs.find((r) => r.runId === runId);
  if (!run) return; // already resumed, or finished
  const output = await resumeWithDecision(agent, fromSuspendedRun(run), decision, { runId, memory: { thread: threadId, resource: RESOURCE_ID } });
  ...
}

Mastra's tool approval is binary, so edit is not offered for issueRefund. To let reviewers change arguments, make the tool suspend() with its proposed arguments and resume it with the reviewer's editedArgs.

Adapting it

  • Add a tool with requireApproval: true or a suspend() call, and add its name to HITL.
  • Keep persistent storage: in-memory storage forgets suspended runs when the process exits, which breaks the webhook path.
  • Resumed outputs repeat the earlier steps; the Live View helper remembers what it has sent so nothing is duplicated.

On this page