event-log.md

August 7, 2026 · View on GitHub

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

Durability map

This page is the hub for the durability story. The neighbours:

  • Human in the loop: pausing at idle and resuming from a persisted snapshot.
  • The step path: per-model-call hosts that append before continuing.
  • Observability: replaying a traced run.
  • SQLite stores (below): the shipped AgentEventLogStore and AgentSnapshotStore implementations.
  • Hosts: where a thread id and a store live in a real deployment.

The model

A machine's durable state is not a snapshot. It is the ordered array of external inputs it has received: effect completions (done/error, outputs inline), user-sent events, timer firings. Because transitions are pure, folding that array through the machine reconstructs the exact snapshot, including which effects were started and which are still owed. The log is the source of truth; everything else is derived.

The rule: external inputs only. Never log raised/internal events; replay re-derives them, and logging them would double-apply. Concurrency is captured, not re-run: the recorded completion order is the serialization, so replay is deterministic even when the live run raced.

This puts one obligation on machine authors: transitions and effect inputs (prompt builders, spawn inputs) must be pure functions of state and event. No Date.now(), no Math.random() inside transition code; inject time and randomness as events or input.

Rules of the event log

Four rules keep a log replayable. Each is enforced somewhere in the library, or detected the moment it is broken.

Journal external inputs only. Raised and internal events are re-derived by replay; recording them applies them twice. runAgent already filters them out, so the rule matters when you build entries yourself.

// Wrong: a self-sent event, already re-derived on replay.
entries.push(createReplayEntry(machine, entries, { type: "RETRY" })); // raised inside the machine

// Right: only what came from outside (effect completions, user events, timers).
entries.push(createReplayEntry(machine, entries, { type: "APPROVE" })); // sent by a human

Keep transitions and effect inputs pure. Nothing statically detects Date.now() or Math.random() in machine code, but the per-entry verification hashes catch it on the next replay as an AgentReplayDivergenceError. Inject time and randomness instead.

// Wrong: the prompt differs on every replay, so effectsHash diverges.
input: () => ({ prompt: `Today is ${new Date().toDateString()}` }),

// Right: the value is in context, put there by an event or the run input.
input: ({ context }) => ({ prompt: `Today is ${context.today}` }),

Never mutate a journaled entry. AgentEventLogStore has exactly four methods (append, read, length, fork): no update, no delete. The in-memory store clones on write and on read, so an edit through a caller's reference does not stick, and a rewritten entry would invalidate every hash after it.

// Wrong: the entry is already the source of truth for everything downstream.
entries[3]!.event = { type: "APPROVE" };

// Right: branch, then append the different event to the new thread.
await store.fork({ threadId: "session-1", newThreadId: "what-if", upToIndex: 3 });
await store.append({ threadId: "what-if", expectedIndex: 3, entries: [correctedEntry] });

Append at the length you read. expectedIndex is optimistic concurrency: a stale writer loses with AgentEventLogConflictError rather than interleaving. Entries must also be contiguous from that index, and event ids unique within the thread.

// Wrong: guesses the position, so a concurrent writer's entries get clobbered or rejected at random.
await store.append({ threadId, expectedIndex: 0, entries: newEntries });

// Right: read the frontier, and treat a conflict as "someone else advanced the thread".
await store.append({ threadId, expectedIndex: await store.length(threadId), entries: newEntries });

Export events from runAgent

Every runAgent result carries the external inputs it observed as a versioned AgentLogEntry[]. Pass it directly to replay:

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

const first = await runAgent(machine, { input, executors });
const { snapshot, effects } = replay(machine, first.events);

A fresh run starts with an envelope around @agent.init. The remaining entries wrap effect completions/failures, externally sent events, and timer firings. Raised events and internal transitions are absent because replay re-derives them.

