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: completedThe 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.