Stately
PackagesAgent

The step path

Drive an agent machine one external input at a time over an append-only journal, so a durable host can crash and resume by replay.

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

When to use the step path

The step path (@statelyai/agent/steps) is for hosts that own the loop and persist between model calls:

  • Durable execution engines: Cloudflare Workflows, Temporal, any queue-driven or serverless-per-turn host that must resume from the last completed step.
  • One invocation per turn: a serverless function that runs one frontier, persists, and returns.

For a live, in-process run, use runAgent instead: it owns an actor and settles done | idle | error. The step path is the same machine driven by hand, so a crashed process resumes without re-billing model calls.

The model

A machine's durable state is not a snapshot. It is the ordered journal of external inputs it has received. Because transitions are pure, folding that journal through xstate reconstructs the exact snapshot, including which effects are still owed. The journal is the source of truth; the snapshot is derived.

The loop is six moves:

  1. Start a journal with initEntry(input) (the reserved @agent.init first entry carries the machine input, so the log is self-contained).
  2. initialTransition(machine, input) gives the first { snapshot, actions }.
  3. getAgentEffects(machine, snapshot, actions, { history }) lowers the frontier into an ordered AgentEffect[].
  4. Execute one async effect and journal its completion event (run execute effects inline; they produce no entry).
  5. transition(machine, snapshot, event) folds the completion back in.
  6. Repeat while snapshot.status === "active". No async effect owed means idle: persist the journal and resume later.

Journal rule: external inputs only. The journal holds effect completions (done/error, with the output inline), user-sent events, and timer firings. Never journal raised or internal events: replay re-derives them from the machine's own logic, and journaling them would double-apply.

Determinism obligation. Replay re-runs your transitions, so they must be pure functions of state and event. No Date.now(), no Math.random() inside transition code or effect-input builders (prompt builders, spawn inputs). Inject time and randomness as events or input.

Concurrency is serialization. When parallel regions race, the journaled completion order is the serialization. Replay folds completions in that recorded order, so it never re-races: a run that raced live reconstructs identically.

The loop

Taught from examples/ai-sdk-game-host/index.ts, the canonical thin loop. One host-owned primitive resolves a single frontier effect into the event to journal (or undefined for a fire-and-forget action):

import { initialTransition, transition, type AnyMachineSnapshot, type EventObject } from "xstate";
import {
  executeAgentRequest,
  getAgentEffects,
  initEntry,
  resolveDecision,
  type AgentEffect,
} from "@statelyai/agent/steps";
import type { AgentRequestExecutors } from "@statelyai/agent";
import { gameActors, gameMachine, gameSchemas } from "../game-agent/index.js";

async function resolveEffect(
  effect: AgentEffect,
  snapshot: AnyMachineSnapshot,
  executors: AgentRequestExecutors,
): Promise<EventObject | undefined> {
  // `execute`: a fire-and-forget action. Run it now; never journaled.
  if (effect.kind === "execute") {
    effect.exec();
    return undefined;
  }
  // `text`: resolve with the model, journal the done event. The effect carries
  // its authored `mode`; `executeAgentRequest` dispatches to
  // `generateText`/`streamText` accordingly.
  if (effect.kind === "text") {
    const output = await executeAgentRequest(effect, executors);
    return effect.toDoneEvent(output);
  }
  // `decision`: pick a legal event (guard-gated by snapshot.can), journal it directly.
  if (effect.kind === "decision") {
    return resolveDecision(effect.request, executors.decide!, {
      canTake: (event) => snapshot.can(event as never),
    });
  }
  throw new Error(`This host does not handle '${effect.kind}' effects.`);
}

async function runTurn(input: unknown, executors: AgentRequestExecutors) {
  const options = { schemas: gameSchemas, actorSources: gameActors };
  const entries: EventObject[] = [initEntry(input).event];
  let [snapshot, actions] = initialTransition(gameMachine, input);

  while (snapshot.status === "active") {
    const effects = getAgentEffects(gameMachine, snapshot as AnyMachineSnapshot, actions, {
      history: entries,
      ...options,
    });

    // Resolve one async effect into the event to journal; run execute effects inline.
    let next: EventObject | undefined;
    for (const effect of effects) {
      const event = await resolveEffect(effect, snapshot as AnyMachineSnapshot, executors);
      if (event) {
        next = event;
        break;
      }
    }
    if (!next) {
      break; // idle: nothing async owed. Persist `entries`; resume on the next event.
    }

    entries.push(next);
    [snapshot, actions] = transition(gameMachine, snapshot, next as never);
  }

  return snapshot.output;
}

