Human-in-the-loop with interrupts
Some actions should not run to completion on their own. Sending a message, deleting a
record, or spending money is the kind of step you want a human to approve first. An action
signals this by throwing an ActionInterrupt: it pauses itself, hands the caller enough
information to make a decision, and is re-run later once the decision is in.
Throw an interrupt to pause
Section titled “Throw an interrupt to pause”ActionInterrupt takes a message, an optional data payload for the approver, and an
optional state checkpoint you get back on resume:
import { ActionInterrupt, defineAction, z } from '@grundlag/core';
const sendMessage = defineAction({ id: 'sendMessage', name: 'Send message', description: 'Sends a message to a recipient, pending approval.', input: z.object({ recipientId: z.string(), body: z.string().min(1) }), output: z.object({ messageId: z.string() }), execute: async ({ input, resumption }) => { const draft = { recipientId: input.recipientId, body: input.body };
if (!resumption) { // First run: pause and ask. `data` describes what the approver must // decide on; `state` is our private checkpoint, returned verbatim later. throw new ActionInterrupt('Approve sending this message?', { draft }, { draft }); }
// Resumed run: `resumption.data` is the caller's answer, `resumption.state` // is the checkpoint we passed above. if (resumption.data !== 'approved') { throw new Error('Message sending was rejected'); }
const messageId = await deliver(draft); return { messageId }; },});The three arguments map to distinct roles:
| Argument | Role |
|---|---|
message |
Human-readable reason the action paused. |
data |
Information the approver needs to decide — a draft, a diff, a cost estimate. |
state |
A private checkpoint for the action, handed back untouched on resume. |
The contract: pause, surface, resume
Section titled “The contract: pause, surface, resume”An interrupt is not an error the action recovers from itself — it propagates up to whichever consumer is running the action, and that consumer owns the pause:
- The sandbox stops the script, returns the accumulated call log so the run can be replayed, and re-surfaces the interrupt to its caller. See Resume an interrupted script.
- The agent records the run as interrupted and exposes it for a human to approve or reject. See Handle interrupts.
When the caller resumes with an answer, the action runs again from the top, this time
with context.resumption populated: resumption.data is the caller’s answer and
resumption.state is the checkpoint you passed. A rejection does not re-execute the action
at all — model rejection as the caller declining to resume, or as an answer your resumed
code inspects and turns into a failure.
Resume re-runs the action
Section titled “Resume re-runs the action”Because a resume re-executes execute from the beginning, treat the code before the
interrupt as something that will run more than once. Two consequences follow:
- Do the irreversible work after the interrupt, not before. In the example,
deliveris only called on the resumed run, so the message is never sent without approval. - Use
stateto skip work you already did. If preparing the draft was expensive, compute it once, pass it as thestatecheckpoint, and read it back fromresumption.stateon resume instead of recomputing it:
execute: async ({ input, resumption }) => { const draft = resumption ? (resumption.state as { draft: Draft }).draft : await buildExpensiveDraft(input);
if (!resumption) { throw new ActionInterrupt('Approve sending?', { draft }, { draft }); } if (resumption.data !== 'approved') { throw new Error('Rejected'); } return { messageId: await deliver(draft) };};Keep data and state JSON-serialisable — both are persisted with the interrupt and
survive the round trip through the caller.
Related
Section titled “Related”- Interrupts explains why one mechanism spans every layer.
- Handle interrupts — surfacing and resuming from the agent.
- Resume an interrupted script — the sandbox side of the same contract.