Documentation
Under the hood

How the client syncs

Every surface paints from a local store, an append only log for each scope delivers only what changed, and one window syncs while the others copy it.

The codecast web and desktop client shows a busy workspace: hundreds of agent sessions that send a heartbeat about once a second, plus tasks, docs and plans that many people and agents edit at once. The backend is Convex, a hosted database whose clients subscribe to queries. Convex has one way to keep a subscription fresh: it runs the query again and pushes the complete result. That is pleasant to program against, and it becomes expensive when the result is a long list and one field of one row changes every second.

Three ideas shipped in August and September 2026 to fix that cost. First, every surface paints from a local store, and a live query only feeds that store. Second, an append only log on the server tells each client exactly what changed, in order, so long lists do not travel again after every write. Third, when a person has several windows open, one window does the syncing and the others copy it.

This article explains each idea, with the real identifiers from the code, for engineers who build similar apps.

Rule one: every surface paints from the local store

A component never renders the result of a live query. A hook subscribes to the query and hands each push to one store function, syncTable. The component reads the store. The store is persisted to IndexedDB, the database built into the browser, so a populated cache is the normal first paint. A skeleton is honest only when the cache is cold (!ready && rows.length === 0).

// Feeder: mounted once, renders nothing.
useSyncCollection("agentTasks", api.agentTasks.webList, args);

// Reader: subscribes to a signature of the fields it draws.
const rows = useCollectionRows("agentTasks", { where, sig, sort });

One entry in store/clientSyncRegistry.ts registers a synced collection. The entry names how the rows persist, when they hydrate at boot, whether a payload is a delta or a complete set, the disk indexes, and feeds, the list of queries that feed the key. The IndexedDB schema, the sync defaults and the typed store slot all derive from that entry.

Registering a feed is a promise that a test enforces. registeredFeeds.guard.test.ts reads the source of every file under app/ and components/ and fails any file that subscribes to a registered feed query directly. The fix is always to read the store, never to widen the allowlist. The registry and this guard shipped on 2026-08-17 (f122d00e5).

Writes are drafts, and locks hold them

A user write never waits for the server. An action() edits a draft of the store, the screen shows the result in the same tick, and the patches ride a dispatch call to the real Convex mutation. The write is also recorded in an outbox table in IndexedDB, so a reload or a lost connection does not lose it.

The risk is a stale push. A query result computed before the mutation landed can arrive after the local write and put the old value back. The store prevents this with a pending lock for each written field. The middleware derives the locks from the draft's patch list, so no action writes them by hand:

pending["tasks:<id>:status"] = { type: "field", value: "done", ts }
pending["tasks:<id>"]        = { type: "exclude", ts }   // a local delete

While a lock exists, syncTable keeps the local value for that field and accepts every other field from the server. The lock retires when the server sends back the same value. A second rule, described below, retires it when the sync log passes the position of the write.

Derived fields are the known trap. A server row can carry assignee_info, an object joined from assignee. If the store locked that object, the lock would never retire, because the comparison is === and the server builds a new object each time. So the client derives such fields at render from the raw field and the team list it already holds (lib/liveEntities).

Keep the fast fields off the big rows

Heartbeats were the first cost. The inbox list query, listInboxSessions, returns a fully enriched row for each session. Any field change on any row changes the result, and Convex then pushes the whole list again.

The fix is a second, small query. sessionsLiveness returns a map from conversation id to only the fields that heartbeats move, and the client merges that map onto the rows in the store. The base query is called with include_liveness: false, which sets those fields to null on the server, so a heartbeat no longer changes the large result.

On 2026-08-28 (836605f0c) two more fields moved to the overlay: message_count and updated_at. Both changed on every streamed token, so one streaming session caused a new push of a list that measured about 1.7 MB for each token. A client opts in with fast_fields_in_overlay: true. The argument is optional, so older clients keep working.

The same idea applies inside the browser. The store uses immutable drafts, so a change to one field of one row produces a new object for the row and for the whole collection. A sidebar subscribed to s => s.sessions rendered again on every heartbeat, and it was measured at about 70 percent of the main thread while idle. The fix is a wake signature (store/wakeSig.ts): a short string built from only the fields the component branches on.

