Interrupts
Learn how to implement Human-in-the-Loop (HITL) using Mastra's native tool suspension.
What is this?#
Mastra's tool suspension provides a native way to implement Human-in-the-Loop workflows.
Tools can call suspend() to pause execution mid-tool and send a payload to the frontend. CopilotKit's useInterrupt hook
captures the suspension, renders custom UI, and sends the user's response back to resume the tool.
When should I use this?#
Interrupt-based HITL is ideal when a tool needs to pause its own execution to collect user input before continuing. Common use cases include:
- Approvals — confirm before performing destructive or irreversible actions
- Disambiguation — ask the user to clarify ambiguous inputs
- Progressive disclosure — collect additional details only when needed at runtime
If you want to render a standalone frontend tool that collects user input without suspending tool execution, see the tool-based approach.
Implementation#
Run and connect your agent#
You'll need to run your agent and connect it to CopilotKit before proceeding. If you haven't done so already, you can follow the instructions in the Getting Started guide.
If you don't already have an agent, you can use the coagent starter as a starting point as this guide uses it as a starting point.
Define a tool with suspend()#
Create a Mastra tool that uses suspend() to pause execution and ask for user confirmation.
The suspendSchema defines what gets sent to the frontend, and the resumeSchema defines what the frontend sends back.
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const confirmActionTool = createTool({
id: "confirm-action",
description: "Ask the user to confirm before performing a critical action",
inputSchema: z.object({
action: z.string().describe("Description of the action to confirm"),
}),
outputSchema: z.object({ confirmed: z.boolean() }),
suspendSchema: z.object({ action: z.string() }),
resumeSchema: z.object({ confirmed: z.boolean() }),
execute: async (inputData, context) => {
const { resumeData, suspend } = context?.agent ?? {};
// First execution: pause and ask for confirmation
if (!resumeData) {
return suspend?.({ action: inputData.action });
}
// Resumed: the user has responded
return { confirmed: resumeData.confirmed };
},
});Add the tool to your agent#
import { Agent } from "@mastra/core/agent";
import { openai } from "@ai-sdk/openai";
import { confirmActionTool } from "@/mastra/tools";
export const myAgent = new Agent({
id: "my-agent",
name: "My Agent",
tools: { confirmActionTool },
model: openai("gpt-4o"),
instructions:
"You are a helpful assistant. When performing critical actions, " +
"always use the confirm-action tool to get user approval first.",
});Handle the interrupt in your frontend#
Use the useInterrupt hook to render UI when the agent suspends a tool.
The event.value.suspendPayload contains the data from the tool's suspend() call.
Call resolve() with the user's response to resume execution.
import { useInterrupt } from "@copilotkit/react-core/v2";
// ...
function YourMainContent() {
// ...
useInterrupt({
render: ({ event, resolve }) => {
const { action } = event.value.suspendPayload;
return (
<div>
<p>{action}</p>
<button onClick={() => resolve({ confirmed: true })}>
Approve
</button>
<button onClick={() => resolve({ confirmed: false })}>
Reject
</button>
</div>
);
},
});
// ...
return <div>{/* ... */}</div>;
}Give it a try!#
Try asking your agent to do something that requires confirmation.
Can you delete all inactive user accounts?The agent will call the confirm-action tool, which suspends execution and shows an approval UI.
After you approve or reject, the tool resumes with your response and the agent continues.
Advanced usage#
Handle multiple interrupt types#
When your agent has multiple tools that use suspend(), use the enabled property to route each
interrupt to the correct handler. The eventValue.toolName field identifies which tool triggered the suspension.
Define multiple suspending tools#
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const confirmActionTool = createTool({
id: "confirm-action",
description: "Ask the user to confirm an action",
inputSchema: z.object({ action: z.string() }),
outputSchema: z.object({ confirmed: z.boolean() }),
suspendSchema: z.object({ action: z.string() }),
resumeSchema: z.object({ confirmed: z.boolean() }),
execute: async (inputData, context) => {
const { resumeData, suspend } = context?.agent ?? {};
if (!resumeData) return suspend?.({ action: inputData.action });
return { confirmed: resumeData.confirmed };
},
});
export const askQuestionTool = createTool({
id: "ask-question",
description: "Ask the user a free-form question",
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 (inputData, context) => {
const { resumeData, suspend } = context?.agent ?? {};
if (!resumeData) return suspend?.({ question: inputData.question });
return { answer: resumeData.answer };
},
});Route interrupts with enabled#
import { useInterrupt } from "@copilotkit/react-core/v2";
function YourMainContent() {
// ...
useInterrupt({
enabled: ({ eventValue }) => eventValue.toolName === "confirm-action",
render: ({ event, resolve }) => (
<div>
<p>{event.value.suspendPayload.action}</p>
<button onClick={() => resolve({ confirmed: true })}>Approve</button>
<button onClick={() => resolve({ confirmed: false })}>Reject</button>
</div>
),
});
useInterrupt({
enabled: ({ eventValue }) => eventValue.toolName === "ask-question",
render: ({ event, resolve }) => (
<div>
<p>{event.value.suspendPayload.question}</p>
<form
onSubmit={(e) => {
e.preventDefault();
resolve({ answer: (e.target as HTMLFormElement).response.value });
}}
>
<input type="text" name="response" placeholder="Your answer" />
<button type="submit">Submit</button>
</form>
</div>
),
});
// ...
return <div>{/* ... */}</div>;
}Preprocessing with handler#
Use the handler property to transform interrupt data or resolve interrupts programmatically before rendering UI.
The return value of handler is passed to render as the result argument.
import { useInterrupt } from "@copilotkit/react-core/v2";
function YourMainContent() {
const [user] = useState({ role: "admin" });
useInterrupt({
handler: async ({ event, resolve }) => {
// Auto-approve for admins
if (user.role === "admin") {
resolve({ confirmed: true });
return;
}
return { action: event.value.suspendPayload.action };
},
render: ({ result, resolve }) => (
<div>
<p>{result.action}</p>
<button onClick={() => resolve({ confirmed: true })}>Approve</button>
<button onClick={() => resolve({ confirmed: false })}>Reject</button>
</div>
),
});
// ...
return <div>{/* ... */}</div>;
}