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
issueRefundwith Approve and Reject. - Live View - a
billing-agentsession 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 installConfigure
cp .env.example .envFill in VIGILATOR_API_KEY and ANTHROPIC_API_KEY. MODEL takes any provider/model string Mastra understands.
Run with polling
bun run startThe 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/vigilatorRegister 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:
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:
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:
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: trueor asuspend()call, and add its name toHITL. - 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.