export const sessionsWakeSig = makeCollectionSig<InboxSession>(sessionStructuralSig);

makeCollectionSig memoizes by collection reference, so unrelated store writes cost nothing. rowSigExcluding(row, deny) does the same for one row with a list of fields to ignore. A signature tracks field changes only. Anything driven by time, such as a relative clock, pairs the signature with a coarse ticker (useCoarseNow).

The sync log

The overlay handles heartbeats. Real edits had the same problem on every list: one task edit sent the whole task window to every open client. The sync log replaces that. The server half shipped on 2026-08-21 (3bea3d68a) and the client applier the same day (bb8668612).

Scopes and positions

A scope is one reader group: user:<userId> or team:<teamId>. Two tables hold the log.

sync_heads:   { scope_key, position, floor }
sync_actions: { scope_key, position, entity_type, entity_id, op, ts,
                patch, unset, full, partial, omitted,
                access_owner, access_key, access_grants }

Every tracked write to conversations, tasks, docs, plans or projects reads the head row of the scope, takes position = head + 1, and writes the action in the same Convex mutation transaction as the data. Convex mutations are serializable. So positions in a scope rise strictly in commit order, and a reader that has seen head H has seen every action at or below H. That is the whole ordering proof. ts exists for retention and is never an ordering key.

Two rules keep the table small. An entity has at most one active row in a scope: a new write moves that row to the new head. And a patch that touches only fields in CHURN_ONLY_FIELDS (last_heartbeat, message_count, updated_at and similar) emits no action. Without that rule every streaming session of a user would contend on one head row.

Reading, cursors and acks

The client holds one live subscription, getHeads, which returns { position, floor } for each scope the caller holds. The payload is a few integers, and it changes only on a tracked write. When a head moves, the applier waits 1500 ms to collect a burst, then reads getRange { scope_key, from, limit, cargo } as one shot queries, up to 500 actions or 1 MB for each page. It applies the page through the store's sync actions and then advances the cursor, which is stored in syncMeta under synclog:v1:<scope_key>.

Positions also acknowledge writes. dispatch takes an optional ack_positions flag and then returns { __syncAckV1, result } with the positions its transaction created. The store stamps those positions on the pending locks that still protect the dispatched value. When the scope cursor reaches a stamped position, the lock retires. The value comparison stays as a permanent second rule, because writes that the server defers to a scheduled function produce no ack.

Cargo and the access stamp

Since 2026-09-01 (4c96cd3ff) each action carries the change as cargo: a merge patch of top level fields, a list of removed fields in unset, and full: true when the patch is the whole document. Applying a patch is field assignment, so applying it twice is harmless. That matters because a moved row merges the cargo of every write since it last moved (mergeCargo), and a reader can see the same row twice. Cargo is capped at 16 KB. A larger one becomes partial, the client fetches the row by id, and the next write rebuilds a full cargo from the document.

Carrying row contents in a shared log raises a question: who may read each row? The answer is one stamp, built in lib/access.ts from the document after the write.

type AccessStamp = { access_owner?: string; access_key?: string; access_grants?: string[] };
// owner = user_id, key = the row's workspace ("team:<id>" or "user:<id>"), grants = a task's assignee

The stamp decides two things. Fan out: an action lands in the owner's user scope, in the team scope when the key names a team, and in the user scope of each grant. A task that is private inside a team has the key user:<owner>, so it never enters the team scope at all. Projection: getRange evaluates authorizedFor(stamp, viewer, heldKeys) for each row and each caller.

The callerWhat getRange returns
Is the owner, holds a grant, or holds the keyThe action with its cargo
Reads a row that has no stampThe action without cargo; the client fetches by id
Is not authorizedA bare delete

The direct queries use the same rule. canAccessTask, canAccessDoc, canAccessPlan and canAccessProject are defined as evaluating that stamp, and a property test pins the pure evaluator to the one that reads memberships. The log and the fetch by id therefore cannot disagree. A log delete alone never removes a row on the client: the client asks the authorized query, and removes only ids that the query omits.

