Stately
PackagesAgent

Human in the loop

Pause an agent for human input by settling idle, persist the snapshot anywhere, and resume in a different process.

Alpha: @statelyai/agent 2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.

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. No interrupt() to learn. When runAgent reaches a point where nothing is in flight, it settles { status: 'idle', snapshot } instead of hanging. Persist the snapshot, present the human their choices, resume by handing the snapshot back with the chosen event. The machine decides which events are legal; the host delivers them. Nothing about the machine changes between the run that settled and the run that resumes.

Note: Idle is a whole-machine condition: runAgent settles only when nothing else is in flight. If one region of a parallel machine waits for a human while a sibling still has work running, the run finishes that work first. Waits modeled with agent.userInput are exempt (pending placeholders that never block the settle). See Parallel machines and pending user input.

Declare your own wait signal

By default runAgent detects a resting state with a timing heuristic. To settle intentional waits deterministically, tell the machine what "suspended" means. No built-in tag; you choose the signal (a tag, a snapshot.matches(...), or a meta field). Declare it once with setupAgent({ isSuspended }); the predicate travels with the machine (including through machine.provide(...)):

const agentSetup = setupAgent({
  // ...schemas...
  isSuspended: (snapshot) => snapshot.hasTag('awaiting-review'),
});

// ...then mark the waiting states with the tag you chose:
reviewing: {
  tags: ['awaiting-review'],
  on: { APPROVE: { target: 'published' } },
},

A host can override per run by passing isSuspended to runAgent directly. Resolution order: runAgent option → machine-carried predicate → timing heuristic.

The isSuspended option name is provisional and may change before 2.0.

A waiting state

The reviewing state 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: {},
    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, settles idle. Persist the snapshot, wait for the human, resume with { snapshot, event }:

const first = await runAgent(machine, { input: { topic: "release notes" }, executors: { generateText } });
// first.status === 'idle'
const persisted = persistSnapshot(first.snapshot);

// ...later, possibly a different process, the human approved...
const second = await runAgent(machine, { snapshot: persisted, event: { type: "APPROVE" }, executors: { generateText } });
// second.status === 'done'

The shipped helper persistSnapshot(snapshot) handles that round-trip: it deep-clones to plain JSON via JSON.stringify/JSON.parse, returning the exact shape you store and feed back to runAgent({ snapshot }). It drops or throws on non-JSON values exactly as JSON.stringify would, so the persisted value matches what a real persistence layer sees.

Present the human's choices

The getAcceptedEvents(snapshot) helper 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, executors: { generateText } });

while (result.status === "idle") {
  const choices = getAcceptedEvents(result.snapshot);
  const event = await promptUser(choices);
  result = await runAgent(machine, { snapshot: result.snapshot, event, executors: { generateText } });
}

A generic host that builds the human's event dynamically (form input, a webhook payload, an interaction protocol) can't type it against a specific machine. parseAgentEvent(snapshot, event) validates the { type, ...payload } at runtime (accepted event types and registered payload schemas) and returns it typed as the machine's event union, throwing a descriptive error otherwise. This replaces event as never casts in meta-driven hosts:

import { parseAgentEvent } from "@statelyai/agent";

const event = parseAgentEvent(result.snapshot, { type: chosenType, ...formPayload });
result = await runAgent(machine, { snapshot: result.snapshot, event, executors });

Legality comes from the machine, not a system prompt: getAcceptedEvents reports only events the snapshot can take, so a UI built from these choices cannot drive 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 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.)

The recurring shape: run to idle → serialize the snapshot to a handle → store it → later, load and resume with an event. The handle is just the JSON-serialized snapshot; nothing else travels with it, because the snapshot is the whole process state.

  • file-snapshot-store: a node:fs store keyed by session id, resumed across several fresh runAgent calls (SQLite variant sketched inline).
  • machine-as-tool: the same handle passed through a host harness's tool call. startTool runs to idle and returns the handle; resumeTool revives it and delivers the event.

The AgentSnapshotStore type (load(id): Promise<Snapshot | undefined>, save(id, snapshot): Promise<void>) is exported so stores interoperate. Type-only: the library ships no implementation; a store (file, SQLite, KV row) is userland.

To render the choices from a stored handle, rehydrate it 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 event

