Human in the loop
Pause an agent for human input by settling idle, persist the snapshot anywhere, and resume in a different process.
The idle-first model
A state waiting on a human is just a state with no invoke and an on: handler for the human's event. There is no interrupt() call to learn. When runAgent reaches a point where nothing is in flight, it settles { status: 'idle', snapshot } instead of hanging. You persist the snapshot, present the human with their choices, and resume by handing the snapshot back with the chosen event.
The machine decides which events are legal in the waiting state; the host delivers the human's event. Nothing about the machine changes between the run that settled and the run that resumes.
Note: Idle is a whole-machine condition:
runAgentsettles only when nothing else is in flight. In a parallel machine where one region waits for a human while a sibling still has work running, the run finishes that work first. Waits modeled withagent.userInputare exempt: they are pending placeholders that never block the settle. See Parallel machines and pending user input.
Tag intentional waits
By default runAgent detects a resting state with a timing heuristic. Mark states that intentionally wait for an external event with the WAIT_TAG tag and the settle becomes deterministic instead:
import { WAIT_TAG } from '@statelyai/agent';
reviewing: {
tags: [WAIT_TAG],
on: { APPROVE: { target: 'published' } },
},Tags are serializable and show up in the Stately visualizer. The tag is recommended, not required: untagged machines fall back to the heuristic and behave exactly as before. A tagged wait still respects whole-machine idle — a sibling region's in-flight work runs to completion first.
To detect suspension some other way, pass isSuspended. The default is (s) => s.hasTag(WAIT_TAG); a custom detector replaces it:
await runAgent(machine, {
input,
generateText,
isSuspended: (snapshot) => snapshot.matches('reviewing'),
});The
isSuspendedoption name is provisional and may change before 2.0.
A waiting state
In this drafting workflow, reviewing has no invoke. Once reached, nothing happens until a human sends APPROVE or REJECT:
import { z } from 'zod';
import { setupAgent } from '@statelyai/agent';
const agentSetup = setupAgent({
context: z.object({ topic: z.string(), draft: z.string().nullable() }),
input: z.object({ topic: z.string() }),
output: z.object({ published: z.boolean(), draft: z.string() }),
events: {
APPROVE: z.object({}),
REJECT: z.object({ reason: z.string() }),
},
requests: {
writeDraft: {
schemas: { input: z.object({ topic: z.string() }), output: z.string() },
model: 'writer',
prompt: ({ input }) => input.topic,
},
},
});
const machine = agentSetup.createMachine({
context: ({ input }) => ({ topic: input.topic, draft: null }),
initial: 'drafting',
states: {
drafting: {
invoke: {
src: 'writeDraft',
input: ({ context }) => ({ topic: context.topic }),
onDone: ({ output }) => ({ target: 'reviewing', context: { draft: output } }),
},
},
reviewing: {
on: {
APPROVE: { target: 'published' },
REJECT: ({ context, event }) => ({
target: 'drafting',
context: { topic: `${context.topic}\nRevision: ${event.reason}` },
}),
},
},
published: {
type: 'final',
output: ({ context }) => ({ published: true, draft: context.draft ?? '' }),
},
},
});Settle idle, then resume
The first runAgent runs the draft, reaches reviewing, and settles idle. Persist the snapshot, wait for the human, resume with { snapshot, event }:
const first = await runAgent(machine, { input: { topic: 'release notes' }, generateText });
console.log(first.status);
// logs 'idle'
const persisted = JSON.parse(JSON.stringify(first.snapshot));
// ...later, possibly a different process, the human approved...
const second = await runAgent(machine, {
snapshot: persisted,
event: { type: 'APPROVE' },
generateText,
});
console.log(second.status);
// logs 'done'The snapshot is a plain, JSON-serializable object; the JSON.stringify/JSON.parse round-trip above proves it survives a real persistence layer.
Present the human's choices
getAcceptedEvents(snapshot) returns one descriptor per currently-legal event: its type, a synthetic toolName, and its payload schema when registered. Drive the loop off it:
import { getAcceptedEvents, runAgent } from '@statelyai/agent';
let result = await runAgent(machine, { input, generateText });
while (result.status === 'idle') {
const choices = getAcceptedEvents(result.snapshot);
const event = await promptUser(choices);
result = await runAgent(machine, { snapshot: result.snapshot, event, generateText });
}
if (result.status === 'done') {
console.log(result.output);
}Legality comes from the machine, not a system prompt: getAcceptedEvents reports only events the snapshot can actually take, so a UI built from these choices cannot drive the machine into an illegal transition.
Persist and resume across processes
Between iterations, persist result.snapshot anywhere: a database row, a queue message, localStorage, a file. runAgent stops its actor on every settle path (done, idle, error), so resume is always by snapshot, never by holding a live actor. That is what makes the pause durable: a crash, a redeploy, or a days-long wait all resume identically.
For a checkpoint after every model call, not only at settle, see Steps.
Resume cannot re-run earlier work. A resumed snapshot starts at the waiting state, so the states before it never re-enter: side effects and model calls that ran before the pause run exactly once, no matter how many times you resume. There is nothing to isolate and no discipline to remember. (Contrast with inline-interrupt designs, where code before the interrupt call re-executes on resume unless the author manually isolates it in its own node.) Re-running work is always an explicit, authored transition, such as a REJECT that targets the drafting state again. This guarantee is pinned by a test in src/run-agent.test.ts ("pre-idle side effects and model calls run exactly once").
Context must be JSON-serializable. Persisted snapshots round-trip through
JSON.stringify/JSON.parse, so anything incontextthat is not plain JSON silently corrupts on resume:Datebecomes a string,Map/Setbecome{}, and class instances lose their prototype. Keep non-serializable handles (sessions, db clients, sockets) in closures and store only their serializable ids incontext; see host actors.
Idle handles and resuming from storage
The persist-and-resume loop has one recurring shape: run to idle → serialize the snapshot to a handle → store the handle → later, load it and resume with an event. The handle is just the JSON-serialized snapshot; nothing else needs to travel with it, because the snapshot is the whole process state.
- file-snapshot-store: a
node:fsstore keyed by session id, resumed across several freshrunAgentcalls (with a SQLite variant sketched inline). - machine-as-tool: the same handle passed through a host harness's tool call.
startToolruns to idle and returns the handle;resumeToolrevives it and delivers the event.
Illegal resume events throw
Resuming with an event the restored state cannot take is a programmer error, so runAgent throws IllegalResumeEventError (carrying eventType and acceptedTypes) before delivering it — the same class as its bind-time throws, not an error-status settle. You do not need to pre-check legality yourself:
import { IllegalResumeEventError, runAgent } from '@statelyai/agent';
try {
await runAgent(machine, { snapshot, event: { type: 'NOPE' }, generateText });
} catch (error) {
if (error instanceof IllegalResumeEventError) {
// error.acceptedTypes lists what the restored state does accept
}
}A type-legal event a guard rejects is not an illegal resume event: the machine simply takes no transition and the run settles per normal semantics. To restore the older silent-drop behavior, pass onIllegalResumeEvent: 'ignore'.
You still call getAcceptedEvents yourself only when you want to render the choices — rehydrate the handle and read them:
import { createActor } from 'xstate';
import { getAcceptedEvents } from '@statelyai/agent';
const snapshot = createActor(machine, { snapshot: JSON.parse(handle) }).getSnapshot();
const choices = getAcceptedEvents(snapshot); // one descriptor per legal eventReading interaction meta
Schema-typed state meta gives the host a typed interaction label or view hints to render for the human. Legal choices still come from the snapshot via getAcceptedEvents. Meta lives keyed by state id, so pull it off an idle snapshot with getStateMeta:
import { getStateMeta } from '@statelyai/agent';
const interaction = getStateMeta(snapshot).interaction ?? null;getStateMeta merges the active state(s)' meta into one typed object (later/deeper wins for nested or parallel machines, {} when none declare meta), typed from the machine's meta schema. It replaces the older Object.values(snapshot.getMeta())[0] cast. See readInteraction in machine-as-tool.
Inline input without settling
To gather input mid-run without settling idle (a CLI prompt between two model calls, say), invoke the builtin agent.userInput actor and supply a userInput handler to runAgent:
reviewing: {
invoke: {
src: 'agent.userInput',
input: ({ context }) => ({
prompt: `How is this draft? ${context.draft ?? ''}`,
schema: z.object({ feedback: z.string() }),
}),
onDone: ({ event }) => ({
target: 'revising',
context: { feedback: (event.output as { feedback: string }).feedback },
}),
},
}await runAgent(machine, {
input,
generateText,
userInput: async ({ prompt }) => ({ feedback: await ask(prompt ?? '') }),
});Without a handler, agent.userInput becomes a pending placeholder instead of an error. See the next section.
Parallel machines and pending user input
Whole-machine idle is not enough for parallel machines: one region may wait for a human while a sibling region still has work in flight. An unhandled agent.userInput invoke covers this case. It waits indefinitely and does not block idle detection, so the run keeps executing sibling work and then settles idle with two extra fields:
pendingUserInputs: one{ id, input }entry per pendingagent.userInputinvoke, carrying the invoke's resolved input (prompt, schema, and so on) for the host to render.persistedSnapshot: a JSON-serializable snapshot that includes the in-flight invokes. Persist this one; the livesnapshotcannot round-trip active children.
Resume with the persisted snapshot plus a userInput handler. The restored invoke re-runs against the handler and the machine proceeds:
const first = await runAgent(machine, { input, generateText });
if (first.status === 'idle' && first.pendingUserInputs) {
await store.save(JSON.stringify(first.persistedSnapshot));
// ...later, possibly a different process...
const second = await runAgent(machine, {
snapshot: JSON.parse(await store.load()),
generateText,
userInput: async ({ prompt }) => ({ feedback: await ask(prompt ?? '') }),
});
}Resuming without a handler settles idle again with the same pending inputs, so a host can safely re-enter the loop.
Choosing between the two waiting styles
- Idle state (
on:handler, no invoke, marked withtags: [WAIT_TAG]): the wait is an event choice; resume delivers the chosen event. Best for approve/reject flows wheregetAcceptedEventsdrives a UI. agent.userInput: the wait is a value request with a prompt and schema; resume supplies the value. Best for free-form input, and the only style that lets sibling parallel regions keep working while a human is pending.
Related
- Steps: durable hosts that checkpoint after every model call.
- Agent machines: authoring the states and transitions a waiting state is part of.