Postgres WAL as an Agent State Ledger
Rebuilding multi-agent context from write-ahead logs
Multi-agent systems have a state problem that most frameworks paper over: when a worker dies mid-task, the orchestrator needs to know exactly what that agent had already done, what messages it had emitted, and what it was about to do next. In-memory context buffers do not survive process death. Redis snapshots are eventually consistent and lose the tail. Application-level event logs are usually best-effort. A common pattern in production agent swarms is to stop treating the database as a place to store final results and start treating the write-ahead log as the authoritative record of agent intent and action.
This is not a new idea. It is the same reasoning that makes Postgres a durable message queue, applied to agent state. The difference is that agent state is not a row you update. It is a sequence of decisions, tool calls, observations, and handoffs that must be replayable to reconstruct context.
Why agent state is a log, not a row
A single agent turn looks like this: read context, call a model, parse a tool call, execute the tool, observe the result, decide whether to continue. If you store only the final assistant message in a messages table, you lose the causal chain. When the process crashes between tool execution and the next model call, you cannot tell whether the tool ran, whether it succeeded, or whether the model had already seen the result.
Treating each step as an append-only log entry solves this. The log entry is the unit of truth. Current state is a projection. This is the event-sourcing argument, but with a specific twist for agents: the log must be written with the same durability guarantees as the database's own crash recovery, because the agent's next action depends on it.
Postgres gives you that for free. Every committed transaction is in the WAL before it is visible. If you write agent events as rows in a normal table, the WAL already contains them. The question is how to read them back in order and how to make replay idempotent.
Schema: events as the primary artifact
The minimal schema is an append-only table with a monotonic sequence, an agent identity, a session identity, and a typed payload.
CREATE TABLE agent_events (
seq BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL,
agent_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON agent_events (session_id, seq);
CREATE INDEX ON agent_events (agent_id, seq);The seq column is the ordering authority. Do not use timestamps for ordering; clock skew across workers will reorder events. BIGSERIAL is monotonic within a single Postgres instance and is assigned at insert time, which is close enough to commit order for most agent workloads. If you need strict commit-order visibility, use pg_current_xact_id() or a logical replication slot, but understand the tradeoff: transaction IDs are not gap-free and are not suitable as a user-facing sequence.
Event types are deliberately coarse. A typical set:
context.loaded— the exact context window sent to the modelmodel.requested— model name, parameters, prompt hashmodel.responded— raw response, token counts, finish reasontool.invoked— tool name, argumentstool.observed— result or errorhandoff.emitted— message to another agenthandoff.received— message from another agenttask.completed/task.failed
Storing the full context window in context.loaded is expensive but often worth it. It is the only way to reconstruct exactly what the model saw, which matters when debugging non-deterministic behavior or when a prompt template changes mid-session.
Writing events inside the same transaction as side effects
The critical rule: if an agent action has a side effect, the event row and the side effect must commit together. Otherwise you get either a lost action or a phantom action on replay.
For side effects inside Postgres, this is trivial. For external side effects, you need an outbox pattern.
CREATE TABLE agent_outbox (
id BIGSERIAL PRIMARY KEY,
event_seq BIGINT NOT NULL REFERENCES agent_events(seq),
target TEXT NOT NULL,
payload JSONB NOT NULL,
dispatched BOOLEAN NOT NULL DEFAULT false,
dispatched_at TIMESTAMPTZ
);The agent writes the event and the outbox row in one transaction. A separate dispatcher reads undispatched rows, performs the external call, and marks them dispatched. If the dispatcher crashes after the external call but before the update, the call is retried. This means external tools must be idempotent or carry an idempotency key. There is no way around this; exactly-once delivery to an external system is not achievable without cooperation from that system.
Reading the WAL directly
Most teams do not need to parse raw WAL. Logical decoding gives you a structured stream of committed changes without touching the physical format.
SELECT * FROM pg_create_logical_replication_slot('agent_slot', 'pgoutput');Then consume the slot with a client that understands the logical replication protocol. The advantage over polling agent_events is latency and ordering: you see changes as they commit, in commit order, without query load. The disadvantage is operational complexity. Replication slots hold WAL until consumed; a stalled consumer will fill the disk. Monitor pg_replication_slots and set max_slot_wal_keep_size to a bound you can tolerate.
For most agent swarms, polling with a cursor is sufficient and far simpler:
SELECT seq, agent_id, event_type, payload
FROM agent_events
WHERE session_id = $1 AND seq > $2
ORDER BY seq
LIMIT 500;This is not as elegant as logical decoding, but it is easier to reason about, easier to back up, and does not require a replication slot. The tradeoff is a small latency window and the possibility of missing events if a transaction commits out of seq order. In practice, the window is milliseconds and the ordering issue is rare; if it matters, use pg_current_snapshot() to wait for a consistent snapshot before reading.
Rebuilding context on restart
When an agent worker restarts, it needs to reconstruct its context. The replay function is straightforward: read all events for the session up to the last known sequence, fold them into a context object.
def rebuild_context(conn, session_id, upto_seq=None):
query = """
SELECT seq, agent_id, event_type, payload
FROM agent_events
WHERE session_id = %s
"""
params = [session_id]
if upto_seq is not None:
query += " AND seq <= %s"
params.append(upto_seq)
query += " ORDER BY seq"
context = {"messages": [], "pending_tools": {}, "handoffs": []}
with conn.cursor() as cur:
cur.execute(query, params)
for seq, agent_id, event_type, payload in cur:
apply_event(context, agent_id, event_type, payload)
return contextThe apply_event function must be pure and deterministic. It should not call external services, read the clock, or depend on mutable global state. If it does, replay will produce different results than the original run, and you lose the ability to debug.
A subtle issue: tool.invoked without a matching tool.observed means the tool was in flight when the crash happened. On replay, the agent must decide whether to retry or abandon. The safe default is to retry with the same idempotency key, and to record the retry as a new event so the log reflects reality.
def apply_event(ctx, agent_id, event_type, payload):
if event_type == "context.loaded":
ctx["messages"] = payload["messages"]
elif event_type == "tool.invoked":
ctx["pending_tools"][payload["call_id"]] = payload
elif event_type == "tool.observed":
ctx["pending_tools"].pop(payload["call_id"], None)
ctx["messages"].append({"role": "tool", "content": payload["result"]})
elif event_type == "handoff.emitted":
ctx["handoffs"].append(payload)
# ...Compaction and snapshots
Replaying a long session from the beginning is O(n) in events. For sessions with thousands of turns, this becomes slow. The standard solution is periodic snapshots: write the folded context to a agent_snapshots table at a known sequence, then replay only from the snapshot forward.
CREATE TABLE agent_snapshots (
session_id UUID NOT NULL,
upto_seq BIGINT NOT NULL,
context JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (session_id, upto_seq)
);Snapshotting is a projection. It can be rebuilt from the log at any time. The log remains the source of truth. This is the same relationship as a materialized view to its base tables, and it carries the same operational caveat: a bug in the snapshot writer produces a corrupt projection that looks authoritative. Version your snapshot format and keep the ability to replay from zero.
What this buys you
The practical benefits are concrete:
- Crash recovery is deterministic. A worker restarts, replays from the last snapshot, and resumes. No lost tool calls, no duplicated handoffs.
- Debugging is possible after the fact. The exact context sent to the model is in the log. You can diff two runs of the same session and see where they diverged.
- Audit is built in. Every agent decision is a row with a timestamp and a sequence number.
- Backpressure is natural. If the log grows faster than consumers can process, you can pause writers or add partitions without changing the agent logic.
The costs are also concrete:
- Write amplification. Every agent step is at least one row, often more. High-frequency agents will generate significant WAL volume.
- Storage growth. Without compaction, the log grows without bound. Partition by time and archive old partitions.
- Replay complexity. Every event type needs a deterministic fold function. This is code that must be tested as carefully as the agent itself.
Operational notes
A few things that tend to bite teams in production:
- Do not use
SERIALIZABLEisolation for agent event writes unless you have measured the contention.READ COMMITTEDwith explicit ordering is usually sufficient and much cheaper. - Set
synchronous_commitdeliberately.offgives you speed but loses recent events on crash.localis a middle ground. For agent state that must survive, useon. - Monitor WAL generation rate. A sudden spike usually means an agent loop is emitting events faster than expected, often due to a retry storm.
- Partition
agent_eventsbycreated_ator bysession_idhash. Dropping a partition is far cheaper than deleting rows. - Keep the outbox dispatcher separate from the agent workers. If they share a process, a crash takes both down and the outbox stalls.
The ledger mindset
The shift is from "store the result" to "record the intent." Once the log is authoritative, the database is no longer a persistence layer bolted onto an agent framework. It is the coordination substrate. Agents read from it, write to it, and recover from it. The WAL is not an implementation detail you ignore; it is the mechanism that makes the whole thing crash-consistent.
This does not require a new database or a specialized event store. A single Postgres instance with a well-designed event table, an outbox, and a snapshot table will handle a surprising amount of agent traffic. The hard part is not the storage. It is making every fold function deterministic and every side effect idempotent. Get that right, and the rest is just SQL.