Skip to content

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.

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.

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.

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, deliver is only called on the resumed run, so the message is never sent without approval.
  • Use state to skip work you already did. If preparing the draft was expensive, compute it once, pass it as the state checkpoint, and read it back from resumption.state on 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.