Retention, and the client that is far behind

A cron job deletes, for each scope, the prefix of actions older than 30 days and moves floor to the last deleted position. If a client returns with a cursor below the floor, the log can no longer prove the gap, and getRange answers resync: true.

The client then drops the cursor and every crawl watermark that belongs to the scope, stamps a new cursor at the current head, and bumps syncLogFloorEpoch. Two things follow. The list queries run once more as snapshot floors (useBootstrapCollection). And for tasks and docs, the next crawl pages through every row of the workspace with one shot queries. Both paths only add rows, so a crawl that stops early cannot empty the cache. In normal operation that crawl runs once in 24 hours as a safety net. It reports the number of rows it healed, zeros included, and two weeks of zeros in production is the stated condition for removing it.

Since 2026-08-30 (0b8dd169e) the header sync indicator reads the log's real distance. Before each replay, the applier stores head.position - cursor for each scope in syncLogLag, and the indicator shows a catch up while some scope has a lag above zero. Its only other busy state is a first load into a collection with no cached rows. A cold scope stamps at the head and is never behind. A catch up that lasts past 20 seconds turns amber.

On 2026-09-01 (c9bbee4b9) the live list subscriptions for tasks, docs, plans and projects became one shot floors. A measurement on 2026-09-02, over 60 seconds with several agent sessions writing, found that those four list queries no longer appeared on the socket at all, while getRange carried 29 KB in 24 calls. The sessions surface remained: listInboxSessions sent 29.8 MB in 17 pushes and sessionsLiveness 10.5 MB in 50 pushes, 97 percent of all bytes. The inbox window is deliberately not retired yet, and that number is why it is the next target.

One window syncs, the rest replicate

The desktop app opens many windows of one origin: the main window, detached tabs, the command palette, a people window and a spare window that is warmed in advance. Each one ran the full sync layer, so N windows held the same 25 or so workspace subscriptions and each wrote the same rows to the same IndexedDB cache. A change made in one window reached the others only after a server round trip. Since 2026-09-01 (7b05b7e54) exactly one window per origin is the sync host, and the others are followers.

Election uses Web Locks, the browser API that grants a named lock to one holder per origin. Each window that mounts the full shell requests codecast-sync-host, and the holder is the host. The host mounts the global feeders and owns the IndexedDB writes. The store middleware already computes a patch list for every write and hands it to the persistence binding. The host wraps that binding: it persists first, then converts the patches into row updates and posts them on a BroadcastChannel, the browser API that delivers messages between windows of one origin.

A follower skips every global feeder (they mount inside HostFeeders, or gate on useIsSyncHost()). It asks the host for a snapshot, which arrives in batches of at most 256 rows, and then applies the stream. Every message carries { hostId, seq }, and a gap or a new host id triggers a fresh snapshot. Replicated rows enter through syncTable, the same path a Convex push takes, so the follower's own pending locks still win.

Writes do not change. Each window dispatches its own mutations to the server. A follower also offers each optimistic write to the host as a mut message, and that is how the write appears in sibling windows before the server echo. An edited row ships as only the fields the action wrote. The follower's copy of the other fields is one hop behind the host's, so a whole row would move the host's fresher values back a step. The host applies the fields under the same locks the follower holds.

Every registry key is classified in REPLICATION_CLASSIFICATION, a Record over all keys, so a new key without a class is a compile error.

ClassExamplesWhy
sharedsessions, tasks, docs, chatMessages, syncMetaServer data, the same in every window
localpending, drafts, feedCursors, tabs, activeTabId, sidePanelSessionIdOne window's unacknowledged writes, paging and arrangement

pending must never cross the channel, because it is the record of this window's own writes. View state, such as the messages of the open conversation, was never shared and stays in its window. Queries for one view still run in every window.