Resume cannot re-run earlier work. A resumed snapshot starts at the waiting state, so states before it never re-enter: side effects and model calls that ran before the pause run exactly once, however many times you resume. Nothing to isolate, no discipline to remember. (Contrast inline-interrupt designs, where code before the interrupt call re-executes on resume unless manually isolated in its own node.) Re-running work is always an explicit, authored transition, such as a REJECT targeting the drafting state again. 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 in context that is not plain JSON silently corrupts on resume: Date becomes a string, Map/Set become {}, class instances lose their prototype. Keep non-serializable handles (sessions, db clients, sockets) in closures and store only their serializable ids in context; see threading host context.

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: a thrown error, not an error-status settle. No need to pre-check legality yourself:

import { IllegalResumeEventError, runAgent } from "@statelyai/agent";

try {
  await runAgent(machine, { snapshot, event: { type: "NOPE" }, executors: { 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: no transition, and the run settles per normal semantics. To restore the older silent-drop behavior, pass onIllegalResumeEvent: 'ignore'.

Resuming across machine versions

Every settled snapshot carries a plain agentMeta: { machineId, version } field (survives the JSON round-trip). version defaults to getMachineStructuralHash(machine): a dependency-free hash over the machine's structure (state ids/nesting, transition event types and targets, invoke srcs, initial), ignoring prompts, guards, and other functions. So a snapshot persisted for days remembers which machine shape produced it.

On resume, runAgent compares the incoming stamp against the current machine's version. If they differ (a state or transition added, removed, or retargeted since the snapshot was saved):

  • onVersionMismatch: 'throw' (default) throws SnapshotVersionMismatchError with from/to.
  • 'warn' logs once and proceeds; 'ignore' proceeds silently.
  • migrateSnapshot(snapshot, { from, to }) (when provided) runs instead; its return value is the snapshot resumed from, so you can adapt an old snapshot to the new shape.

An unstamped snapshot (no agentMeta) is always accepted. Pass an explicit machineVersion (semver or build id) to control migration boundaries yourself rather than tracking the structural hash.

try {
  await runAgent(machine, { snapshot, event, executors: { generateText } });
} catch (error) {
  if (error instanceof SnapshotVersionMismatchError) {
    // error.from / error.to: the machine changed since this snapshot was saved
  }
}

Reading interaction meta

Schema-typed state meta gives the host a typed interaction label or view hints. Legal choices still come from getAcceptedEvents. Meta is keyed by state id, so pull it off an idle snapshot with getStateMeta:

import { getStateMeta } from "@statelyai/agent";

const interaction = getStateMeta(snapshot).interaction ?? null;

The getStateMeta helper 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), invoke the builtin agent.userInput actor and supply a userInput handler to runAgent:

// in the machine:
reviewing: {
  invoke: {
    src: 'agent.userInput',
    input: ({ context }) => ({ prompt: `How is this draft? ${context.draft ?? ''}` }),
    onDone: ({ output }) => ({ target: 'revising', context: { feedback: output } }),
  },
}

// at the host:
await runAgent(machine, {
  input,
  executors: { generateText },
  userInput: async ({ prompt }) => ask(prompt ?? ""),
});

The handler resolves to a string (what the human typed), so onDone's output needs no cast. For structured input, gather the string(s) and parse/classify in a follow-up state (see twenty-questions) or register a custom actor source in place of agent.userInput.

Without a handler, agent.userInput becomes a pending placeholder instead of an error (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 still has work in flight. An unhandled agent.userInput invoke covers this: it waits indefinitely and does not block idle detection, so the run keeps executing sibling work, then settles idle with two extra fields:

  • pendingUserInputs: one { id, input } entry per pending agent.userInput invoke, carrying the invoke's resolved input (prompt, metadata) for the host to render.
  • persistedSnapshot: a JSON-serializable snapshot that includes the in-flight invokes.

Always persist persistedSnapshot when pendingUserInputs is present. The live snapshot cannot round-trip active children: persisting it here silently drops the pending agent.userInput invokes, and the loss only surfaces on resume.

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, executors: { generateText } });

if (first.status === "idle" && first.pendingUserInputs) {
  await store.save(JSON.stringify(first.persistedSnapshot));
  // ...later, possibly a different process...
  await runAgent(machine, {
    snapshot: JSON.parse(await store.load()),
    executors: { 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, flagged by your own isSuspended signal): the wait is an event choice; resume delivers the chosen event. Best for approve/reject flows where getAcceptedEvents drives 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.
  • Steps: durable hosts that checkpoint after every model call.
  • Agent machines: authoring the states and transitions a waiting state is part of.

On this page