There are two shapes for an agent’s state:

  1. Mutable state: the current message list, current tool stack, current memory — overwritten as the agent runs.
  2. Event-sourced state: an append-only log of every event that ever happened; the current view is a projection of that log.

openclaw-rs picks the second.

Why event sourcing fits agents

Agents are conversations. Conversations are sequences. A conversation is exactly the kind of thing you want to:

  • Replay for debugging.
  • Audit for compliance.
  • Time-travel to investigate “what did the agent see at step 12?”
  • Branch to A/B-test prompt changes against a real history.
  • Project into different views (chat UI, agent memory, training dataset) without coupling write paths.

The mutable model lets you do none of these without a lot of bookkeeping. The event-sourced model gives them to you for free.

The eight SessionEvent kinds

pub enum SessionEventKind {
    SessionStarted { channel: ChannelId, peer_id: PeerId },
    MessageReceived { content: String, attachments: Vec<Attachment> },
    MessageSent     { content: String, message_id: MessageId },
    ToolCalled      { tool: String, params: serde_json::Value },
    ToolResult      { tool: String, result: serde_json::Value, success: bool },
    AgentResponse   { content: String, model: String, tokens: u64 },
    SessionEnded    { reason: EndReason },
    StateChanged    { key: String, value: serde_json::Value },
}

Eight is small. That’s the point. Anything you can do during a session falls into one of those buckets — and the bucket carries enough metadata to reconstruct what happened without ambiguity.

Why sled

The EventStore is backed by sled: pure-Rust embedded ACID, lock-free reads, append-friendly. The shape matches our workload:

  • Writes are append-only — one new entry per event.
  • Reads are walks of a per-session sub-tree, in insertion order.
  • We never need to update an event after it’s written.

sled is also dependency-light, which keeps cross-compilation simple. No C dependency, no bindgen.

Projections: last-write-wins CRDT semantics

The session’s current state — the rendered chat, the cumulative token count, the active tool set — is a projection. It’s derived from the event log on demand.

We project with CRDT semantics: every state key uses last-write-wins merge, with the event’s monotonically-increasing sequence as the version. That means two projections of the same log always converge to the same state, regardless of order of arrival. It also means future clustering (multiple gateway instances in front of one event store) is safe by construction.

let projection = SessionProjection::project(&events);

// Read derived state
let active_tools = projection.get("tools.active");
let token_count  = projection.get("tokens.total");

Time travel for free

Because the log is the source of truth, “what did the agent see at step 12” is just:

let snapshot = SessionProjection::project(&events[..12]);

That’s the entire feature.

This is gold for debugging weird tool behaviour: rewind to the moment before ToolCalled, inspect the projected state, run the tool yourself with the captured params, compare results.

Why this matters for security

There is one corollary of “every state change is an event” that’s load-bearing for the security model: you can audit a session without trusting any code path other than the event store. Auth decisions, tool executions, secret accesses — they’re all in the log, in order. If a plugin misbehaves, the log tells you what it tried to do before you decided to trust it.

We come back to this in the zero-trust runtime piece.

The migration story

If you’re coming from a mutable-state agent framework, you may be wondering: do I have to model my whole world as events from day one?

No. The event log lives at the boundary between the agent runtime and the rest of your system. Your tools, your channel adapters, your dashboard — they don’t need to know it exists. They just see a session API. The event store is what gives that API its replay, audit, and projection superpowers.