Skip to Content
Core ConceptsRuns and the Event Log

Runs and the Event Log

Every turn — a chat message or a workflow invocation — appends events to one ordered log for that run. This log, not anything an engine keeps for itself, is what every consumer of a run reads: replay, audit, the HTTP surface, pause and resume.

Every way of starting a turn writes here, and they all write the same thing. run and stream record a run exactly as POST /agents/{name}/chat and POST /workflows/{name} do, because all of them play the turn on the same Runtime. So a run you started from a script is as readable afterwards as one a client started over HTTP — same events, same order, same store. Deck has no public store property; reach for agentdeck.composition.resolve_event_store() — the same one the Runtime itself uses — to read one back.

from agentdeck import Agent greeter = Agent(name="Greeter", instructions="You are a friendly scheduling assistant. Keep replies to one short sentence.")
import asyncio import os os.environ["AGENTDECK_EVENTS"] = "sqlite://./events.sqlite3" from agentdeck import Deck from agentdeck.composition import resolve_event_store from agentdeck.core import RunContext from agentdeck.core.status import status_of async def main() -> None: run_id = None async with Deck.from_project() as deck: # A run_id, not a context: the Runtime mints it and puts it on every event, so a # caller that wants it reads it off the stream. async for event in deck.stream( "Greeter", "book me a slot Tuesday", session_id="sess-1", namespace="workspace:acme" ): run_id = run_id or event.run_id # already durable — appended before it is yielded assert run_id is not None # A second, independent handle on the same store: what a later process, or a # dashboard, reads back. The store is an internal port, so it still takes a context. store = resolve_event_store() ctx = RunContext(run_id=run_id, session_id="sess-1", namespace="workspace:acme") events = await store.read_run(ctx.log_key, run_id, ctx) for event in events: print(event.seq, event.kind) seqs = {event.seq for event in events} gaps = [n for n in range(max(seqs) + 1) if n not in seqs] if seqs else [] print("gaps:", gaps) print("status:", status_of(events)) asyncio.run(main())
0 run.started 1 text.delta 2 text.delta 3 usage.reported 4 message.completed 5 run.completed gaps: [] status: completed

The two calls to Deck.from_project() and resolve_event_store() never touch each other directly — the second one is a fresh store handle, built the same way the Runtime built its own. What comes back is whatever the first call actually wrote, nothing cached in between — the same handle a later process, or a dashboard, would build to read exactly what this run wrote.

The exact kinds in between run.started and run.completed depend on what the run did, not on a fixed shape: a text.delta per streamed chunk, a tool.call.started/tool.call.completed pair per tool call, usage.reported per model call, message.completed once a full reply is in. This run made no tool call, so none of those appear above.

seq is the order, and the loss check

seq is per-run and contiguous from zero — 0, 1, 2, … with no gaps — which is what makes it the ordering authority rather than ts, the wall-clock timestamp carried alongside it for information only. The gap check above is that made concrete: the missing numbers in one run’s events, [] when there are none. A consumer that gets events out of order, or suspects one went missing, refetches the run and runs the same check over the result instead of guessing from timing.

Every event says which schema wrote it

Each envelope carries v, a {major, minor} pair, and the two halves mean different things. major is what a reader must already understand to parse the envelope at all: reading a log whose major this version does not support fails on the first event, by name, rather than as a validation error on a model you have never met. minor records an addition an old reader already tolerates by construction — a kind it has never seen arrives as UnknownEvent, a content block it has never seen as UnknownBlock, and neither consults the number to do it.

So a minor bump is safe to read with an older version and a major bump is not, which is also the migration rule: a durable log written by an incompatible major has to be replayed into a new store, or read with the version that wrote it. Only sqlite, postgresql and redis are affected — memory:// keeps nothing across a restart, so there is nothing to migrate.

Status is a fold, not a field

A run’s status — pending, running, paused, waiting_human, completed, failed, or cancelled — is not written anywhere as its own row. status_of derives it by folding the run’s own lifecycle events in order and taking the last transition; a log with none of those folds to pending, and a log ending in run.completed folds to completed every time it’s asked, with no cache to go stale after a restart. paused and waiting_human differ in what resuming them takes — nothing for a pause, an answer for an interrupt — which Run Control covers in full.

The log is not the engine’s memory

This log is the only thing any surface, protocol adapter, or dashboard is allowed to read — but it is not what feeds the model on the next turn. Each engine keeps its own execution state privately: the OpenAI Agents SDK’s session, LangGraph’s checkpointer. That state is what a run actually resumes from; this log is a record of what happened, kept for everyone else.

The trade that follows is real, not a technicality: this log gives you every input, every tool call, and every completed message, at the message level — never a byte-exact replay of what the model itself saw. A tool result here is a capped preview, size, and hash, not the tool’s actual output; a model’s internal reasoning between messages isn’t recorded here at all. Build an audit trail on this log and it will tell you truthfully what happened and in what order. It will not hand you back the exact context an engine fed the model to produce it.

Where the log lives

AGENTDECK_EVENTS’s scheme picks the store: memory:// (the default) keeps it in the process and loses it on exit; sqlite://<path> (as above) survives a restart; redis:///rediss:// and postgresql:// are the two several workers can share. SQLite’s cross-process story is a shared file, not shared memory: several processes on the same machine can open it, but a file can’t reach a second machine — which is exactly why redis and postgresql exist as separate options rather than “just point everyone at the same file.”

A listed exception: timer resumes

The claim at the top of this page — every consumer of a run reads this log — has one carve-out. Deck.due_resumes() lists which timer-paused workflow threads are due, and Deck.tick() resumes them, by reading each workflow’s own LangGraph checkpointer, not this log. That is deliberate: the checkpoint backend defaults to durable (sqlite) while the event store defaults to memory, so listing off the log alone would stop surviving a process restart under the default configuration — exactly the guarantee a due timer keeps its wake-up call across one restart. When a due thread also has a run Deck.pending() already knows about (parked by a Deck.run() or HTTP call, which does go through the Runtime), tick() resumes it through the Runtime, so that resume itself lands in the log like any other; only a thread with no logged run at all — parked by calling a durable Workflow’s own run/resume directly — falls back to resuming straight off the checkpointer, since there is no log entry to reconcile. See Workflows → Timers for where this shows up in practice.

Next: Run Control covers what a run in flight can be asked to do, and how those requests land in this same log.

Last updated on