Skip to content

Replay & state

kaboo-runtime treats the AG-UI event log as the single source of truth. Messages, tools, state and sub-agent cards are all reconstructed from it — there is no separate, hand-maintained state blob.

The event log

Each run's events are appended to the thread's log in order, while the run streams. When the run finishes, the store replaces its events with AG-UI's compactEvents of them: text and tool-argument deltas are joined, and state changes are folded into one snapshot. A compacted run replays to the same transcript, the same state and the same sub-agent cards as the stream did, in a fraction of the events. readEvents(threadId) returns the log as stored.

A run's RUN_STARTED is stamped with the input that matters for replay: the messages the run added and any resume answers. A run that failed before the agent said anything still shows what was asked. Tools, state and forwardedProps stay out: they are large, already logged elsewhere, or carry credentials.

Deriving state

State is not stored on its own. deriveState takes the last STATE_SNAPSHOT in an event log and applies every STATE_DELTA after it:

import { deriveState } from "@pgege/kaboo-runtime";
import type { BaseEvent } from "@ag-ui/client";

declare const events: BaseEvent[];
const state = deriveState(events); // Record<string, unknown> | null

The built-in stores use deriveState for readState, and so should custom stores, so behavior is identical everywhere.

Who owns each state key

A thread's state has three kinds of top-level key:

Kind Default keys The browser… Before a run, the agent gets…
hidden kaboo_sessions never sees it the stored value
server kaboo_workspace sees it, read-only the stored value
browser everything else owns it the value the browser sent, or the stored one when it sent none

So a client cannot forge a pending approval, rewrite an agent's transcript or overwrite the workspace the agents maintain, and the agents' sessions, which can be large, never travel to the browser at all. Live events are filtered the same way: a STATE_DELTA that only touches a hidden key isn't sent, and a replay sends the state without it.

import { createKabooRunner, InMemoryThreadStore } from "@pgege/kaboo-runtime";

const runner = createKabooRunner(new InMemoryThreadStore(), {
  stateKeys: { hidden: ["kaboo_sessions"], server: ["kaboo_workspace", "billing"] },
});

A host vouches for a server key itself by returning it from enrich (Server-side props).

State replay is a guarantee, not a convention

kaboo-workflows writes what a turn accumulates back as a STATE_DELTA. Before every run, KabooAgentRunner derives the thread's state and sets it on the agent as input.state, by the ownership above. Every app on the runtime therefore gets the round-trip without writing any code for it:

  • kaboo_sessions.history — every agent's transcript, so multi-agent memory is seeded from the server rather than the browser.
  • kaboo_sessions.paused — the paused checkpoint of a run waiting on the person. This is what makes a question or an approval durable: it is restored onto whichever process serves the next turn, so a workflows restart, a second replica, or a rebuilt session no longer strands it.
  • kaboo_sessions.approvals — each open approval request (interruptId, tool, subject, scopes, input), which the runner matches the person's decisions against (Access control).

Because a paused run depends on this, persisting state events is a requirement of a store rather than a nicety — see thread stores and custom store.

Replay on reconnect

connect(threadId) replays the thread's stored log, then tees any in-flight run:

  • If nothing is running, it emits the stored events and completes.
  • If a run is in flight, it emits the stored prior turns and then forwards live events from the running run.

This is what lets a browser reload — or a second tab — rebuild the full transcript and then continue watching live work.

The stored part is made safe for an AG-UI client first. The client accepts nothing after a run's RUN_ERROR, so a run that failed or was aborted (the client aborts a run to send a new message) replays as finished — after closing any message or tool call it left open — and the turns after it stay visible. A terminal event outside any run is dropped, and messages the log lost are replayed inside the last run rather than after it.

A run that answered its own calls

When an agent calls an MCP App's tool, the run ends waiting on the call, and CopilotKit's MCP Apps middleware answers it in that same response. The runner then runs the agent again with the answer, as the browser does after answering a tool itself, and the turn arrives as one run: one RUN_STARTED, the app, the agent's answer, one RUN_FINISHED. A run still waiting on the browser ends as it is.

MCP Apps' own requests — the app reading its resource or calling its server's tools, sent as runs carrying forwardedProps.__proxiedMCPRequest — pass straight through to the agent: they are not stored, enriched or checked for approvals.

Warming the index after a cold start

The synchronous thread-query methods (listThreads, getThreadEvents, …) read an in-memory index. After a process restart, call hydrate() once to populate it from the store (individual run/connect calls also hydrate their own thread lazily):

import { KabooAgentRunner, InMemoryThreadStore } from "@pgege/kaboo-runtime";

async function main() {
  const runner = new KabooAgentRunner(new InMemoryThreadStore());
  await runner.hydrate();
  return runner.listThreads();
}