The event stream
One frame type, one journal, two pure reducers, and every surface a client of them.
One vocabulary
bingo_sdk::Event is the only output vocabulary in the system. The terminal
surface, --print, JSON-RPC and IM channels all consume it and derive their
views at render time. No surface defines a private mirror enum, and
scripts/check_discipline.sh refuses any surface crate that declares a type
whose name ends in Event (ADR-0002).
The frame
pub struct Frame {
pub seq: Seq,
pub ts: Timestamp,
pub session: SessionId,
/// The client intent this frame answers or results from, when there is one.
pub cause: Option<IntentId>,
pub event: Event,
}
seq is minted by the session actor under one lock and is gapless for durable
frames. Durable frames are appended to the store before they are published.
The events
| group | variants |
|---|---|
| session | SessionUpdated, SessionClosed |
| turn | TurnStarted, TurnRetrying, TurnUsage, TurnCompleted |
| items | ItemStarted, ItemDelta, ItemUpdated, ItemCompleted |
| queue | QueueChanged |
| questions | InteractionOpened, InteractionResolved, InteractionCancelled |
| intents | IntentAck |
| history | Compacted, Rewound |
| config | ConfigChanged, CatalogChanged |
| plugins | Notice, Extension, Signal |
| transport | Lagged |
Four of them are ephemeral: ItemDelta, Notice, Signal and Lagged
take a live seq but are never written to the journal. A replay is therefore a
subsequence of the live stream, and clients require monotonic sequences rather
than gapless ones.
The items
An Item is a durable unit of the transcript.
pub struct Item {
pub id: ItemId,
pub turn: Option<TurnId>,
pub round: u32,
pub status: ItemStatus,
pub started_at: Timestamp,
pub completed_at: Option<Timestamp>,
pub intent: Option<IntentId>,
pub body: ItemBody,
pub meta: Map<String, Value>,
}
ItemBody is what an item is: User, Assistant, Reasoning, ToolCall,
Action, Compaction, Rewind, Interruption, Notice, QuestionAnswer,
PermissionReceipt and a few more. A ToolCall carries its call_id, its
input, its ToolOutput when it has one, its progress tail while it runs, and
its duration.
Two reducers over one journal
SessionState::apply(&Frame)produces the client view. It is the same reducer the kernel runs for its own snapshot, so any client’s view equalsapply(snapshot, frames since snapshot.seq).ContextView::fold(frames)produces the provider messages. It is the most load-bearing function in the system and gets a golden test per journal version.
Neither ever rewrites history. Compaction and rewind are events —
Compacted { boundary, kept, summary }, Rewound { to_turn, dropped } — and
the journal header carries a version, so a format change is a migrator, never
an in-place edit.
SessionState holds the transcript in order, the live turn, the queue, the
open interactions, the context usage, the config view, and two maps of
plugin-owned state — extensions (durable, the latest Extension payload per
kind) and signals (ephemeral, gone after a resume).
Writes return nothing
fn submit(&self, intent: IntentId, input: Input);
fn interrupt(&self, intent: IntentId, scope: InterruptScope);
fn answer(&self, intent: IntentId, interaction: InteractionId, answer: Answer, activation: Activation);
All three take a client-minted IntentId, which is also the idempotency key,
and return (). The outcome arrives as Event::IntentAck { intent, outcome }.
A synchronous key handler can never wait for a receipt, because none exists.
An intent that waited is acknowledged twice — Queued when it joined the
queue, TurnStarted when its turn opened — so a client learns which turn is
its own without matching items.
Ids
SessionId, TurnId, ItemId and InteractionId are ULIDs minted once by
the actor and persisted; they are never re-minted on restart. IntentId is the
client’s to mint.
Backpressure is the kernel’s to announce
Each subscriber has a bounded channel. On overflow the kernel sends
Lagged { from, to } and the client re-reads with events_since(seq). The
kernel never blocks on a client.
The model stream never leaves the loop
ModelEvent mirrors the provider stream-part algebra — per-block ids,
start/delta/end for text, reasoning and tool input, a Finish carrying usage
and both a unified and a raw finish reason, and provider metadata keyed by
provider id. The accumulator folds it into Items inside the turn loop. Only
Items and Events are ever published, so no client parses a provider’s
dialect.
Sessions, sub-agents and rooms
A sub-agent is a session with a parent link; a room is a session without a
model. There is no second noun and no second reducer.
Opening a session with children: true makes the attachment carry every live
descendant’s frames from its head, descendants created later included, each
frame stamped with its own session. A lag anywhere in the tree is healed in
the kernel — re-subscribed from the last seq forwarded — so a tree stream
never carries a Lagged marker. On the wire an event notified under a tree
attachment also carries root, the session it was opened through, which is
what a client routes by (ADR-0010).
The child’s SessionSummary.parent.item names the tool call that spawned it.
There is no second copy of that link on the tool call itself: a client derives
it from the frames it already holds.
Plugin state travels as data
A plugin’s own resources — a roster, a room, a task list — travel as
Event::Extension { plugin, kind, payload }, journaled, where the latest
payload is the whole of that kind’s state (ADR-0011). Live state that should
cost the journal nothing travels as Event::Signal instead: never written,
folded into SessionState.signals as the latest payload per (plugin, kind),
removed by a Null payload, absent after a resume. A progress bar updated ten
times a second is free.
The kernel enumerates neither. What a surface does with them is UI as data.
Reading it yourself
bingo --print --output-format jsonwrites oneFrameper line on stdout.bingo serve --stdionotifies each frame verbatim as anevent— see schemas and protocols.
Both are the same frames the terminal surface folds.