When the host window closes or crashes, the browser releases the lock and the next window in line becomes host. Its feeder gate is reactive, so the subscriptions mount, and it takes over persistence. The log cursors in syncMeta replicate, so the new host resumes the sync log where the old one stopped. Roles favor safety. Every window boots as a host and becomes a follower only when a snapshot from a living host arrives. A follower that has no synced stream for 8 seconds acts as its own host until a host answers. Where Web Locks or BroadcastChannel do not exist, as in the mobile app and in tests, the window is a host with no transport.

What to take from this

Keep the subscribed value small and move data through one shot reads. Give the log an order that the database proves, not a clock. Derive who receives a change and who may read it from one function. And make every path into the store the same path, so that a push, a log page and a message from a sibling window all meet the same locks.

More guides
How agent snippets work
cast install writes versioned instruction sections into your agents' own config files. This is the mechanism every other guide builds on.
Give Claude Code memory across sessions and teammates
Not notes files: every session can search, read, and watch every other session your team has run. The commands, the scopes, and how agents use them.
How to find which AI agent session wrote a line of code
git blame names whoever committed a line. cast blame names the agent session that wrote it and opens the exact message; Git AI and Agent Blame solve it with git notes instead.
Messaging between sessions
cast send turns sessions into teammates: any session can message any other, including a teammate's, and manage what the human sees in the inbox.
Ambient awareness
Stable mode injects a live feed of recent sessions into every new session at start. Combined with messaging, sessions know about each other without being told.
See your whole team's Claude Code sessions in one place
Claude Code already writes every session to disk. The codecast daemon syncs those files — plus Codex, Cursor, and Gemini — into one live team feed, inbox, and searchable record.
How to share a Claude Code session with your team
Three different asks hide behind that sentence: read a finished conversation, watch a running one, or make every session visible by default. What Anthropic ships, what Lore does, and where codecast fits.
Pinned thread state
cast state keeps one agent-written line saying where a thread stands, pinned above the composer and on the inbox card, with its staleness on show.
Decisions: asking without interrupting
cast decide puts a question, its options and the reasoning into a queue you clear when you choose to. The answer returns to the agent as a message.
Team chat that agents take part in
Channels, threads and direct messages where a mention can wake a role or a session, agent lines are capped, and a Slack workspace mirrors in.
Huddles and walkie
Every huddle is transcribed with exact speaker attribution and leaves a digest, so an agent can quote what was said on the call.
Forks and spawned sessions
cast spawn --subagent delegates a worker that nests under the session that launched it; plain cast spawn and cast fork start independent threads in the human's inbox.
Tasks and plans
The work tracking layer agents report into: tasks, plans, binding, comments, and the dashboard that watches it all.
Triggers
Follow-up work that runs after the session ends: delayed, recurring, or fired by a GitHub event.
Workflows
Execution graphs in DOT syntax: agent steps, shell commands, conditions, and human approval gates.
Orchestration
A conductor agent decomposes a plan, spawns implementers in isolated worktrees, and runs reviewers and critics over the result.
Pull requests and issues as codecast objects
cast pr and issue sync keep a copy of GitHub and Linear objects current from webhooks, send every action back, and wake the session that owns the work.
The org: roles, scopes and the line
Route work to a standing responsibility instead of a session: roles with scopes, wakes, proposals a person accepts, and a line with independent review.
Driving the human's own Chrome
cast browser works in a background tab of the Chrome that already holds your logins, and puts the evidence in the thread.
Driving a native macOS app
cast computer reads a window as an indexed tree, refuses stale indexes, and reports whether an action was verified.
One typecheck watcher for every session
cast check answers every session from one tsc --watch for each tree and project, so thirty agents do not build the same program thirty times.
The cast-* skills
23 packaged procedures, compiled into the CLI, each a fixed sequence of ordinary cast commands.
Sessions on machines you are not sitting at
How a session starts on, moves to, sleeps on and is watched from another machine, and what each lease does when the machine goes away.
Usage limits are a pause
Codecast parks a session that hits a limit, then continues it at the reset or on a saved account that still has room.
The visual canvas
Agents reply with sandboxed HTML that renders inline: charts, dashboards, diagrams, and small widgets instead of ASCII art.
Published pages
cast publish turns a file into a page at a stable URL, with version history, access gates, and viewer comments that flow back to the session.