Skip to content

Interrupts

Some work cannot finish in a single pass. An action might need a human to approve a destructive change, wait for an out-of-band signal, or defer until a condition holds. Rather than give each runtime its own way to handle this, the platform has one mechanism: an action pauses itself by throwing an interrupt, and whichever consumer is running catches it and turns it into something it can resume. Because the mechanism is defined in the core, every layer understands the same thing.

An action signals a pause by throwing ActionInterrupt:

import { ActionInterrupt } from '@grundlag/core';
throw new ActionInterrupt(message, data?, state?);
  • message — a human-readable description of why the action stopped.
  • data — a caller-facing payload describing what must be satisfied before resuming (for example, the question to put to a human).
  • state — the tool’s private checkpoint. It is opaque to everyone else, persisted with the interrupt, and handed back to the action verbatim when it resumes.

The action is written to be re-entrant. On its first call it has no resumption, so it interrupts; when resumed it receives a resumption and continues from there:

execute: async ({ input, resumption }) => {
if (!resumption) {
throw new ActionInterrupt('Approve sending the invoice?', { invoiceId: input.id });
}
// resumed: resumption.data is the caller's answer,
// resumption.state is the checkpoint this call captured earlier
return send(input.id);
};

Because ActionInterrupt lives in the core alongside the action contract, the action does not know or care which consumer is running it. It just throws.

The interrupt propagates up out of execute and is caught by whatever is driving the action. Each consumer renders it into a resumable form that fits its runtime:

  • The agent stops its run loop, records the interrupt on the suspended tool call, and returns { status: 'interrupted' }. You continue with resume(data) — which re-runs the action with your answer — or reject(error) — which records a failure the model sees. See Handle interrupts.
  • The sandbox stops the script, re-surfaces the pause as a SandboxInterrupt carrying the accumulated call log, and lets you resume by replaying the log with the answer supplied at the interrupted call’s position. See Wrap a script as an agent tool.
  • The HTTP API surfaces the interrupt to its caller as the response, so the calling application can gather the answer and call back.

The action wrote the pause once; each runtime knows how to hold it and hand back an answer.

However the pause was caught, resuming reconstructs the resumption the action expects:

type ActionResumption = { data: unknown; state?: unknown };

data is the caller’s answer, and state is the checkpoint the action stashed when it interrupted. The action re-runs — the same execute, from the top — but this time context.resumption is populated, so its guard clause falls through and it proceeds. An action can interrupt more than once (each pause captures its own state), and the resolution of each is kept, so the transcript records the whole negotiation.

Approval flows, confirmations, and “ask the user first” steps all reduce to the same shape: an action stops, someone decides, the action continues. Because that shape is a single core primitive rather than a feature bolted onto each runtime, a provider author writes the pause once and it works everywhere the action runs — inside an agent conversation, inside a sandboxed script, or behind an HTTP endpoint. And because the pause is persisted (on the agent’s Prompt, in the sandbox’s call log), a run can be suspended across a restart and resumed later from reconstructed state.