Per-effect resolution, by kind:

  • text (agent.generateText / a createTextLogic invoke): resolve with the model via executeAgentRequest, then journal effect.toDoneEvent(output) (or effect.toErrorEvent(error)).
  • decision (agent.decide): resolveDecision(effect.request, decide, { canTake }), wiring canTake to snapshot.can so guard-rejected choices are caught and retried. A decision has no output of its own; the chosen machine event is what you journal.
  • task (any other invoke or spawn): a plain host-run actor. Run it in your own runtime, then journal effect.toDoneEvent(output) / effect.toErrorEvent(error).
  • delay (an after(...) timer): the host owns the clock. Schedule the delay (a Workflow sleep, a Temporal timer, a queue delay); when it fires, journal effect.event as a normal external entry.
  • execute (a fire-and-forget action: a custom entry action, sendTo, cancel): run effect.exec() once at the frontier. Never journaled, never replayed.

Idle. A frontier that produces no completion event (all execute, or nothing owed) means the machine is waiting on an external event or a timer. Persist entries and leave the loop; resume later by appending the event and folding it in (or by replay).

For the durable, resume-by-replay flavor of this same loop (persist nothing but the journal, rebuild the frontier with replay each turn), see examples/cloudflare-workers-ai-host/index.ts.

Ordering guarantee

getAgentEffects emits a single transition's effects in document order. An entry action, then a spawn, then a sendTo to that spawned child yields execute, task, execute in that order, never a reordered set. This is load-bearing: the sendTo must run after the child it targets is started. Effects visible only on the snapshot (a re-surfacing agent.plan, children spawned by an earlier transition still pending) append after the action-derived effects, deduped by site id.

requestId and idempotency

Every non-execute effect carries a requestId of the form site#occurrence: the invoke/spawn site id, then a 1-based occurrence derived from the journal (job#1, job#2 on re-entry). Because it is derived from the log, the same journal yields identical requestIds on every replay.

Use it as the idempotency key for at-least-once effect execution. A host runs the effect, then appends its completion; a crash in that window re-runs the effect on resume. An idempotent downstream (keyed by requestId) closes the gap. Errors count as completions: xstate.error.actor.<id> routes onError and increments the occurrence, so a retry is a fresh occurrence (#2, #3, …), not a re-run of the same one.

Crash recovery and resume

replay(machine, entries) folds a journal without executing anything and returns { snapshot, effects }: the final snapshot plus the effects still owed at the frontier. This is crash recovery, fork resume, and time travel in one call.

Still-owed dynamic spawns are recovered too. A fan-out that spawned N branches and journaled 2 of N completions before a crash: replay re-derives the one owed branch task (with its correct requestId), so completing it finishes the run identically to the uninterrupted path (pinned in src/effects.test.ts).

import { replay } from "@statelyai/agent/steps";

// Fresh process: rebuild the frontier from the persisted journal alone.
const { snapshot, effects } = replay(gameMachine, entries, options);
// resolve `effects`, journal the completion, replay again (or fold with transition for speed).

Compaction. A snapshot taken mid-flight cannot carry in-flight effect state; the journal can. So snapshot only at quiescent / idle points. Resume from an idle snapshot plus the entries appended since, or replay the whole journal from index 0. Both yield the identical state. See Checkpoints and the event log for the AgentEventLogStore protocol and the snapshot-as-compaction cache.

Plans

An agent.plan invoke applies an ordered sequence of legal events (each a decision), not one. On the step path it surfaces as a kind: 'plan' effect that re-surfaces on every frontier while the plan is in flight, its candidates recomputed from the live snapshot.

Per frontier, the host resolves one decision from request.events (via resolveDecision, wiring canTake to snapshot.can, plus the reserved done move and any stopOn events) and journals the chosen machine event. The next frontier re-surfaces the plan effect. The plan completes on the reserved agent.plan.done move (PLAN_DONE_EVENT_TYPE), a stopOn event, an exhausted maxSteps budget, or no legal events. Completion is journaled as the invoke's xstate.done.actor.<id> event carrying { steps, stopped }, which fires its onDone.

The re-surfaced effect's own applied / stepsRemaining are not folded under pure replay (the thin loop journals bare machine events, not the invoke child's ledger mutations), so the host derives the applied trail from the journal itself. A single applied event that exits the invoking state cancels the invoke (onDone never fires), identical to runAgent. The full host-side plan driver, and parity with runAgent across all four stop reasons, is pinned in src/steps-plan.test.ts.

What is not journaled, and not durable

execute effects (fire-and-forget custom actions, sendTo, cancel) run once at the frontier and are skipped on replay (replay re-derives them). They are not durable state.

Anything that must survive a crash has to be an invoke or spawn of a registered actor source, so it surfaces as a text / decision / plan / task effect whose completion is journaled (the completion-event rule). A side effect written as a plain fire-and-forget action is not recoverable; model it as an invoke if its result must be replayed.

Known limits

Stated plainly (see src/effects.ts):

  • Every spawn is host-executed on this path. A spawned machine surfaces as a task the host runs, not a live nested child actor. No live nested child machines yet.
  • Streaming output has no journal channel. A text effect carries its mode and executeAgentRequest dispatches to streamText, but chunks are a live-host concern; only the final output lands in the journal.
  • decision effects assume the chosen event exits the invoking state. A decision whose chosen event keeps the machine in the same invoking state is not modeled here.

On this page