Ask the user mid-task
An interrupt pauses a task until its owner answers a question or approves an action. When someone else approves, or the user is in Slack, have the agent draft instead.
You need: a recipe you can change (Add a tool) and an app that runs tasks (Add a chat to your web app).
1. Define the ask tools
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { askUserApproval, askUserQuestion } from "@introspection-ai/recipes/interactions";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "ask_user_question",
label: "Ask user",
description: "Ask the user a clarifying question and wait for the answer.",
parameters: Type.Object({ question: Type.String(), options: Type.Optional(Type.Array(Type.String())) }),
executionMode: "sequential",
async execute(toolCallId, p, signal, _onUpdate, ctx) {
return await askUserQuestion({ question: p.question, options: p.options }, { toolCallId, ctx, signal });
},
});
pi.registerTool({
name: "request_approval",
label: "Request approval",
description: "Ask the user to approve an action before taking it.",
parameters: Type.Object({ title: Type.String(), body: Type.String() }),
executionMode: "sequential",
async execute(toolCallId, p, signal, _onUpdate, ctx) {
return await askUserApproval({ title: p.title, body: p.body }, { toolCallId, ctx, signal });
},
});
}Add both to the agent’s tools. Questions also take selection: "multiple".
2. Detect the pause in your app
The run ends with an interrupt outcome and the task moves to awaiting_user.
let interrupts = [];
for await (const e of handle.stream()) {
if (e.type === EventType.RUN_FINISHED && e.outcome?.type === "interrupt") interrupts = e.outcome.interrupts;
}After a reload, read metadata.pending_interrupts from tasks.get. Each has an
id (send it back unchanged), a reason, a message, and metadata.
3. Resume with the answer
The task stays awaiting_user until every pending interrupt has an answer.
await runner.tasks.runs.resume(taskId, {
resume: [{ interruptId: interrupts[0].id, status: "resolved", payload: { answer: "EU" } }],
});| Interrupt | status | payload |
|---|---|---|
| Question | resolved | { "answer": "EU" }, or { "answers": [...] } for multiple |
| Approval | resolved | { "approved": true } or { "approved": false, "feedback": "Lower it" } |
| Dismiss either | cancelled | none |
Resume with the same credential that owns the task.
Verify
introspection tasks get <task-id> --query 'metadata.pending_interrupts'
introspection tasks resume <task-id> --interrupt-id <id> --payload '{"approved": true}'
introspection tasks get <task-id> --query status -o tableExpect the pending list, then running or idle.
Or ask your agent: “Add an approval step before any refund, with buttons in our chat UI.”
How long a pause lasts
| After | What happens |
|---|---|
| 10 minutes idle | The sandbox is torn down. Files and memory are saved. |
| 3 hours with no activity | The task stops holding a concurrency slot. |
| Any time later | Resume or prompt it; the conversation is intact. |
An expiresAt on a question is only recorded: cancel stale ones with
tasks resume <task-id> --cancel-interrupt <id>.
Limits
| Not supported | Do this instead |
|---|---|
| Questions from a subagent | Only the root agent pauses: ask from it. |
| Interrupts in Slack | Ask in the thread with reply. |
| A webhook when a task pauses | Keep the stream open, or poll tasks list --status awaiting_user. |