Event model: the bottom-layer schema¶
Optional reading
You don't need any of this to use Romp. Describes the system as of 2026-07-21; the behaviour it documents moves, so treat anything here as a snapshot rather than a contract.
Architecture deep dive: the pinned schema for the bottom layer of romp — how a session's transcript becomes a structured, queryable model of turns and atoms.
The guiding principle: the data model is the Claude streaming protocol, made graph-aware. An atom is a streaming message, a turn is the streaming protocol's user-to-result cycle, and the on-disk transcript is just those messages plus the graph metadata needed to reconstruct them after rewinds and resume-forks. One model serves both substrates: on a stream you receive it, on a file you rebuild it. Fields are named after the streaming API wherever an equivalent exists, so a future stream substrate is a near passthrough.
The model at a glance¶
- ROMP UUID is the stable session identity, assigned once at session birth,
never changing across resume / clear / fork. Everything binds to it. It is the
one durable id; per-fork transcript ids (
fsid) are provenance only. - atom = one transcript line = one streaming message. Its payload is a
content[]array of blocks, so one atom can render as several chat rows. - turn = a contiguous group of atoms, opened by a trigger and ended at the
next turn boundary. A turn has no
kind; its character is its trigger. - trigger = the user atom that opened a turn, or null (autonomous /
continuation). Its provenance is the atom's
author, not a separate flavor. kindlives on the atom, and equals the streaming messagetype/subtype(assistant,user,system:compact_boundary,result), plus one synthetic kind,idle. (Transcripts have nosystem:initrecord — model/cwd/branch ride on every record instead.)- compaction is an atom (
system:compact_boundary), not a turn. It has no trigger and is a streaming message. - idle is an atom (
idle), not a turn. It has no trigger, is one contiguous span, and is synthesized on the file substrate from the state log. - Turn boundary =
end_turn/result(streaming parity). A turn opens at a user-prompt atom; mid-turn prompts (absorbed) and decisions stay inside the turn. Whether to sub-segment there is a higher-layer choice. - Authorship is the one real addition over the stream. A user atom carries an
author(human / sdk / system / peer), because the stream cannot tell a peer romp message from a human prompt. Author + position (opener vs mid-turn) + content together derive the finer distinctions. - Openers are
human,sdk, andpeer;systemis not an opener.systematoms are harness injections (e.g.<task-notification>when a background task/agent completes); they fold into the current work, never start a new ask. - Turn vs segment are two groupings over the atoms, both derived. A turn is
end_turn-bounded (the stream unit, whatnum_turnscounts) and may contain several inputs. A segment runs from one input to the next (or toend_turn) and is what the timeline draws as a bar. The bottom layer stays unopinionated; consumers pick the grain. - Postal is detected by the
<!-- romp-msg-id: <id> -->marker, with the peer ROMP UUID filled by joining the id totimeline/messages.jsonl(from_id= sender anchor sid). Never the"Stop hook feedback"prefix (any blocking Stop hook produces it). No new marker, no romp-postal-service change. - The event layer is not opinionated about which atom matters. No
workUuid, noreplyUuid. "Which line a click lands on" is a render-time function over a turn's atoms. - Clear drops pre-clear history. Within a file the
parentUuidgraph encodes it for free: clear breaks the link, so the leaf-to-root walk stops there. (Resume KEEPING history across files is NOT free — resume forks don't link viaparentUuidin practice; that's the deferred session→files concern below.) - Idle comes from the state log. Period clipping keys on the real idle
transition in
states/<sid>.jsonl, not a silence-duration heuristic. - Resolved: the Agent SDK backend is the per-turn headless backend, and the
default for new sessions — see
plans/sdk-backend.mdfor the design and the guide for the tmux/SDK choice. Still deferred: whether summaries operate at turn grain or segment grain (the timeline uses segments).
Hierarchy¶
Session ← keyed by ROMP UUID
└─ Turn ← end_turn-bounded; the stream unit (num_turns); may hold many inputs
└─ Atom ← one message / one transcript line (chat zoom)
└─ ContentBlock ← text | thinking | tool_use | tool_result (one chat widget each)
One parse produces the whole tree. The chat reads it at the Atom/ContentBlock level. No second parser.
Turn vs segment. A turn is end_turn-bounded: one continuous stretch where
the model held the floor. It is not "one ask" — mid-turn injection (absorb) and
decisions add inputs without an intervening end_turn, so a turn may hold
several. A segment runs from one input to the next (or to end_turn) and is
what the timeline draws as a bar. Segments are a turn split at its input atoms — a
pure derivation, not stored. Naming the coarser turn as well lets each consumer
pick its grain.
Schema¶
Pseudo-TypeScript. Field names mirror the streaming API / Agent SDK where an equivalent exists; file-only fields are grouped and labeled.
// ── Session ───────────────────────────────────────────────
// Identity lives here and never changes across forks.
interface Session {
rompUuid: string; // THE stable identity key (binds summaries, DAG, everything)
name: string;
dir: string;
color: string;
leafFsid: string; // newest transcript file; the walk's start pointer (updates on resume)
turns: Turn[]; // ordered oldest → newest
}
// ── Turn ──────────────────────────────────────────────────
// No kind. A turn IS its trigger plus the atoms until the next turn boundary.
interface Turn {
id: string; // `${rompUuid}:${t}:${hash}` — anchor-keyed, fork-stable
trigger: Trigger | null; // the user atom that opened it, or null (autonomous / continuation)
t: number; // start (trigger.t, else first atom.t)
end: number; // last atom's end (or next turn's t)
ended: boolean; // STREAM: from `result` / stop_reason. FILE: inferred from stop_reason.
atoms: Atom[]; // every atom in [t, nextTurn), in order: assistant, user,
// system:compact_boundary, idle — all of it
}
// The trigger references the opening user-prompt atom. Its provenance is that
// atom's `author` (below); opener-vs-mid-turn is position, derived from turn
// structure, not stored; "decision" is read from the atom's content.
interface Trigger {
uuid: string; // the user-prompt atom that opened the turn
} // STREAM: a trigger is simply a user message
// ── Atom = one message (one transcript line) ──────────────
interface Atom {
// Parity with the streaming API:
type: "assistant" | "user" | "system" | "result" | "idle"; // "idle" is ours
subtype?: "compact_boundary" | "status" | "task_notification"; // when type==="system"
uuid: string; // message id (same value in stream and transcript)
session_id: string; // = rompUuid
message?: ApiMessage; // assistant/user: the Anthropic message object (below)
compact_metadata?: { trigger: "auto" | "manual"; pre_tokens: number }; // system:compact_boundary
result?: { // type==="result"
subtype: "success" | "error_during_execution" | "error_max_turns" | string;
num_turns: number; stop_reason: string | null;
usage: Usage; total_cost_usd: number; duration_ms: number; is_error: boolean;
};
// Synthetic atom (FILE substrate; no stream equivalent):
end?: number; // type==="idle": the contiguous idle span is [t, end]
// FILE-only provenance (the graph the stream doesn't carry):
t: number; // timestamp (transcript has it; on a stream you stamp on arrival)
fsid?: string; // which transcript file this line physically lives in (click-to-open)
parentUuid?: string; // graph link (parentUuid ?? logicalParentUuid)
promptSource?: "typed" | "queued" | "sdk" | "system"; // raw signal; ABSENT on
// hook-injected (postal) and tool_result lines
// THE one genuine addition over the stream: who authored a user atom. Derived —
// promptSource typed/queued→human, sdk→sdk, system→system; the romp-postal-service
// marker→peer. A tool_result-only user atom has no author (it's harness output).
author?: "human" | "sdk" | "system" | { peer: string /* peer rompUuid */ };
}
// ── ApiMessage / ContentBlock — verbatim Anthropic shapes ─
interface ApiMessage {
role: "assistant" | "user";
content: ContentBlock[];
model?: string;
usage?: Usage;
stop_reason?: "end_turn" | "tool_use" | "stop_sequence" | "max_tokens" | null;
}
type ContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; thinking: string; signature?: string }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean };
interface Usage { input_tokens: number; output_tokens: number }
One atom, many blocks¶
A single transcript line is one message whose content is a list of blocks. An
assistant atom routinely carries a thinking block, a text block, and a
tool_use block at once, and the chat renders one widget per block, so the atom
paints as several rows. A tool call spans two atoms: the assistant atom with
the tool_use block, then the next user atom with the matching tool_result
block. This is exactly the Messages API shape.
Turn boundaries and the absorb/queue mechanism¶
The objective turn boundary is end_turn / result. Understanding why requires
knowing that a Claude Code turn is a loop of streaming requests, not one call.
A turn runs as: the model emits text and a tool_use and stops with
stop_reason: "tool_use"; the client runs the tool and sends a follow-up
request carrying the tool_result; the model continues; repeat until
stop_reason: "end_turn". So a turn with N tool calls is N+1 streaming requests,
and the model generates only in bursts, one request per cycle, with gaps between
while the client runs tools and composes the next request.
Those gaps are the only injection points. When the user types mid-turn, the client holds the text and, at the next request boundary, either:
- splices it into the next request alongside the tool_results, so the model
continues in the same turn. This is absorb (transcript
queue-operationremove). - sends it as a fresh top-level request because the turn already hit
end_turn. This is queue / new turn (transcriptqueue-operationdequeue, then a normalpromptSource: "queued"user line).
The Messages API has no queue. It is stateless and sees only a messages array.
Absorb versus queue is entirely the client's choice of where the user text lands
in that array and when the request is sent. In the streaming output the
distinction is pure position: a user message before the turn's result was
absorbed, one after it opened a new turn. The transcript's queue-operation
records are the client's bookkeeping of the same fact, a file-side artifact, not
API truth.
Consequences for the model:
- A user-prompt atom (any author) or a postal message arriving after
end_turnopens a turn. - A user-prompt atom or a decision arriving mid-turn (before
end_turn) is an atom inside the current turn, not an opener. - A higher layer may sub-segment a turn at a mid-turn prompt or a decision for
summary or timeline granularity. The bottom layer does not; it records the atom
(with its
author) so the choice stays available above.
Authorship, position, and what the model adds over the stream¶
Authorship is the one field romp adds to the user atom; everything else the stream already encodes as position or content. The finer distinctions (typed/queued/absorbed/decision/postal) sort into three buckets, and only the third is real new information:
- Position labels (typed, queued, absorbed) add nothing. They only describe
where a user message sits relative to
end_turn: idle-opener, busy-opener, mid-turn. The stream already encodes that as position. typed-vs-queued (idle vs busy when typed) is the lowest-value distinction of all; nothing branches on it. Derive position; do not store these. - decision is a content tag, not a kind. It is "a
tool_resultwhosetool_usewasAskUserQuestionorExitPlanMode." The tool name is in the stream, so this is read from the atom's content, not stored. (Permission allow/deny is excluded: no post-choice line to anchor.) - postal is the one genuine addition. The stream cannot tell a peer romp
message from a human prompt: both arrive as
usermessages. Only romp's marker distinguishes them.
So authorship collapses to author on the user atom plus derivation:
| author | derived from | opener? |
|---|---|---|
human |
promptSource: "typed" \| "queued" |
yes |
sdk |
promptSource: "sdk" |
yes |
{peer} |
the romp-msg-id marker + messages.jsonl from_id join |
yes |
system |
promptSource: "system" |
no (folds in) |
| (none) | a tool_result-only user atom |
no |
promptSource values on disk:
| value | meaning |
|---|---|
"sdk" |
injected programmatically (Agent SDK / headless / claude -p / scheduled / workflow-spawned agents); lands in ORDINARY transcript files |
"typed" |
human keystroke while idle |
"queued" |
human keystroke while busy, queued |
"system" |
harness control injection; observed example <task-notification> (a background task/agent completed) |
| (absent) | hook-injected (postal) and tool_result lines carry no promptSource |
Turn-opener rule. A turn opener is a genuine new prompt (author ∈ {human,
sdk}) or a postal message (author: {peer}). system atoms
(<task-notification> and the like) are NOT openers; they fold into the current
work, matching _is_harness_injected handling. Keying on author covers all
origins uniformly, including sdk prompts (so SDK/headless sessions get their
triggers).
Postal detection. A user atom is postal when it carries the
<!-- romp-msg-id: <id> --> marker. Fill the peer ROMP UUID by joining that id to
timeline/messages.jsonl: the sent row's from_id is the sender's anchor sid,
to_id the recipient. peer = null only when the id isn't in the log (legacy/rare).
The postal log is the authoritative sender-identity source (the same contract
postal-spec.ts relies on), so identity needs no inline from= marker. Never key on
the "Stop hook feedback" prefix: it is Claude Code's generic wrapper around any
blocking Stop hook, not a postal signal.
Queue operations on disk: enqueue, dequeue, remove, and popAll (clear
the whole queue at once). They are used only to derive position (absorbed = a
mid-turn remove).
The file adapter: graph recovery, quarantined¶
The Session/Turn/Atom types are substrate-neutral. Everything specific to
reconstructing them from the append-only graph on disk lives in the file adapter,
and only there.
- Get the file set. The caller provides the session's transcript file(s).
Resume forks do NOT link child→parent via
parentUuidin real data, so the parser CANNOT discover a session's files by walkingparentUuidacross files. "Which files belong to one session" is a DEFERRED higher-layer concern (see below); the parser consumes the files it is given. - Resolve the active path. Walk leaf-to-root by
parentUuid(within the given files); drop dead / rewound branches. Clear is handled for free: it leaves no parent link, so the walk stops at it and pre-clear atoms are never reached. COMPACTION STITCH: acompact_boundarycarrieslogicalParentUuidto the pre-compaction leaf, but Claude Code sometimes points it at a uuid listed incompactMetadata.allUuidsyet never written as a line; following it blindly orphans ALL pre-compaction history. Fall back tocompactMetadata.preservedSegment.tailUuid(the in-file pre-compaction leaf). - Order atoms by timestamp; tag each with its
fsid. - Set
authorand detect triggers. A prompt atom whoseauthor ∈ {human, sdk}(frompromptSource), or a postal atom (author: {peer}, from the romp-msg-id marker + themessages.jsonlfrom_idjoin), that arrives afterend_turnis a trigger.systematoms (<task-notification>etc.) and mid-turn prompts/decisions are kept inline, not treated as openers. (Openers are not restricted to typed/queued, which would drop every SDK/headless turn.) - Infer
ended. From the turn's last assistantstop_reason. (The stream states this viaresult; the transcript usually omits a result line.) - Synthesize idle atoms. From
states/<sid>.jsonlidle transitions, insert{type: "idle", t, end}into the turn's atom list. Lets the timeline color the gap as not-working.
On a future stream substrate, steps 1, 2, 5, and 6 disappear (the stream is
already linear and marks turn-end), and steps 3 and 4 reduce to reading messages
in arrival order. Same Session/Turn/Atom out either way.
Session → files is a deferred higher-layer concern¶
In practice a resume fork's first line does not link to its parent via
parentUuid, so the parser cannot resolve "which files are one session" from the
transcript graph. That inference (same customTitle + shared-node intersection) is
fragile, and node-intersection does not hold here either.
So this is OWNED ABOVE the parser and is DEFERRED: caption-only works per-file and
does not need it; it matters only for stitching a resumed session's history and
for the goals layer. The lean approach: track it EXPLICITLY, a rompUuid → its fork
files registry updated on resume (the headless backend's lastSid pattern,
extended to the interactive launcher), rather than re-inferring. The parser stays
correct — it consumes a caller-provided file set.
Identity vs the walk vs click-landing¶
Three separate concerns, kept separate:
- Identity binds to
rompUuid(stable). Nothing downstream sees an unstable id. This removes the need to reassemble identity at all. - The walk starts at
leafFsid, a pointer that updates on each resume. It is an index, not an identity, so its changing is harmless. - Click-to-open uses the per-atom
fsidto know which physical file to open. It may vary per atom; that is fine, it is provenance.
What is different from the streaming API¶
The overlap is near total: atom type/subtype, message.content blocks,
tool_use/tool_result, usage, stop_reason, num_turns, compact_metadata,
and session_id are all verbatim. The genuine deltas are five, four of which are
file-substrate recovery:
rompUuididentity. A stable session key above the stream's per-runsession_id, because resume mints a newsession_idand the file fragments identity. A romp-level addition.- The
idleatom. The one atom kind with no stream counterpart. The stream marks turn-end explicitly and never needs to represent "nothing happened"; the file substrate does, to clip revived turns and to color the gap. resultis inferred, not received. The stream emits aresultatom per turn; the transcript usually does not, so on the file substrateendedis synthesized fromstop_reason. Same field, different origin.authoron user atoms. The one field that carries information the stream lacks: the stream cannot tell a peer romp message from a human prompt (both areusermessages), soauthor(esp.{peer}) is a genuine addition. The rest of the flavor distinctions (typed/queued/absorbed/decision) are derivable from position and content and are not stored.- Graph fields (
parentUuid,fsid, active-path resolution). The stream is already linear and resolved; these exist only to rebuild that linearity from the append-only graph on disk.
Everything else is the streaming API. The model is not a parallel invention; it is the streaming message protocol with a stable identity on top and a thin file adapter underneath that reconstructs what a stream would have streamed.