Timeline: history pages, live overlay, settlement
How one ordered conversation is assembled from saved history pages, a revisioned live overlay, and pending sends, and why identity rather than text or time decides what exists.
Checked against the source on 5 October 2026
On this page
The timeline joins three inputs into one list
- Typed keytype + "\0" + id
A history copy and a live copy of the same row carry the same key. A client keeps one row per key and never matches rows by their text.
- History over liveorigin: canonical
When a history copy and a live copy share a key, the client shows the history copy. The desktop drops the live copy from the overlay once that row settles.
- Pending echouser-message::<operationId>
The desktop draws a message you send at once, under the key its history row will have, and hides that echo once any row carries its operation ID.
| Input | Authority | When to reconcile |
|---|---|---|
| Canonical history window | The desktop history route and its boundary token. | On initial load, refresh, and older-page splice. |
| Live overlay | Desktop settlement | On valid current-target snapshots or live updates. |
| Outbox and submitted echo | Desktop custody and acceptance. The local UI can show pending intent. | When the outbox changes or canonical evidence confirms an accepted operation. |
| Viewport and measurements | The selected client view. | When switching target, loading older content, or following new activity. |
Typed identity and canonical order
Each block has a typed identity key, the block type joined to the block ID with a NUL byte. The five block types are user, system, activity, agent-reply, and thinking. A block ID is unique only inside its type. The first figure shows how clients merge rows by that key. Timestamps are order metadata and never deduplication identity.
After every material merge a client sorts again: by timestamp, then by the sourceOrder vector from the desktop, then by the identity key. sourceOrder is a short list of numbers that compares element by element. Its first number ranks the source of the row, so a history row sorts before a live row with the same timestamp. A row without sourceOrder sorts after rows with it at the same timestamp.
| Row source | sourceOrder | Where the numbers come from |
|---|---|---|
| History, paginated | [0, 1, ordinal, entry | The ordinal of the saved item and the index of the row inside that item. Codex pages and Claude pages both use this form. |
| History, legacy Codex read | [0, 0, ordinal, entry | The position of the item in a thread/ |
| Live row | [2, sequence] | A counter in the desktop process. The row gets its number once, when it first enters the run. |
| Status row of an active run | [2, started | The start time of the run in milliseconds, so each rebuild gives the same position. |
| Synthetic row | [3, sequence] | A row that the desktop made without a transcript item or a live event. |
Both agents obey one rule for IDs: a live row must carry the ID that its saved row will have. Settlement and the client merge compare keys only. An adapter that gave a live row a different ID would show that row twice.
Desktop reads owned paginated Codex history through thread/items/list, newest first, at most 100 items per sidecar page. It preserves createdAtMs and updatedAtOrdinal, an unsupported public item appears as a diagnostic row that still advances the cursor, and an entry with neither or both of item and unsupportedItem fails the whole page. The boundaryToken a client receives is the SHA-256 of the rollout path, or of owned-thread-store plus the owner ID, so it identifies the history source rather than a page. oldestCursor is the prefix thread-items-v5: followed by base64url JSON that carries the thread ID, the raw sidecar cursor, a generation, that source token, the prior leading block key, and the window revision. A cursor for another thread is rejected. Desktop does not parse Codex JSONL and does not query the child’s private databases.
A Claude session uses the same typed keys, the same order, and the same row types. Only the history source differs. The desktop reads Claude’s saved transcript through the Claude Agent SDK and projects each message into timeline rows. The boundaryToken is the Claude session ID, or the subagent ID for a child thread. oldestCursor is JSON with four fields: session holds that ID, before holds the UUID of the oldest loaded message, anchor holds the leading block key, and revision holds the window revision. The desktop accepts at most 4,096 characters and rejects a cursor whose session differs from the open conversation or subagent. When the transcript no longer holds the before message, the page comes back empty with reloadRequired. A transcript that does not exist loads as an empty conversation. Saved Claude history starts after the last compaction, because the SDK returns only that part.
Live overlay revisions and the settlement epoch
A timeline contains canonical history plus live blocks that are not yet fully represented there. The desktop saves one overlay snapshot per target in the table chat_live_timeline_overlays, with the key app_session_id plus timeline_target_id. The target is the session for the main conversation, and the thread ID or subagent ID for a child. The snapshot format is version 2. It stores formatVersion, settlementEpoch, revision, and blocks containing wire data plus settlement metadata. The client receives only settlementEpoch, revision, and wire blocks. Internal settlement identity and provenance stay on desktop.
- Settlement epochliveOverlay.settlementEpoch
Rises only when a live row moves into history. A client takes a higher epoch only from a latest-window snapshot, because only that snapshot also carries the history rows.
- RevisionliveOverlay.revision
One global counter on the desktop. Within an epoch a higher revision replaces the whole overlay, a lower one is ignored, and the same revision with different rows makes the client reload.
- Latest windowrun.chatLoad · run.chatReconcileSessions
The desktop reads the newest history page, removes overlay rows whose keys that page holds, and returns both in one response. The desktop app calls the same builder. run.chatReconcileSessions does this for at most 16 sessions in one call.
liveOverlay is { settlementEpoch, revision, blocks } and contains the full unresolved live projection for one target. Within one settlement epoch an empty higher revision clears it. Revisions come from one durable global sequence, the single row of chat_live_timeline_overlay_revisions, so a deleted target cannot reuse an old revision. An empty target snapshot is persisted so the target keeps its epoch and revision. Overlay writes compare an expected revision inside a transaction, the counter is capped at JavaScript’s exact integer limit, 9,007,199,254,740,991, and a stale writer retries against current state rather than overwriting it.
The overlay holds two classes of rows. An ordinary live row has a saved counterpart in the agent’s history: a user message, reply text, a command, a tool call. The other rows are ephemeral. No history row ever replaces them.
| Row class | What it is | How it leaves the overlay |
|---|---|---|
| Ordinary live row | A row whose key also appears in saved history. | A history read proves its key. The figure below shows the three reads. |
| Status row | The thinking block with the ID thinking-<run | A newer status row for the same target replaces it. A latest window drops it when another run is the current run. The Codex cleanup after a run removes it. |
| Context-compacting marker | The marker that shows while the agent compacts its context. | A latest window drops it when its run is not the current run. The Codex cleanup after a run removes it. |
| Parent progress mirror | A copy of a child thread’s progress in the parent conversation. | A mirror write from another run replaces it. A latest window drops it when another run is the current run. The Codex cleanup after a run removes it. |
| Approval card | An activity row with an approval field. | It stays with its decision. The Codex cleanup after a run removes it with the other ephemeral rows of that run. A Claude run has no such cleanup, so its card stays in the overlay. |
The settlement epoch is a target safety fence. The desktop advances it in the same write that removes at least one ordinary live row because history now holds that row. Ephemeral rows never advance it. A publisher can also retract a row that it no longer shows, because no history row would ever settle such a row.
- Latest windowrun.chatLoad · run.chatReconcileSessions
Shared by both agents. The desktop removes every overlay row whose key the loaded page holds, and raises the epoch in the same write.
- Exact scanthread/items/list · 3 scans · 100 ms, 250 ms
Codex only. After the terminal event the desktop scans history to the end for the exact keys of that run. A missed key gets a second scan after 100 ms and a third after 250 ms.
- Restart repairjournal row: session, run
Codex only. The journal row names the session and the run. It stays until a scan has settled every row of that run, and the repair starts when a target of the session opens.
Three reads can settle an ordinary row. The first is shared: when a latest window loads, the desktop removes every overlay row whose key that page holds. Both agents call the same function for it, and it is the only read that a Claude session has. The other two are Codex paths. At the end of a Codex run the desktop writes the session and the run into chat_pending_terminal_overlay_cleanups. Then it scans thread/items/list to the end for the exact keys of that run in every target of the session. A scan that misses a key runs again after 100 milliseconds and after 250 milliseconds, three scans in total. The whole cleanup has 2 minutes. If it fails or the app quits, the journal row stays, and the repair runs the same exact scan when a Codex target of that session opens.
Phones take several sessions in one call. run.chatReconcileSessions accepts at most 16 sessions and returns for each the latest window, the overlay, the outbox, and the active run. It allows 256 outbox entries per session, and it checks the 8 MiB limit while the response grows. Mobile applies a window and its overlay in one reducer operation.
Live delivery: one function sends to three sinks
Every live change leaves the desktop runtime through one function, emit_chat_stream_event. It feeds three sinks: the WebView event chat:stream, the WebView event chat:progress, and the relay event chat:timeline-updated. The event name inside the payload decides which sinks get it.
- Block snapshotevent: blocks · liveOverlay
The whole overlay of one target after a write. It goes to the relay and to chat:stream, and never to chat:progress.
- Coalescing50 ms · 128 targets
The first snapshot for a target starts the timer, and a newer snapshot replaces the waiting one. A run event sends the waiting snapshots first.
- SubscriptionsubscriptionId · streamRevision
A window gets snapshots only for the target that it subscribed to. Run events go to every window.
- Relay eventchat:timeline-updated
Sent only for a session in which a phone holds interest. The desktop does not coalesce it.
- Repeatevery 5 s
For each target of an active run with no new write since the last tick, the runtime reads the saved overlay and sends it to the relay again.
| Payload event | chat:stream (desktop windows) | chat:progress (desktop) | Relay event (phones) |
|---|---|---|---|
| blocks | Only windows with a live subscription for that target, coalesced for 50 milliseconds. | No. | Yes, at once. |
| started, completed, failed, aborted | Every window. | Yes, as event, app | Yes. A failed event also carries agent |
| runtime | Every window. | No. | Yes. |
| agent-threads | Every window. | No. | Yes. |
An agent-threads payload has only event, appSessionId, timestamp, and a threadId of null. It tells every client that the agents list of the session changed: a child thread or a subagent started, or one of them has a new status. The client then loads the list again. No timeline row carries this fact. No row stands for a subagent or a workflow, and no row opens a thread. A session sends its first signal at once, and then at most one more signal at the end of a window of 1 second.
A blocks payload is a snapshot: the whole overlay of one target after a write, as liveOverlay. The producer saves the overlay first and sends the saved snapshot, so a client that missed an event loses nothing that the next snapshot does not carry.
A desktop window subscribes to one target at a time. The subscribe call returns the saved overlay, so the window starts from a complete state. After that the runtime sends the window only snapshots of its target and tags each with the subscriptionId and a streamRevision, a counter shared by all windows. The first snapshot for a target starts a 50 millisecond timer. A newer snapshot for the same target replaces the waiting one, and the timer sends one event. At most 128 targets wait at once. One more target sends all of them at once. A run event sends the waiting snapshots first, so a window never sees completed before the last rows.
The relay sink forwards a payload only for a session in which a phone holds interest. A blocks payload must also have a liveOverlay with exactly settlementEpoch, revision, and blocks, a revision above 0, and both numbers inside the exact integer range. The desktop does not coalesce this sink.
A phone can lose an event on the way. For that case a repair timer runs every 5 seconds while a run is active. For each target that had no new write since the last tick, it reads the saved overlay and sends it to the relay again. The repeat does not go to desktop windows. The shared live emitters arm this timer. The Codex adapter sends every row through them. The Claude adapter saves and sends its rows directly and uses a shared emitter only for an approval card, so a Claude run has the timer only after its first card.
Approvals use one shared model
An approval request from either agent becomes the same ChatApprovalRequest. It names the request ID, the thread, the turn, the item, and the decisions that this request allows. The desktop refuses a decision that is not in that list.
| Part | Values |
|---|---|
| Kind | command |
| Decision | accept, accept |
| Result | accepted, accepted |
| Card status | pending, then the result. resolved |
The card is a live activity row of kind event. Its ID starts with approval: and the request ID, followed by the thread ID, the turn ID, and the item ID, each with its length in front. The same ID carries the pending card and the decided card, so the decision replaces the card in place. The card is an ephemeral overlay row.
Clients answer with run.chatResolveApproval, which carries requestId, threadId, turnId, itemId, and decision. It carries no session ID. The dispatch layer asks the Claude adapter first, which answers when it holds an open permission with that request ID. Otherwise the Codex app-server that owns the request settles it.
The desktop writes the words of each row
The desktop owns the words and the facts of an activity row, and each client owns the layout. A client shows the words as they are and makes no words of its own. Desktop tests check these rules on the rows of the shared samples.
| Field | Rule |
|---|---|
| title | The headline of the row, for example “Message to Android landing”. |
| subtitle | The context line, for example the summary of a message. It never repeats the title. A command row has none. |
| display. | A fact, for example “Exit 1”, “Draft 2”, or “Sent”. It is never a status word, and it never repeats the title or the context line. |
| status | The state of the row: in-progress, completed, failed, or declined. The context line and the signal never say a status word. |
| detail | The text that opens on a tap, with its format: markdown, plaintext, or shell. A plaintext detail has a language |
Each client shows a plaintext detail without a languageHint and without a sourcePath as plain wrapped text. It guesses no language, colors no keywords, and shows no line numbers.
Message rows carry one typed message fact
A row that is a message between two sessions or between two agents carries one typed fact, message. An activity row has it as activity.message. A user row that another session sent has it as message. The desktop makes every word of the fact. The desktop window and the phones show these words and make no words of their own.
| Field | Content |
|---|---|
| direction | sent or received. |
| kind | message, or task on a received row that gives an agent its task. |
| from, to | The words for the two ends. They are never blank and never an ID. The end that shows the row is “This session”, or “This agent” in an agent thread. |
| peer | The kind of the other end, session or agent, and its agent, codex or claude. The agent is absent only when the call named a session that does not exist. |
| delivery | Only on a sent row whose call ended: sent, needs-approval, or not-sent. |
| failure | Only when delivery is not-sent: the first line of the reason, at most 220 characters. |
| link | The other end of the message: app |
The words for the other end:
- Another HB Code session: its title, “Untitled session” for a blank title, or “Unknown session” when the call named no session.
- The lead agent of a Claude session: “Claude”.
- A Claude subagent: its title in the agents list, else “Subagent”.
- A Claude session outside HB Code: its name, else “Another Claude session”.
- Codex: “Codex” for the root thread, and the words of the agent path for an agent thread, for example “Android landing”.
The desktop also makes the title of the row: “Message to <to>” on a sent row, and “Message from <from>” or “Task from <from>” on a received row. A user row has a title only together with message. Each client uses the title as the accessibility label and as the title of the message detail. display.signal is Sent, Needs approval, or Not sent, from delivery. A row has no signal while its call runs. A message row has no detail on the wire. Each client makes the detail from the title, the message fact, and the text of the row.
These rows are message rows: the result of a send_session_message call, the user row that another session sent, a Claude SendMessage call, the messages of the lead agent in a Claude subagent thread, a message from another Claude session, and a message that a Codex thread got from another agent. A send_session_message call that still runs stays the plain call row “Send message to session”.
A row links to its other end when a timeline of HB Code holds that end. A sent session message links to the user row in the recipient session, but only while its delivery is sent. The received user row links to the call row of the sender, in the main timeline or in the agent thread that sent the message. A Claude SendMessage to a subagent of this session links to the subagent thread at the time of the call. A lead message in a Claude subagent thread links to the main timeline at the time of the message. Codex rows and messages from another Claude session have no link. The desktop never compares text to find the other end.
The delivery of a sent session message follows the queue of the recipient. The table chat_session_messages keeps it: sent, needs-approval while the message waits for you, or declined after you removed the message from the queue. A declined message shows as not-sent with the reason “The user removed the message from the queue.” Each change of the queue publishes the row of the sender again.
How an older page joins the list
- SeamboundaryBlockKeys
The key of the oldest row the window held when the cursor was issued. The desktop reads it back out of the cursor and returns it after the older rows.
- Window revisionwindowRevision
One higher than the revision inside the cursor. A client applies a page only if this is higher than the revision it holds, so the same page never lands twice.
- Cursorthread-items-v5:<base64url>
JSON with the thread ID, the sidecar cursor, a generation, the source token, the seam key, and the revision. Clients send it back as beforeCursor, with their boundaryToken as expectedBoundaryToken.
Older pages come from run.chatLoadPageBefore and run.chatLoadThreadPageBefore with pageSize, beforeCursor, and expectedBoundaryToken. In a Codex session, a cursor whose source token differs from the current source fails with Timeline source changed, and one request fetches up to four sidecar pages until one yields a visible block. A response over 8 MiB fails with errorCode TIMELINE_SNAPSHOT_TOO_LARGE. Phones wait 115 seconds for run.chatLoad and run.chatLoadThread. The desktop gives those two methods a 95-second dispatch cap around a 90-second handler deadline, and every other method, older pages included, 45 seconds. Its RPC decoder accepts a pageSize up to 640 before the runtime enforces 100.
| Current manifest policy | Value |
|---|---|
| Initial timeline page | 12 entries. |
| Older page / maximum page | 100 / 100 entries. |
| Anchored window | 40 entries, for every client. |
| Initial timeline timeout | 115 seconds. |
| Paginated timeline timeout | 60 seconds. |
| Outbox command acknowledgement timeout | 20 seconds. |
| Initial timeline retry | At most 3 retries after the first request, only for the same current timeline target. Base delay 0.75 seconds, maximum 3 seconds. |
An anchored load opens the other end at its row
A client follows a link in two steps. It opens the session and the thread of the link with its normal open path. Then it loads that timeline with an anchor. run.chatLoad, run.chatLoadThread, chat_load_command, and chat_load_thread_command take one typed anchor.
| Anchor | Source |
|---|---|
| {kind: "link", token} | The message. |
| {kind: "newer", token} | The newer |
| {kind: "row", row: {type, id}} | The type and the id of a block of the timeline that loads, as an answer gave them. |
A token is opaque: the prefix ta1. and base64url JSON that the desktop wrote. A client never makes or reads a token. A blank token or a blank row key fails as invalid params on the relay and as a validation error in the desktop window. A token of the other kind, or a text that the desktop did not write, fails with “The timeline anchor is invalid; reload the timeline”.
The answer is the normal load answer with three more fields, which are always present. anchorRow is the block to center and mark. anchorNotice is a sentence of the desktop when the load opened no row, for example “This message is not in the saved history.” or “This row is not in the saved history.” A link load or a row load has exactly one of the two. newerCursor is set when the window is a page in the past. Such a page has no active run, and a client shows no live overlay rows below it. A load with the newer anchor gives the rows that follow the page, with no gap and no overlap. The client repeats this load until an answer has no newerCursor.
The desktop opens every anchored window with 40 rows, the value of mobileChatPolicy.timeline.anchoredPageSize, whatever pageSize the request names. A place in the newest 40 rows gives the normal newest window. For Claude, one host read, historyAround, finds the page that holds the place, and the place is in the middle of that page. For Codex, the desktop reads pages back from the newest page until a page holds the place. A row that is only in the live overlay has no saved place, so a link to it gives a notice.
No client has a Back control or a Forward control. The way back is the link on the row at the other end. In each client the name of the other end in the label line of the row is the link. A tap on another part of the row opens the message detail. When a gap recovery runs while a page in the past shows, the client loads that page again with a row anchor at the row that the reader reads. The reading position stays.
Desktop uses a virtualized React timeline
useTimelineVirtualizer adapts the vendored @timeline-virtualizer to stable row keys, row height estimates, measured elements, and inner reading anchors. It anchors short content to the end, follows appends only when autoScrollToLatest is enabled, and can restore a previous reading position and measurement cache. Direct DOM positioning is enabled for scrolling.
WorkspaceTimelineLayers distinguishes the parent conversation from the selected child thread. It assigns viewport ownership to the active target and uses retained state for the parent. Ownership includes a target, viewport ID, activation epoch, mode, and active flag. A selected thread change therefore affects view ownership as well as the row list.
Command rows carry descriptors, not output
A command row carries the command text and either an artifact descriptor or the output the agent retained. Rendering or reconciling a timeline never reads output bytes. An open viewer fetches them separately.
Useful regression cases
- Switch projects while a history request is in flight. The old response must not replace the new target.
- Move live content into canonical history and return an empty overlay with a newer settlement epoch. Old live rows must not reappear.
- Send three snapshots for one target inside 50 milliseconds. A subscribed window must get one event with the newest overlay, and a completed event must arrive after it.
- End a Claude run and load no window. The live rows must stay in the overlay and leave with the next latest window.
- Load older messages while the assistant streams. Preserve the reading anchor and avoid duplicate rows.
- Return an unavailable or malformed history response. Preserve readable history and expose recovery rather than showing a false empty conversation.
- Follow a link to a row that is older than the newest window, then follow the link on that row back. Each load must open the exact row, and a page in the past must show no live rows below it.