The step path
Drive an agent machine one model call at a time so a durable host can checkpoint after every step.
Why it exists
The step path is a set of helpers that advance an agent machine one transition at a time, handing you a plain, persistable checkpoint after every model call. It is the durable-host counterpart to runAgent, not a lesser version of it.
runAgent checkpoints only when the run settles (done, idle, error). That is enough for most hosts, but not for Cloudflare Workflows, Temporal, or anything that must resume from the last model call rather than the last settle. For those hosts, drive the loop yourself.
Why this runs anywhere
The step path works in any host (Temporal, Workflows, serverless, a queue consumer, a plain server) because of one property: every step helper is a pure, synchronous function. initialAgentStep, transitionAgentStep, and resolveAgentStep never await, never do IO, and always return the same step for the same inputs. All asynchrony lives in your code, between steps.
That split is the whole durability story:
- The host journals each async result (model output, actor result, timer firing) in its own runtime as its own activity.
- Replay is deterministic: re-applying the journaled events through the pure transitions reconstructs the exact snapshot, so a crashed run resumes without re-billing model calls.
- Nothing async hides inside a transition, so there is nothing the host cannot checkpoint around.
The flip side: because transitions are pure, the host must execute everything async itself. There are exactly three kinds:
- Model requests (
step.requests): text requests viaexecuteAgentRequest, decisions viaresolveDecision. The loop below. - Plain actors (a non-model
invoke, like a send-email side effect): these do not appear instep.requests. They surface instep.actionsas spawn actions carryingid,src, andinput. The host runs the effect in its own runtime, then feeds the result back withresolveAgentStep(machine, step, id, output). - Delayed transitions (
after): schedulable raise actions instep.actions; the host owns the clock (see below).
A step with requests: [] and done: false therefore means one of two things: the machine is idle waiting for an external event (a human reply: persist the snapshot and resume later with transitionAgentStep), or a plain actor or timer is pending in step.actions and the host must execute it. Check step.actions to tell them apart; do not treat empty requests as done or as an error.
The step loop
initialAgentStep starts a machine and returns its first step. Loop until step.done:
- A decision request goes through
resolveDecision(wirecanTaketostep.snapshot.canso guard-rejected choices are caught and retried), thentransitionAgentStepapplies the chosen event. - A text request goes through
executeAgentRequest, thenresolveAgentStepfeeds the output back.
import {
executeAgentRequest,
initialAgentStep,
resolveAgentStep,
resolveDecision,
transitionAgentStep,
} from '@statelyai/agent';
let step = initialAgentStep(gameMachine, input, {
schemas: gameSchemas,
actorSources: gameActors,
});
while (!step.done) {
const [request] = step.requests;
if (!request) {
// Idle (waiting for an external event) or a plain actor/timer is
// pending in step.actions. Persist the snapshot and leave the loop;
// resume later with transitionAgentStep (see "Why this runs anywhere").
break;
}
if (request.kind === 'decision') {
const chosenEvent = await resolveDecision(request, decide, {
canTake: (event) => step.snapshot.can(event),
});
step = transitionAgentStep(gameMachine, step, chosenEvent, {
schemas: gameSchemas,
actorSources: gameActors,
});
continue;
}
const output = await executeAgentRequest(request, executors);
step = resolveAgentStep(gameMachine, step, request, output, {
schemas: gameSchemas,
actorSources: gameActors,
});
}
console.log(step.snapshot.output);One-line loop with resolveAgentRequests
The dispatch above — pick the pending request, branch on kind, resolve it, advance — is the same every turn. resolveAgentRequests collapses it into one call: it resolves the current step's pending request (decision or text) and returns the next step, wiring canTake to step.snapshot.can for you. A complete durable host is two lines:
import { initialAgentStep, resolveAgentRequests } from '@statelyai/agent';
let step = initialAgentStep(gameMachine, input, { schemas: gameSchemas, actorSources: gameActors });
while (!step.done) {
step = await resolveAgentRequests(gameMachine, step, executors, { schemas: gameSchemas, actorSources: gameActors });
}
console.log(step.snapshot.output);Reach past it to the manual dispatch (shown above) when a host must interleave its own work between the request and the transition — per-turn persistence, one serverless invocation per turn, or a plain actor/timer pending in step.actions. resolveAgentRequests throws a clear error if the executor a request needs (generateText/streamText or decide) is missing. It does not surface plan requests yet — run plans with runAgent.
See examples/ai-sdk-game-host/index.ts for the full loop, and Decisions for the validate-and-retry rules.
Note:
executeAgentRequestis text-only; passing akind: 'decision'request throws. A decision has no output value to feed intoresolveAgentStep; it produces a chosen event applied withtransitionAgentStep.
The floor: no executors at all
executeAgentRequest and resolveDecision are conveniences, not requirements. A kind: 'text' request carries everything needed (model ref, system, prompt, messages, tools, outputSchema, maxOutputTokens); call any API yourself and feed the result back:
const text = await yourOwnFetch(request);
step = resolveAgentStep(machine, step, request, text);For decisions, request.events carries the candidates (type, toolName, payload inputSchema). Pick an event however you like (your own model call, a rules engine, a human) and apply it with transitionAgentStep; guards still reject illegal events. resolveDecision remains available a la carte when you want its validate-and-retry loop (canTake: (e) => step.snapshot.can(e)).
The full ladder: runAgent (owns the loop), resolveAgentRequests (one call per iteration), raw step helpers with executors, raw step helpers with your own code, the machine as pure oracle.
The AgentStep shape
Each step is a plain, inspectable object:
snapshot: the machine's current snapshotactions: the executable actions that produced itrequests: pending text/decision work, akind-discriminated union ('text' | 'decision')done: whether a final state was reached
A durable host persists this after every model call. Both request kinds are plain data, so a host can serialize a request, schedule the model call in its own runtime, and resume the loop later. transitionAgentStep accepts a raw snapshot or a whole prior step as its second argument.
Delayed transitions
An after does not run on a live timer here. It surfaces in step.actions as a schedulable raise action, and the host owns the clock: schedule the delay with a Workflow sleep, a Temporal timer, or a queue delay, then apply the event with transitionAgentStep when it fires.
Steps as an event log
Think of the step path as event sourcing:
- Each step applies exactly one event: a machine transition, a resolved text result, or a decision's chosen event.
- Persisting the ordered event log, not just the latest snapshot, is what makes replay and audit possible.
- A snapshot is a compaction checkpoint, not the source of truth.
This is why the step helpers hand back plain objects rather than owning a live actor: the host decides what to persist, when, and where.
Related
- Hosts and executors: the executor functions the step loop calls.
- Human in the loop: idle states and resuming from a persisted snapshot.