interface AgentLogEntry {
  schemaVersion: 1;
  id: string;
  index: number;
  recordedAt: string;
  machineId: string;
  machineVersion: string;
  event: EventObject;
  causationId?: string;
  correlationId?: string;
  verification?: { stateHash: string; effectsHash: string };
  metadata?: Record<string, JsonValue>;
}
  • recordedAt is acceptance metadata, never semantic machine time. If a transition needs time, put it in the machine event itself.
  • machineVersion is the explicit run option, else the machine's own version (createMachine({ version })), else its structural hash.
  • verification pins the logical state and still-owed effects after each entry.

XState v6 uses stable category event types with identity in payload fields. Preserve the whole object: an invoke completion is { type: "xstate.done.actor", actorId, sessionId, output }; a timer firing is { type: "xstate.timer", id }. replay rebinds logged actor sessions to the new actor system, so globally unique runtime IDs do not make the log machine-specific.

When resuming by snapshot, pass the preceding events back to keep one complete history:

const second = await runAgent(machine, {
  snapshot: first.snapshot,
  event: { type: "APPROVE" },
  events: first.events,
  executors,
});

replay(machine, second.events);

The events option only carries history forward; snapshot remains the live resume source. If omitted on a snapshot resume, the returned array contains only events observed during that invocation and is not a complete replay from initialization.

To capture the same replayable events while the run is in flight, use onEvent:

const events = [...previousEvents];

const result = await runAgent(machine, {
  snapshot,
  event,
  events: previousEvents,
  executors,
  onEvent: (entry) => {
    events.push(entry);
    appendToStore(entry);
  },
});
  • Fires once per newly observed envelope, including the @agent.init entry on a fresh run.
  • Does not re-emit history supplied through events.
  • Excludes raised and internal events, unlike onTransition, so its output goes straight to replay.

runAgent owns a live XState actor. Its onEvent callback observes an event after XState accepted it and cannot await an asynchronous store before the transition. It is useful for export/write-through recording, but is not an append-before-transition crash-safety guarantee. For that guarantee use the step path.

Log-only resume (crash recovery)

With no snapshot and a self-contained log (a reserved @agent.init first entry, which every fresh runAgent log has), runAgent derives the resume snapshot from the log itself:

const recovered = await runAgent(machine, {
  events: persistedEntries, // no snapshot
  executors,
});
  • Recorded results are replayed, never re-executed: a model call whose completion is in the log runs zero times during recovery.
  • A request that was in flight when the log ended (its completion was never recorded) round-trips as a pending child and re-executes idempotently on restore: XState v6 restarts restored pending invokes.
  • The recovered result's events extends the same log, so the whole history stays replayable.

This is the crash-recovery path for hosts that persist entries as they happen (onEvent or an event-log store): after a process death mid-run, resume from the log alone. Because restart is at-least-once for the in-flight request, executors with non-idempotent side effects should dedupe by their own idempotency key. See examples/crash-recovery.

The JSON wire contract

createReplayEntry, initEntry, every built-in store append, and replay validate the complete envelope. Values that JSON would drop or coerce are rejected with NonSerializableAgentEventError carrying the exact path: undefined, functions, symbols, bigint, non-finite numbers, negative zero, sparse arrays, hidden properties, cycles, Date, Map, Set, and class instances. Framework error events normalize Error values to plain { name, message, cause? } records before storage.

Use assertJsonSerializable(value) and assertAgentLogEntry(entry) at custom transport boundaries. This strict subset makes an exported log mean the same thing in another process or language.

Strict replay verification

Normal replay checks verification hashes when present. verifyReplay requires them on every entry and fails at the first mismatch:

import { AgentReplayDivergenceError, verifyReplay } from "@statelyai/agent";

try {
  verifyReplay(machine, entries);
} catch (error) {
  if (error instanceof AgentReplayDivergenceError) {
    console.error(error.eventId, error.index, error.kind);
  }
}
  • kind: "state": the current machine derived different logical state.
  • kind: "effects": it owed different work, from a changed prompt, model, tool set, task input, or timer.
  • kind: "missing-verification": an entry carried no hashes at all.
  • AgentReplayMachineMismatchError rejects an envelope stamped for another machine id/version before folding it.

