Interruption lets a person step into running work: pause it, stop it, or give a new instruction. The work the agent keeps when interrupted decides whether stopping feels safe. The person resumes from the point it stopped, not from the beginning.
When to Use It
Humans need to redirect a running agent without losing its work
Stopping must feel safe; the work so far survives the interrupt
Mid-run course corrections are cheaper than restarts
When Not To
Runs are short; cancellation is enough
An interrupt would land seconds too late to matter; ask before the run instead
The work genuinely cannot be preserved; then be honest that stop means abort
Three views
The exchange between the human, the agent and the system the agent acts on; who holds each part of it; and the component that implements it.
01 · the interaction
02 · who holds what
01PauseYou stop the AI without throwing away its work
02StopYou end the run and keep what it had
03RedirectYou give a new instruction while it runs
04State preservationWhat the AI keeps safe when you interrupt it
05ResumeYou pick up from the exact point it stopped
03 · the component · code: experimental
vendor presets are recreations, not vendor assets
THE LIVE PLAYGROUND NEEDS A WIDER SCREEN — IT'S WAITING ON DESKTOP.
THE ANATOMY ABOVE AND THE CODE BELOW ARE THE SAME COMPONENT.
Do / Don't
Do
Let the step in flight finish, or cancel it before it writes, and say which one happened.
Don't
Freeze the agent in the middle of a write and leave a half-written file behind.
Interruption keeps the work that Emergency Stop gives up. A half-written step is lost work that looks saved.
Do
Keep the completed steps when a person redirects, and replace only the steps still waiting.
Don't
Start the run again from the top with the new instruction.
If an interruption costs the person work they already have, they stop interrupting and let a wrong run finish.
Do
Show pausing until the host confirms the step has ended, and paused only after that.
Don't
Report the run as paused the moment the button is pressed.
The pause has to land where the agent runs. Until the host says so, the agent may still be acting.
Do
Switch pause off for a run that cannot keep its work, and say that stop discards the run.
Don't
Offer a pause that throws the run away without saying so.
A person who expects the work to survive will pause without a second thought. Tell them up front if it will not.
Every pair cites a documented tension or principle boundary — no free-floating taste.
Get the Code
Namespaced install (add the registry to components.json once):
/**
* Interruption — headless core.
* Pattern: a person pauses, stops, or redirects an agent mid-task without losing its work.
* Anatomy: Pause · Stop · Redirect · State preservation · Resume
*
* The step in flight is the host's to wrap up, so each interruption is a request
* the host answers. Pause asks the step to finish, stop asks it to cancel, and a
* redirect asks for either. The host calls settle('finished' | 'cancelled') when
* the step has ended, and only then does the component say "paused", "stopped"
* or apply the new instruction. With nothing in flight, the request settles at once.
* Completed steps are never touched: a redirect replaces only the steps still
* waiting, and resume picks up at the first of them.
*
* Lifecycle:
* running → pausing → paused (PAUSE, then SETTLE)
* running | pausing | paused → stopping → stopped (STOP, then SETTLE)
* running → redirecting → running (REDIRECT, then SETTLE)
* paused → running (RESUME; REDIRECT while paused applies at once)
* A run that cannot keep its work (preservable: false) has no pause, and its stop discards.
* Contract events (spec 2026-10-04 §1): execution.paused, execution.stopped and
* plan.revised, each with phase "requested" and then "confirmed"; execution.resumed.
*/
import * as React from 'react';
import type { Actor, InteractionEvent, InteractionEventType } from '@/lib/interaction-contract';
export type { Actor, InteractionEvent, InteractionEventType } from '@/lib/interaction-contract';
export interface TaskStep {
id: string;
label: string;
}
export interface PlannedStep extends TaskStep {
/** "plan" for the agent's steps, "human" for an instruction the person added. */
origin: 'plan' | 'human';
}
export type InterruptionState = 'running' | 'pausing' | 'paused' | 'stopping' | 'stopped' | 'redirecting';
export type StepStatus = 'done' | 'running' | 'pending' | 'cancelled' | 'replaced' | 'lost';
export type InFlightOutcome = 'finished' | 'cancelled';
/** What a redirect does with the step in flight. */
export type RedirectMode = 'finish' | 'cancel';
export type InterruptKind = 'pause' | 'stop' | 'redirect';
/** What the host receives when a step in flight has to end before the interruption holds. */
export interface InterruptRequest {
runId: string;
kind: InterruptKind;
stepId: string;
/** Let the step finish, or cancel it. */
wants: RedirectMode;
instruction?: string;
}
export type Event =
| { type: 'PAUSE'; at?: string }
| { type: 'STOP'; at?: string }
| { type: 'REDIRECT'; instruction: string; mode: RedirectMode; at?: string }
/** The host reports that the step in flight ended. */
| { type: 'SETTLE'; outcome: InFlightOutcome; at?: string }
/** The host started a step (its `current` prop). The step before it, if still running, finished. */
| { type: 'START_STEP'; stepId: string }
| { type: 'RESUME'; at?: string };
export interface MachineState {
state: InterruptionState;
runId: string;
preservable: boolean;
steps: PlannedStep[];
status: Record<string, StepStatus>;
/** The redirect that waits on the step in flight. */
pendingRedirect: { instruction: string; mode: RedirectMode } | null;
/** Contract events in order. The Root hands each one to onEvent once. */
events: InteractionEvent[];
}
export interface InitialOptions {
runId: string;
steps: TaskStep[];
/** Index of the step in flight. Steps before it are done; -1 means nothing is in flight. */
current: number;
preservable?: boolean;
}
export function initialState({ runId, steps, current, preservable = true }: InitialOptions): MachineState {
const status: Record<string, StepStatus> = {};
steps.forEach((st, i) => {
status[st.id] = current < 0 ? 'pending' : i < current ? 'done' : i === current ? 'running' : 'pending';
});
return {
state: 'running',
runId,
preservable,
steps: steps.map((st) => ({ id: st.id, label: st.label, origin: 'plan' })),
status,
pendingRedirect: null,
events: [],
};
}
const now = () => new Date().toISOString();
export const inFlight = (s: MachineState): PlannedStep | undefined => s.steps.find((st) => s.status[st.id] === 'running');
/** Steps that count toward the run: everything but the ones a redirect replaced. */
export const countedSteps = (s: MachineState) => s.steps.filter((st) => s.status[st.id] !== 'replaced');
export const keptSteps = (s: MachineState) => s.steps.filter((st) => s.status[st.id] === 'done');
export const nextStep = (s: MachineState): PlannedStep | undefined => s.steps.find((st) => s.status[st.id] === 'pending');
export const isLive = (state: InterruptionState) => state === 'running' || state === 'redirecting';
function emit<P>(s: MachineState, type: InteractionEventType, actor: Actor, at: string, payload: P, correlationId?: string): MachineState {
const e: InteractionEvent<P> = {
id: `${s.runId}:interrupt:${s.events.length + 1}`,
runId: s.runId,
actor,
type,
at,
payload,
...(correlationId ? { correlationId } : {}),
};
return { ...s, events: [...s.events, e] };
}
const lastEventId = (s: MachineState) => s.events[s.events.length - 1]?.id;
/** Replace every waiting step with the person's instruction. Done work stays done. */
function applyRedirect(s: MachineState, instruction: string): { next: MachineState; replaced: string[]; added: string } {
const replaced = s.steps.filter((st) => s.status[st.id] === 'pending').map((st) => st.id);
const added = `${s.runId}:instruction:${s.steps.filter((st) => st.origin === 'human').length + 1}`;
const status = { ...s.status, [added]: 'pending' as StepStatus };
for (const id of replaced) status[id] = 'replaced';
return { next: { ...s, steps: [...s.steps, { id: added, label: instruction, origin: 'human' }], status, pendingRedirect: null }, replaced, added };
}
function confirmStop(s: MachineState, at: string, stepId: string | null, outcome: InFlightOutcome | null, correlationId?: string): MachineState {
const status = { ...s.status };
if (!s.preservable) for (const st of s.steps) if (status[st.id] !== 'replaced') status[st.id] = 'lost';
const next: MachineState = { ...s, state: 'stopped', status, pendingRedirect: null };
return emit(next, 'execution.stopped', 'system', at, { phase: 'confirmed', stepId, outcome, kept: s.preservable ? keptSteps(s).length : 0, discarded: !s.preservable }, correlationId);
}
function confirmRedirect(s: MachineState, at: string, instruction: string, mode: RedirectMode, stepId: string | null, outcome: InFlightOutcome | null, correlationId?: string): MachineState {
const { next, replaced, added } = applyRedirect(s, instruction);
const state: InterruptionState = s.state === 'paused' ? 'paused' : 'running';
return emit({ ...next, state }, 'plan.revised', 'system', at, { phase: 'confirmed', instruction, mode, stepId, outcome, replaced, addedStepId: added }, correlationId);
}
export function reducer(s: MachineState, e: Event): MachineState {
switch (e.type) {
case 'PAUSE': {
if (s.state !== 'running' || !s.preservable) return s;
const at = e.at ?? now();
const step = inFlight(s);
const requested = emit({ ...s, state: 'pausing' }, 'execution.paused', 'human', at, { phase: 'requested', stepId: step?.id ?? null, wants: 'finish', awaiting: !!step });
if (step) return requested;
return emit({ ...requested, state: 'paused' }, 'execution.paused', 'system', at, { phase: 'confirmed', stepId: null, outcome: null, kept: keptSteps(s).length }, lastEventId(requested));
}
case 'STOP': {
if (s.state !== 'running' && s.state !== 'pausing' && s.state !== 'paused' && s.state !== 'redirecting') return s;
const at = e.at ?? now();
const step = inFlight(s);
const requested = emit({ ...s, state: 'stopping', pendingRedirect: null }, 'execution.stopped', 'human', at, {
phase: 'requested',
stepId: step?.id ?? null,
wants: 'cancel',
awaiting: !!step,
});
return step ? requested : confirmStop(requested, at, null, null, lastEventId(requested));
}
case 'REDIRECT': {
const instruction = e.instruction.trim();
if (!instruction || (s.state !== 'running' && s.state !== 'paused')) return s;
const at = e.at ?? now();
const step = s.state === 'running' ? inFlight(s) : undefined;
const payload = { phase: 'requested', instruction, mode: e.mode, stepId: step?.id ?? null, awaiting: !!step };
if (step) return emit({ ...s, state: 'redirecting', pendingRedirect: { instruction, mode: e.mode } }, 'plan.revised', 'human', at, payload);
const requested = emit(s, 'plan.revised', 'human', at, payload);
return confirmRedirect(requested, at, instruction, e.mode, null, null, lastEventId(requested));
}
case 'SETTLE': {
const step = inFlight(s);
if (!step) return s;
const at = e.at ?? now();
const settled: MachineState = { ...s, status: { ...s.status, [step.id]: e.outcome === 'finished' ? 'done' : 'cancelled' } };
// The confirmation answers the latest request of its kind.
const request = (type: InteractionEventType) => [...s.events].reverse().find((ev) => ev.type === type && (ev.payload as { phase?: string }).phase === 'requested')?.id;
switch (s.state) {
case 'pausing':
return emit({ ...settled, state: 'paused' }, 'execution.paused', 'system', at, { phase: 'confirmed', stepId: step.id, outcome: e.outcome, kept: keptSteps(settled).length }, request('execution.paused'));
case 'stopping':
return confirmStop(settled, at, step.id, e.outcome, request('execution.stopped'));
case 'redirecting': {
const r = s.pendingRedirect!;
return confirmRedirect(settled, at, r.instruction, r.mode, step.id, e.outcome, request('plan.revised'));
}
default:
// The step ended on its own while the run went on.
return settled;
}
}
case 'START_STEP': {
if (s.state !== 'running' || s.status[e.stepId] !== 'pending') return s;
const status = { ...s.status };
const step = inFlight(s);
if (step) status[step.id] = 'done';
status[e.stepId] = 'running';
return { ...s, status };
}
case 'RESUME': {
if (s.state !== 'paused') return s;
const at = e.at ?? now();
return emit({ ...s, state: 'running' }, 'execution.resumed', 'human', at, { fromStepId: nextStep(s)?.id ?? null, kept: keptSteps(s).length });
}
}
}
// ---------------------------------------------------------------- words
const lower = (t: string) => t.charAt(0).toLowerCase() + t.slice(1);
export const stateLabel = (s: MachineState): string =>
({
running: 'Running',
pausing: 'Pausing',
paused: 'Paused',
stopping: 'Stopping',
stopped: s.preservable ? 'Stopped, work kept' : 'Stopped, work lost',
redirecting: 'Redirecting',
})[s.state];
export type StepTone = 'done' | 'flight' | 'finishing' | 'cancelling' | 'cancelled' | 'next' | 'new' | 'waiting' | 'not-run' | 'replaced' | 'lost';
export const stepChip: Record<StepTone, string> = {
done: 'Done, kept',
flight: 'In flight',
finishing: 'Finishing',
cancelling: 'Cancelling',
cancelled: 'Cancelled, nothing written',
next: 'Next',
new: 'Your instruction',
waiting: 'Waiting',
'not-run': 'Not run',
replaced: 'Replaced by you',
lost: 'Lost',
};
export function stepTone(s: MachineState, st: PlannedStep): StepTone {
const status = s.status[st.id];
if (status === 'done' || status === 'cancelled' || status === 'replaced' || status === 'lost') return status;
if (status === 'running') {
if (s.state === 'stopping' || (s.state === 'redirecting' && s.pendingRedirect?.mode === 'cancel')) return 'cancelling';
if (s.state === 'pausing' || s.state === 'redirecting') return 'finishing';
return 'flight';
}
if (s.state === 'stopped') return 'not-run';
if (nextStep(s)?.id === st.id && (s.state === 'paused' || st.origin === 'human')) return 'next';
return st.origin === 'human' ? 'new' : 'waiting';
}
/** The line that says what the last interruption did. Empty while nothing has happened. */
export function noticeText(s: MachineState, agent = 'the agent'): string {
const last = s.events[s.events.length - 1];
if (!last) return '';
const p = last.payload as Record<string, unknown> & { phase?: string; stepId?: string | null; outcome?: InFlightOutcome | null };
const label = p.stepId ? s.steps.find((st) => st.id === p.stepId)?.label : undefined;
const total = countedSteps(s).length;
const kept = keptSteps(s).length;
const cancelledLine = `"${label}" was cancelled before it wrote anything.`;
switch (last.type) {
case 'execution.paused':
if (p.phase === 'requested') return `Pausing. "${label}" finishes first, then ${agent} holds.`;
if (p.outcome === 'finished') return `Paused. "${label}" finished first, so ${kept} of ${total} steps are saved.`;
if (p.outcome === 'cancelled') return `Paused. ${cancelledLine} ${kept} of ${total} steps are saved.`;
return `Paused. ${kept} of ${total} steps are saved.`;
case 'execution.stopped':
if (p.phase === 'requested') return `Stopping. Cancelling "${label}" first.`;
if (!s.preservable) return `Stopped. This run cannot be saved, so nothing from it is kept.${p.outcome === 'cancelled' ? ` ${cancelledLine}` : ''}`;
if (p.outcome === 'cancelled') return `Stopped. ${kept} of ${total} steps are kept. ${cancelledLine}`;
if (p.outcome === 'finished') return `Stopped. ${kept} of ${total} steps are kept. "${label}" finished before the stop.`;
return `Stopped. ${kept} of ${total} steps are kept. Nothing was in flight.`;
case 'plan.revised': {
const next = lower(String(p.instruction));
if (p.phase === 'requested') {
return p.mode === 'cancel' ? `Got it. Cancelling "${label}", then I will ${next}.` : `Got it. "${label}" finishes first, then I will ${next}.`;
}
if (p.outcome === 'cancelled') return `Got it. I cancelled "${label}" before it wrote anything. Next I will ${next}.`;
if (p.outcome === 'finished') return `Got it. "${label}" finished and is kept. Next I will ${next}.`;
return `Got it. Next I will ${next}.`;
}
case 'execution.resumed': {
const from = s.steps.findIndex((st) => st.id === p.fromStepId);
if (from < 0) return 'Resumed. Nothing is left to run.';
const position = countedSteps(s).findIndex((st) => st.id === p.fromStepId) + 1;
return `Resumed at step ${position} of ${total}: "${s.steps[from].label}".`;
}
default:
return '';
}
}
// ---------------------------------------------------------------- React
interface InterruptionContext extends MachineState {
agent: string;
send: (e: Event) => void;
}
const Ctx = React.createContext<InterruptionContext | null>(null);
export function useInterruption(): InterruptionContext {
const ctx = React.useContext(Ctx);
if (!ctx) throw new Error('Interruption parts must be used inside <Interruption.Root>');
return ctx;
}
export interface RootProps {
runId: string;
agent?: string;
steps: TaskStep[];
/** Id of the step in flight. Change it as the run moves on; omit when nothing is in flight. */
current?: string;
/** False when this run cannot keep its work: pause is off and stop discards. */
preservable?: boolean;
/**
* Called when a step in flight has to end before the interruption holds. Wrap the step up as
* asked, then call settle('finished') or settle('cancelled'). Report 'cancelled' only when the
* step left nothing half written.
*/
onRequest?: (request: InterruptRequest, settle: (outcome: InFlightOutcome) => void) => void;
/** Every contract event, once each. */
onEvent?: (e: InteractionEvent) => void;
children: React.ReactNode;
}
function Root({ runId, agent = 'the agent', steps, current, preservable = true, onRequest, onEvent, children }: RootProps) {
const [machine, send] = React.useReducer(
reducer,
{ runId, steps, current: current ? steps.findIndex((st) => st.id === current) : -1, preservable },
initialState,
);
const settle = React.useCallback((outcome: InFlightOutcome) => send({ type: 'SETTLE', outcome }), []);
React.useEffect(() => {
if (current) send({ type: 'START_STEP', stepId: current });
}, [current]);
const sent = React.useRef(0);
React.useEffect(() => {
const fresh = machine.events.slice(sent.current);
sent.current = machine.events.length;
for (const e of fresh) {
onEvent?.(e);
const p = e.payload as { phase?: string; awaiting?: boolean; stepId: string; wants?: RedirectMode; mode?: RedirectMode; instruction?: string };
if (p.phase !== 'requested' || !p.awaiting) continue;
const kind: InterruptKind = e.type === 'execution.paused' ? 'pause' : e.type === 'execution.stopped' ? 'stop' : 'redirect';
const request: InterruptRequest = { runId: e.runId, kind, stepId: p.stepId, wants: p.wants ?? p.mode ?? 'finish' };
if (p.instruction) request.instruction = p.instruction;
onRequest?.(request, settle);
}
}, [machine.events, onEvent, onRequest, settle]);
const value = React.useMemo(() => ({ ...machine, agent, send }), [machine, agent]);
return (
<Ctx.Provider value={value}>
<div role="group" aria-label={`Interrupt ${agent}`} data-pattern="interruption" data-part="root" data-state={machine.state}>
{children}
</div>
</Ctx.Provider>
);
}
/** The agent, its state, and the step in flight. */
function Bar() {
const machine = useInterruption();
const step = inFlight(machine);
const counted = countedSteps(machine);
return (
<div data-part="bar">
<span data-part="agent">
<span data-part="pulse" data-live={isLive(machine.state) || undefined} aria-hidden="true" />
{machine.agent}
</span>
<span data-part="chip" data-tone={machine.state === 'stopped' && !machine.preservable ? 'lost' : machine.state}>
{stateLabel(machine)}
</span>
{step && (
<span data-part="now">
Step {counted.findIndex((st) => st.id === step.id) + 1} of {counted.length}: {step.label}
</span>
)}
</div>
);
}
type TriggerProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>;
/** Lets the step in flight finish, then holds. Off when the run cannot keep its work. */
function Pause({ children, ...props }: TriggerProps) {
const { state, preservable, agent, send } = useInterruption();
return (
<button type="button" data-part="pause" disabled={state !== 'running' || !preservable} onClick={() => send({ type: 'PAUSE' })} {...props}>
{children ?? `Pause ${agent}`}
</button>
);
}
/** Cancels the step in flight and ends the run, keeping completed steps unless the run cannot keep them. */
function Stop({ children, ...props }: TriggerProps) {
const { state, preservable, send } = useInterruption();
return (
<button type="button" data-part="stop" disabled={state === 'stopping' || state === 'stopped'} onClick={() => send({ type: 'STOP' })} {...props}>
{children ?? (preservable ? 'Stop and keep the work' : 'Stop and discard the run')}
</button>
);
}
/** A new instruction, with suggestions and a choice for the step in flight. */
function Redirect({ suggestions = [], label = 'New instruction', placeholder = 'Tell it what to do next', submit = 'Send instruction' }: { suggestions?: string[]; label?: string; placeholder?: string; submit?: string }) {
const machine = useInterruption();
const { state, send } = machine;
const [text, setText] = React.useState('');
const [mode, setMode] = React.useState<RedirectMode>('finish');
const id = React.useId();
const open = state === 'running' || state === 'paused';
const choosing = state === 'running' && !!inFlight(machine);
return (
<form
data-part="redirect"
onSubmit={(ev) => {
ev.preventDefault();
if (!text.trim()) return;
send({ type: 'REDIRECT', instruction: text, mode });
setText('');
}}
>
<label htmlFor={`${id}-ins`}>{label}</label>
<div data-part="redirect-row">
<input id={`${id}-ins`} value={text} placeholder={placeholder} disabled={!open} onChange={(ev) => setText(ev.target.value)} />
<button type="submit" disabled={!open || text.trim() === ''}>
{submit}
</button>
</div>
{suggestions.length > 0 && open && (
<div data-part="suggestions" role="group" aria-label="Suggestions">
{suggestions.map((sg) => (
<button key={sg} type="button" aria-label={`Use suggestion: ${sg}`} onClick={() => setText(sg)}>
{sg}
</button>
))}
</div>
)}
{choosing && (
<fieldset data-part="redirect-mode">
<legend>The step in flight</legend>
<label>
<input type="radio" name={`${id}-mode`} value="finish" checked={mode === 'finish'} onChange={() => setMode('finish')} />
Let it finish first
</label>
<label>
<input type="radio" name={`${id}-mode`} value="cancel" checked={mode === 'cancel'} onChange={() => setMode('cancel')} />
Cancel it now
</label>
</fieldset>
)}
</form>
);
}
/** What is kept: a meter and every step with its status. */
function Steps({ label = 'Steps saved' }: { label?: string }) {
const machine = useInterruption();
const counted = countedSteps(machine);
const kept = machine.preservable || machine.state !== 'stopped' ? keptSteps(machine).length : 0;
const pct = counted.length ? Math.round((kept / counted.length) * 100) : 0;
return (
<div data-part="preserve">
<div data-part="meter">
<span>
{label}: {kept} of {counted.length}
</span>
<div role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={counted.length} aria-valuenow={kept} data-part="meter-bar">
<span style={{ width: `${pct}%` }} />
</div>
</div>
<ol data-part="steps">
{machine.steps.map((st, i) => {
const tone = stepTone(machine, st);
return (
<li key={st.id} data-part="step" data-tone={tone} data-origin={st.origin}>
<span data-part="step-n">{st.origin === 'human' ? '+' : String(i + 1).padStart(2, '0')}</span>
<span data-part="step-label">{st.label}</span>
<span data-part="chip" data-tone={tone}>
{stepChip[tone]}
</span>
</li>
);
})}
</ol>
</div>
);
}
/** One polite line: what the last interruption did. */
function Notice({ children }: { children?: (s: MachineState) => React.ReactNode }) {
const machine = useInterruption();
const text = children ? children(machine) : noticeText(machine, machine.agent);
if (!text) return null;
return (
<p data-part="notice" role="status" aria-live="polite">
{text}
</p>
);
}
/** Picks up at the first step still waiting. Only while paused. */
function Resume({ children, ...props }: TriggerProps) {
const machine = useInterruption();
const next = nextStep(machine);
const position = next ? countedSteps(machine).findIndex((st) => st.id === next.id) + 1 : countedSteps(machine).length;
return (
<button type="button" data-part="resume" disabled={machine.state !== 'paused'} onClick={() => machine.send({ type: 'RESUME' })} {...props}>
{children ?? `Resume from step ${position}`}
</button>
);
}
export const Interruption = Object.assign(Root, { Bar, Pause, Stop, Redirect, Steps, Notice, Resume });
patterns/interruption/interruption.tsx
/**
* Interruption — styled reference implementation of the headless core.
* The agent bar on top, then pause and stop, the redirect, what is kept, and
* resume. Styling lives in tokens.css (.ah-ctl, .ah-interrupt).
*/
import * as React from 'react';
import {
Interruption as Core,
useInterruption,
type InFlightOutcome,
type InterruptRequest,
type TaskStep,
type InteractionEvent,
} from './interruption-core';
export interface InterruptionProps {
runId: string;
agent: string;
steps: TaskStep[];
/** Id of the step in flight. Change it as the run moves on. */
current?: string;
/** Instructions the person can send with one click, as suggestions. */
suggestions?: string[];
/** False when this run cannot keep its work: pause is off and stop discards. */
preservable?: boolean;
/** Wrap up the step in flight as asked, then call settle('finished') or settle('cancelled'). */
onRequest?: (request: InterruptRequest, settle: (outcome: InFlightOutcome) => void) => void;
onEvent?: (e: InteractionEvent) => void;
}
function Body({ suggestions }: { suggestions: string[] }) {
const { state, preservable } = useInterruption();
return (
<section className="ah-ctl ah-interrupt" data-state={state}>
<Core.Bar />
<div className="ah-interrupt-controls">
<div className="ah-ctl-part" data-actor="human">
<span className="ah-ctl-label">Pause</span>
<Core.Pause />
<p className="ah-ctl-hint">{preservable ? 'Lets the current step finish, then holds.' : 'This run cannot hold its place, so pause is off.'}</p>
</div>
<div className="ah-ctl-part" data-actor="human">
<span className="ah-ctl-label">Stop</span>
<Core.Stop />
<p className="ah-ctl-hint">{preservable ? 'Cancels the current step and ends the run.' : 'Ends the run. Nothing from it is kept.'}</p>
</div>
</div>
<div className="ah-ctl-part" data-actor="human">
<span className="ah-ctl-label">Redirect</span>
<Core.Redirect suggestions={suggestions} />
</div>
<div className="ah-ctl-part" data-actor="system">
<span className="ah-ctl-label">State preservation</span>
<Core.Steps />
<Core.Notice />
</div>
<div className="ah-ctl-part" data-actor="human">
<span className="ah-ctl-label">Resume</span>
<Core.Resume />
{state === 'stopped' && (
<p className="ah-ctl-hint">{preservable ? 'A stopped run does not resume. Start a new run from the kept steps.' : 'Nothing to resume. Start a new run.'}</p>
)}
</div>
</section>
);
}
export function Interruption({ runId, agent, steps, current, suggestions = [], preservable, onRequest, onEvent }: InterruptionProps) {
return (
<Core runId={runId} agent={agent} steps={steps} current={current} preservable={preservable} onRequest={onRequest} onEvent={onEvent}>
<Body suggestions={suggestions} />
</Core>
);
}
Give this to your coding agent (shadcn MCP: npx shadcn@latest mcp init --client claude):
Add the @agentsandhumans registry to components.json:
{
"registries": {
"@agentsandhumans": "https://agentsandhumans.ai/api/registry/{name}.json"
}
}
Then install interruption (npx shadcn@latest add @agentsandhumans/interruption) and wire it into the flow where your agent changing an agent's course while it runs, without starting over.
Both sides watched each other work, and both adjusted. I have not seen a deployed product expose the human's reading state as a live signal to the agent. That is the gap this study names.
The paper names five moments where intent can slip between a human and an agent. Each one is a gap a product team can close in the interface. That is the design argument worth taking to your next sprint.
The monitor watched. The agent moved. The monitor missed. Designing a view is not designing oversight. The interface decides whether the human can act in time, not whether they are paying attention.
The human approved the plan; the agent changed the plan and kept going. This is the sharpest evidence I have seen that approval needs to travel with the task, not sit at the front of it.
The repair work was real labor, and none of it was visible in the product. That gap, between what the agent did and what the team could see and correct, is where agent-identity and human-handoff patterns earn their keep.
The study names six distinct moments where a developer must act, not watch. Most tools are built for the moment a task is assigned and leave the rest thin. That gap is where trust breaks.
The progress and interruption patterns aren't nice-to-haves — this study shows their absence is actively harmful. If I'm designing an agent that acts in the world, accessible status events are load-bearing, not polish.
The approval pattern has been living above the transport layer — patched into app logic, inconsistent across hosts. If this lands, the protocol itself will have a place for mid-task human checkpoints. That's the right level to solve it.
This is the approval/interruption seam made into a protocol primitive — the design question is now squarely about what the waiting state looks like and how you stop a user from treating an agent-surfaced URL as suspicious.
Documented sightings (19) — anatomy-mapped observations from vendor materials
The 'Missed' failure setting simulates agents that fail to respond, requiring the main agent to detect and act on unresolved dependencies rather than silently proceeding.
The benchmark models a real-time interruption scenario where each action must be scored and potentially blocked before execution, revealing a fundamental context-deprivation problem.
The benchmark finds models ask for clarification significantly less often when actually executing tasks with tools than when merely judging proposed actions — revealing a systematic under-interruption bias in agentic execution.
The agent autonomously emailed external researchers when it encountered a problem it could not resolve on its own, self-initiating an interrupt outside its designed task loop.
The AI Worker asks for clarification on only 3.5–16.7% of ambiguous turns, revealing that agents systematically under-interrupt when referential uncertainty is high.
The system surfaces 'intervention cues' — signals that a session needs human attention — allowing developers to selectively interrupt only the agents that are stuck or diverging.
The user had to manually intervene to stop the send action — no agent-initiated pause or confirmation was offered before a high-stakes irreversible action.
The gap between threat detection and containment — systems recognizing prompt injection while still acting on it — points to a missing interruption primitive that could halt agent action pipelines upon detection.
The multi-round-trip model allows in-flight requests to be interrupted with back-channel messages rather than requiring a full cancel-and-restart cycle.
The MCP server halts the agentic flow to signal that a sensitive interaction (e.g., payment, step-up auth) requires user action before work can continue.
Organizational protocols for interrupting agent autonomy are proposed as a counterweight to skill atrophy, requiring deliberate friction rather than frictionless escalation.
Chart unlocks as the corpus grows — n = 0 mentions across 0 quarters.
AnthropicEsc to interrupt
OpenAIcancel / stop task
Tensions & Failure Modes
Interrupt cost: if stopping loses work, humans stop stopping
Latency: an interrupt that lands three actions late is not control
Redirect vs. restart: most surfaces only offer the nuclear option
What we are learning from the community
Readers who build with Interruption add what they found: questions, use cases, critiques, other ways to solve it.
An editor reads each one before it shows here. Nothing here changes the pattern's status on its own.
This pattern has no contributions yet. The form below sends the first one.
Oct 4, 2026Component published, experimental: pause, stop and redirect wait for the host to finish or cancel the step in flight, completed steps are kept, and resume starts at the first step still waiting.