The event log
The event log is an append-only journal of the external inputs a machine consumed. It is the source of truth for a run: replay folds it back into a snapshot without executing anything.
runAgent({ store, threadId }) writes and reads the log for you. The primitives on this page (replay, forkEventLog, initEntry, createReplayEntry, the stores, and the conformance suite) are in @statelyai/agent/log.
What is journaled
Only external inputs, in the order the run accepted them:
- The reserved
@agent.initfirst entry, carrying machineinputor a persistedsnapshot. - Host-sent events.
- Child completions:
xstate.done.actorandxstate.error.actor, with output inline. - Timer firings.
@agent.usagespend records.
Raised and internal events are never journaled. Replay re-derives them from the machine's own logic, so journaling them would apply them twice.
Recorded results are never re-executed. A model call whose completion is in the log runs zero times on replay.
Entry shape
interface AgentLogEntry {
schemaVersion: 1;
id: string;
index: number;
recordedAt: string;
machineId: string;
machineVersion: string;
event: EventObject;
causationId?: string;
metadata?: Record<string, JsonValue>;
verification?: { stateHash: string };
}recordedAtis acceptance metadata, not machine time. A transition that needs time takes it from the event.machineVersionis the machine's declaredversion, or its structural hash when none is declared.verification.stateHashis the hash of the persisted snapshot after the entry is applied.metadatais host-owned and stored verbatim. The init entry'smetadata.executionIdnames the lineage; read it withgetLogExecutionId(entries).
Entries are strict JSON. createReplayEntry, initEntry, and every store append reject values JSON would drop or coerce (undefined, functions, symbols, bigint, non-finite numbers, Date, Map, Set, class instances, cycles) with NonSerializableAgentEventError, which carries the offending path. Error payloads on xstate.error.actor are normalized to { name, message, cause? }. Use assertJsonSerializable and assertAgentLogEntry at custom transport boundaries.
Record a log
runAgent returns a complete, self-contained segment as result.events. Two ways to get it into storage:
store | onEvent | |
|---|---|---|
| When it writes | Write-ahead: every entry is appended to the store, and pending writes are awaited before each model call | Synchronously, as the entry is accepted |
| Waits | The result resolves only after the run's writes land | Never awaited |
| On failure | The run stops: { status: "error", cause: "journal" } | Nothing; the host owns it |
| Guarantee | Append-before-execute | At-least-once, if the host persists there |
store: write-ahead
Pass a store and the thread to write to. The run reads that thread as its resume log and appends to it.
const result = await runAgent(machine, {
input,
store,
threadId: "session-1",
executors
});- Each entry is written at its own
indexasexpectedIndex, so a concurrent writer conflicts instead of interleaving. - No model call starts until every entry before it is durable. Pure transitions never wait.
- A rejected write (an
AgentEventLogConflictError, or any storage failure) aborts in-flight work and settles the run{ status: "error", cause: "journal" }. No further calls run. - A
@agent.usageentry that arrives after the run settled is still written, but the result does not wait for it. Awaitresult.drain()before terminating the process if you care about those straggler entries; it resolves once every write issued so far has landed, and never rejects (a failed write already settled the run withcause: "journal"). threadIdis required with astore; without itrunAgentthrowsAgentErrorwith codemissing-thread-id.- Passing
eventsas well makes that log the resume, and it must BE the thread's log: a different length throwsAgentEventLogConflictError, and an entry-for-entry mismatch at the same length throwsAgentErrorwith codeevent-log-conflict.
onEvent: observer
onEvent fires once per newly accepted entry, init entry first. It is synchronous and never awaited: it observes an entry after XState accepted it and cannot hold up the transition. Persisting there is at-least-once, not append-before-execute.
const appended: AgentLogEntry[] = [];
const result = await runAgent(machine, {
input,
executors,
onEvent: (entry) => appended.push(entry)
});To build entries yourself, use initEntry for index 0 and createReplayEntry for each subsequent external input.
Replay
replay is a pure fold. No actor starts, no action runs, no model is called.
const { snapshot, persistedSnapshot } = replay(machine, entries);- The first entry seeds the fold:
inputfolds frominitialTransition,snapshotrestores purely and the rest of the log folds onto it. replay(machine, entries.slice(0, n))rebuilds the state as of any point in the log — time travel with no side effects.- Journaled child sessions are rebound to the sessions the current fold minted, so completions still apply.
Verification
Every entry carries verification.stateHash by default. replay checks it as it folds.
verify | Behavior |
|---|---|
true (default) | Checks entries that carry a hash; entries without one pass. |
'strict' | Requires a hash on every entry. |
false | No checks. |
try {
replay(machine, entries, { verify: "strict" });
} catch (error) {
if (error instanceof AgentReplayDivergenceError) {
console.error(error.eventId, error.index, error.kind);
}
}kind: "state"means the machine derived a different state than the one recorded.kind: "missing-verification"means an entry carried no hash under'strict'.AgentMachineVersionMismatchErrorrejects an entry stamped for another machine id or version before folding it. The init-with-snapshot entry is exempt: that entry is the version bridge.
Structural hashing cannot see function bodies or validator implementations. Bump the machine's own version when those semantics change.
Hazards
Replayability rests on pure transitions.
- Keep transitions, guards, and request inputs pure functions of state and event.
Date.now()orMath.random()in a guard produces a different fold and throwsAgentReplayDivergenceErroron the next replay. Inject time and randomness as events or as input. - Never mutate a journaled entry. There is no update and no delete. Rewriting an entry invalidates every hash after it; fork instead.
- Journal external inputs only when building entries by hand.
- Keep context JSON-serializable. Hold sessions, clients, and sockets in closures and store only their ids.
- Replay against the machine
runAgentfolded. When actors come in throughrunAgent({ actors }), callreplay(machine.provide({ actors }), entries). The executor-bound machine is never needed; recorded results replace executors. - A run whose initial state cannot be serialized to JSON produces no log:
result.eventsis empty,onEventnever fires, and nothing is written to astore.replayrejects a log without an init entry, sorunAgentjournals nothing rather than a suffix. onEventalone does not make a call append-before-execute. A crash between an entry and the host's flush loses it. Usestorewhen that matters.
Fork
forkEventLog(entries, upToIndex) returns the prefix [0, upToIndex) as a new, still self-contained log. upToIndex is exclusive and must leave the init entry in place. A fork copies the init entry, so it inherits the parent's execution id.
const branch = forkEventLog(entries, 8);store.fork({ threadId, newThreadId, upToIndex }) does the same at the storage layer. To diverge, append different entries to the new thread.
Usage totals
getUsageFromEvents(entries) folds the @agent.usage entries into one cumulative total. The log is the source of truth, so the totals are a projection of it rather than a host-side accumulator. See Usage and budgets.
Stores
AgentEventLogStore is the one interface a host implements. It is append-only, with optimistic concurrency on log length.
const store = createInMemoryEventLogStore();
await store.append({ threadId: "session-1", expectedIndex: 0, entries: [initEntry(machine, { input })] });
const recent = await store.read("session-1", { from: 3 });
const next = await store.length("session-1"); // the next expectedIndexappendis atomic. A stale writer fails withAgentEventLogConflictError, carryingthreadId,expectedIndex, andactualIndex, so two hosts resuming one thread resolve to exactly one winner. Entry indices must be contiguous fromexpectedIndex, and ids unique within the thread.runAgent({ store, threadId })drives all three:readto resume,appendper entry,readagain to check an expliciteventslog against the thread.readandlengthare the only reads. Everything else about a thread is derived from its entries.forkcopies a prefix onto a fresh thread.
createInMemoryEventLogStore() is the reference implementation. No SQLite or database store ships with the package: hosts own durability. An append-only table with a (thread_id, index) primary key maps onto any database.
Store conformance
await assertEventLogStoreConformance(() => createMyStore(), { describe, it });The suite runs a host-written store against the reference on races, isolation, ordering, and fork semantics. It drives any test runner, or a plain script.
Related
- Persistence: persisting the log, snapshot-as-cache, and the version bridge.
- Hosts and executors: idempotency keys for at-least-once execution.
- Observability: traces alongside the log.