Every framework error extends AgentError and carries a stable .code ("replay-divergence", "event-log-conflict", "non-serializable-event", …), so a host can branch on the code instead of on instanceof. The other durability-adjacent errors: AgentIllegalResumeEventError, AgentSnapshotVersionMismatchError, AgentDecisionExhaustedError.

Structural hashing cannot see custom function bodies or schema-validator implementations. Set an explicit machineVersion whenever those semantics change; the per-entry hashes then verify their observable state/effect consequences.

The store contract

AgentEventLogStore is an append-only protocol with optimistic concurrency on the log length:

import { createInMemoryEventLogStore, initEntry } from "@statelyai/agent";

const store = createInMemoryEventLogStore();
const entries = [initEntry(machine, input)];

// append at the expected position (0 = new thread); a concurrent writer
// with the same expectation loses with AgentEventLogConflictError
await store.append({ threadId: "session-1", expectedIndex: 0, entries });

// catch up incrementally
const recent = await store.read("session-1", { from: 3 });
const next = await store.length("session-1"); // the next expectedIndex
  • append is atomic and rejects stale writers with AgentEventLogConflictError (threadId, expectedIndex, actualLength), so two hosts resuming one thread resolve to exactly one winner. Event ids must also be unique within a thread.
  • read/length are the whole history API: the log is the history.
  • fork({ threadId, newThreadId, upToIndex }) copies an exclusive index prefix; atEventId is the inclusive event-id form. Forked entries retain their ids. Rewind and diverge by appending different envelopes to the new thread.

Every entry carries optional host-owned metadata, stored verbatim.

SQLite stores

@statelyai/agent/sqlite ships both durability stores on Node's built-in node:sqlite: no dependencies, but Node-only and requires Node >= 22.18.

  • createSqliteEventLogStore(options) returns an AgentEventLogStore plus close().
  • createSqliteSnapshotStore(options) returns an AgentSnapshotStore plus close().
  • options.database is a file path (or ':memory:') to open, or an existing node:sqlite DatabaseSync handle. Both stores can share one handle.
  • close() only closes a database the store opened itself; a passed-in handle stays the caller's to close.
  • options.tableName defaults to agent_event_log and agent_snapshots. Tables are created on demand.
import { DatabaseSync } from "node:sqlite";
import { createSqliteEventLogStore, createSqliteSnapshotStore } from "@statelyai/agent/sqlite";

const database = new DatabaseSync("./agent.db");
const events = createSqliteEventLogStore({ database });
const snapshots = createSqliteSnapshotStore({ database });

await events.append({ threadId: "session-1", expectedIndex: 0, entries });
await snapshots.save("session-1", snapshot);

append runs its length check and its inserts inside one BEGIN IMMEDIATE transaction. node:sqlite is synchronous, so nothing interleaves between the check and the write: racing appends resolve to exactly one winner, and the loser gets AgentEventLogConflictError.

Fork and diff

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

await store.fork({
  threadId: "session-1",
  newThreadId: "candidate",
  atEventId: "evt_00000007",
});

const diff = diffEventLogs(machine, await store.read("session-1"), await store.read("candidate"));

diffEventLogs returns the exact common prefix, parent-only and fork-only tails, both replay results, JSON Patch-style logical-state changes, and added/removed/changed frontier effects. It is structural only; semantic quality belongs to an evaluator.

Store conformance

createInMemoryEventLogStore() is the reference implementation and the conformance baseline. An append-only log with a unique (threadId, index) constraint is a natural fit for any database; prove yours matches the reference on races, isolation, ordering, and fork semantics:

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

await assertEventLogStoreConformance(() => createMyStore());

Each assertion throws a descriptive Error on the first violation, so any runner (or a plain script) can drive it. The SQLite store passes the same suite.

Snapshots as compaction

AgentSnapshotStore remains useful as an idle-point cache: at a quiescent point (an idle state with no in-flight effects), persist the snapshot and resume from it plus the events appended since, instead of replaying from index 0. Compact only at quiescent points; a snapshot taken mid-flight cannot carry in-flight effect state, but the log can.