ADR 0023: Realtime Channel Between the UI and the Services¶
Date: 2026-10-23
Status¶
Proposed
Related: ADR 0002 (authentication), ADR 0019 (Iggy), ADR 0020 (agent tasks), ADR 0021 (workflows).
Context¶
Open pages show stale data¶
The UI shows a change only after it fetches the current state. A change made by another person, an agent, a webhook, or a background job appears on an open page only after the page refetches, either on a polling interval or when the user reloads.
Deploys show the problem. Imbi starts a deploy run in one of four ways. A direct deploy deploys a commit from the Deploy button. A rollback deploys an earlier commit. A promote moves a release to the next environment, either by dispatching a release build, a GitHub workflow that creates the tag and the Release, or by starting the deploy run itself. The UI calls the second kind of promote an inline promote.
When a user clicks Deploy, DeploymentRunWatcher polls the run from
that user's browser and keeps a toast up to date. Other users on the
project's deployments tab see nothing. Their page loads deploys with the
currentReleases query, which uses the global defaults in
ui/src/lib/queryClient.ts. Those defaults set a staleTime of five
minutes and turn off refetch on window focus, so an open page never
refetches the list on its own. Until
#534, a deploy of a
commit with no Release was not recorded at all, so a reload did not
show it either.
Each live feature polls on its own¶
Features that update while a page is open each implement their own polling loop:
| File | Interval | Watches |
|---|---|---|
components/deploy/DeploymentRunWatcher.tsx |
4 s | A direct deploy, rollback, or inline promote, in the browser that started it |
components/deploy/ReleaseBuildWatcher.tsx |
6 s | A release build |
components/releases/releaseInFlight.tsx |
6 s | A promote |
components/agents/taskQueries.ts |
3 s, 15 s | Task events, the inbox, the "waiting on you" count |
components/workflows/workflowQueries.ts |
2 s | Open workflow runs |
hooks/useCommitSync.ts, usePRSync.ts, useDeploymentResync.ts, useMaintenanceOperations.ts |
3 s | Background syncs and maintenance operations |
hooks/useDocumentPresence.ts |
10 s | The "currently editing" heartbeat for documents |
components/admin/AgentRunners.tsx |
15 s | Runner status |
The decision depends on these facts¶
- Pages read all data through permission-checked REST endpoints.
The UI caches each response with TanStack Query, and
ui/src/lib/queryKeys.tsdefines every query key in one place, so the UI can refetch any resource by its key. - Writes happen in more than one process. imbi-api makes most writes, including those to agent tasks, comments, and deployments. imbi-workflows writes run state, and other services write the graph directly.
- GitHub owns the status of deploy runs. A promote that dispatches
a release build has a server-side watch job in
release_promote/queue.py. Direct deploys, rollbacks, and inline promotes have none. Imbi records their status only when a GitHub webhook arrives, when a user runs a manual resync, or when a page loads. Every call tolist_current_releasesasks GitHub about each in-flight run it returns (_reconcile_in_flight_runinreleases.py). - Valkey is required in practice. Only imbi-api connects to Valkey
today.
VALKEY_URLdefaults tovalkey://localhost:6379/0, so imbi-api starts without it set. Nothing serves that address in the pod, so imbi-api's workers start and then fail on their first command. - imbi-api can run more than one pod.
service.api.replicasin the Helm chart sets the pod count. The Kubernetes Deployment uses the default rolling update, so even with one replica, an old pod and a new pod both serve traffic during a rollout. - WebSockets can authenticate with the existing access cookie. The
browser
WebSocketAPI cannot set request headers, so the UI cannot send the bearer token it uses for REST calls. The browser does send cookies with the handshake, the HTTP request that opens the connection. imbi-api already copies the access token into theimbi_access_tokencookie so that<img>requests to the upload endpoints are authenticated. The cookie's path is the API prefix, so the browser sends it to any endpoint under that prefix. It isHttpOnlyandSameSite=Strict, and it expires with the access token (one hour by default). - The stack already supports WebSockets. imbi-common depends on
uvicorn[standard], which installs thewebsocketslibrary. Caddy's/api/*route proxies WebSocket handshakes with no extra configuration.
Decision¶
Each browser tab opens one WebSocket to imbi-api, and every feature on the page shares it. Over the socket, the browser subscribes to topics, each of which covers one scope such as a project or a document. When something on a topic changes, the writer publishes an event to Valkey pub/sub. Every imbi-api pod receives the event and sends it to its own sockets that subscribe to the topic. The event names the resource that changed, and the browser refetches that resource through REST.
Presence, the record of who is viewing or editing a page, moves onto the same socket. Presence is why the channel is a WebSocket. The browser sends presence updates as often as the server sends events, and a WebSocket carries messages in both directions. Server-Sent Events (SSE) only carry messages from server to browser, so presence would need a second channel. See Alternatives Considered.
flowchart LR
subgraph writers[Writers]
api[imbi-api routes and workers]
wf[imbi-workflows]
other[other services]
end
vk[(Valkey pub/sub<br/>one channel per topic)]
subgraph pods[imbi-api pods]
p1[pod 1: one subscriber,<br/>topic to sockets map]
p2[pod 2: one subscriber,<br/>topic to sockets map]
end
b1[browser tabs]
b2[browser tabs]
api -- publish --> vk
wf -- publish --> vk
other -- publish --> vk
vk --> p1
vk --> p2
p1 <-- WebSocket --> b1
p2 <-- WebSocket --> b2
b1 -. REST refetch .-> api
b2 -. REST refetch .-> api
Topics¶
imbi-api keeps a registry with one entry per topic type. Each entry names the permission that the matching REST read requires, and imbi-api checks that permission when a browser subscribes.
| Topic | Read permission |
|---|---|
org:<org> |
Membership |
project:<org>:<project_id> |
project:read |
agent:<org>:<slug> |
agent:read |
prompt:<org>:<slug> |
prompt:read |
workflow:<org>:<slug> |
workflow:read |
document:<org>:<document_id> |
document:read |
task:<org>:<short_id> |
agent_task:read |
user:<user_id> |
Only that user |
Every topic except user: includes the organization, as the document
presence keys do. To authorize a subscribe, imbi-api calls
imbi.api.auth.organizations.check_member for that organization, then
checks the entry's permission. Adding a topic type means adding one
registry entry.
Messages¶
All messages are JSON.
// browser to server
{"type": "subscribe", "topic": "project:acme:abc123"}
{"type": "unsubscribe", "topic": "project:acme:abc123"}
{"type": "presence", "topic": "agent:acme:triage", "state": {"editing": "system_prompt"}}
{"type": "presence", "topic": "agent:acme:triage", "state": null}
// server to browser
{"type": "ping"}
{"type": "subscribed", "topic": "project:acme:abc123"}
{"type": "invalidate", "topic": "project:acme:abc123", "resource": "deployments", "actor": "alexc@aweber.com"}
{"type": "presence", "topic": "agent:acme:triage", "members": [{"user": "...", "state": {"editing": "system_prompt"}, "changed_at": "2026-10-23T20:12:49Z"}]}
{"type": "notify", "topic": "user:42", "level": "success", "title": "Deployed abc1234 to testing", "url": "/projects/abc123/deployments"}
{"type": "error", "topic": "project:acme:abc123", "code": "forbidden"}
pingkeeps the socket busy and lets the browser detect a dead connection. See The/realtimeendpoint.subscribedconfirms that a subscription is active. Delivery explains why the browser waits for it.invalidatesays that a resource on a topic changed, and who changed it. Most messages are this type. It carries identifiers only, never the changed data, so the browser refetches through REST and the REST endpoint checks permissions again. A bug in topic authorization can therefore expose only that a resource changed and who changed it.presenceis short-lived state about who is on a topic and what they are doing. Each topic type defines and validates its own state shape. From the browser it carries the sender's state, andstate: nullleaves the topic. From the server it carries a snapshot, the complete list of members, which replaces the one the browser shows.notifyis a message for one person, shown as a toast. It is allowed only onuser:topics, so only that person sees its text.errorreports a refused subscribe or a malformed message. The socket stays open.
Publishing events¶
imbi.common.realtime.publish(topic, event) publishes a Pydantic model
to the Valkey pub/sub channel imbi:rt:<topic>.
- Code that writes calls
publishafter the write commits. Where a write already publishes an audit or domain row to Iggy (operations_log,events), it callspublishin the same place. - Each writer calls
publishitself instead of relying on HTTP middleware, because webhooks, sweepers, agent tasks, and workflow steps write without an HTTP request. publishnever raises. It retries a failed publish once, then logs and counts the failure, as document presence does during a Valkey outage. Delivery covers how the browser recovers.publishcounts published events and publish errors by topic type, following the pattern ofimbi_iggy_published_total.- A service other than imbi-api adds
valkey.valkey_lifespanwhen it starts publishing. imbi-workflows is the first, to publish run progress.
A contract test catches write routes that forget to publish. It lists every mutating route in imbi-api and fails for any route that neither publishes an event nor appears on an exemption list with a reason. Writers outside HTTP routes rely on code review.
The /realtime endpoint¶
imbi-api serves the WebSocket at /realtime under its route prefix.
- The handshake authenticates with the access cookie. imbi-api reads
the cookie with the same resolver as
require_permission(..., allow_cookie=True). That resolver already refuses agent API tokens in the cookie, so the endpoint refuses them too. - The
Originheader must match the public URL or an entry incors_allowed_origins.SameSite=Strictalready stops the browser from sending the cookie on a cross-site handshake. TheOrigincheck is a second defense against cross-site WebSocket hijacking. - Each subscribe is authorized with the check from the topic's registry entry.
- Permission changes apply to open sockets. A write that changes
what a user can read, such as an organization membership, a role
assignment, or a role's permissions, publishes a
permissionsevent. The event names the user, or the organization for a role change. It goes on the internal channelimbi:rt:permissions, which every pod always subscribes to and browsers cannot subscribe to. Every pod receives every permission change, which costs little because permission changes are rare. Each pod reauthorizes the subscriptions of its sockets that belong to the named user, or to a member of the named organization. It drops each subscription that fails and sends anerrormessage for it. - Each socket handles its subscribes and reauthorizations one at a time, in the order they arrive. A reauthorization that arrives while a subscribe is still being checked runs after that subscribe activates, so it rechecks that subscription too.
- The socket closes when the access token expires. When the token's
exppasses, the server closes the socket with code4401. The browser refreshes the token through the normal flow and reconnects. This also limits how long a connection lives, which Multiple pods relies on. - The server sends a
pingmessage every 25 seconds. It stops proxies and load balancers from closing an idle socket, and lets the browser notice that the server went silent. uvicorn's WebSocket protocol pings cover the other direction (see Presence), but the browser answers protocol pings itself and never shows them to page code, so the browser needs a message it can see. - Limits cap topics per socket, message size, and presence updates per second.
Each imbi-api process holds one Valkey pub/sub connection, shared by all its sockets. An in-memory map from topic to sockets sends each event to the sockets subscribed to its topic. The process subscribes to a channel when the first local socket subscribes to that topic, and unsubscribes when the last one leaves.
The Helm chart documents Valkey as a required backing service.
The browser¶
RealtimeProvidermanages the tab's socket. It connects, reconnects with backoff and a random delay, refreshes the token after a4401close, and showsnotifytoasts. It keeps its state in a zustand store.RealtimeProviderdetects a dead socket. A connection can die without acloseevent, for example when the network drops. The provider treats 60 seconds without a message as a closed socket, and treats the window'sofflineevent as one immediately.- The page shows when live updates pause.
RealtimeProviderexposes the connection state. When the socket has been down for more than a few seconds, the page shows that live updates are paused. The delay keeps the notice from flashing during the reconnect after a rollout. The socket can be down while REST still works, so the notice does not say the user is offline. useTopic(topic)subscribes while a component is mounted. Several components can use the same topic, and the topic stays subscribed until the last one unmounts.- The invalidation bridge is one table keyed by topic type, then by
resource. Each entry listsqueryKeysbuilders, which take the topic's parameters, such as the organization and project ID. On aninvalidateevent, the bridge callsqueryClient.invalidateQueriesfor the keys under the event's topic type andresource. A topic's queries are all the keys under its topic type, and the refetches in Delivery and the fallback use them. A query whose key is in the table updates live with no change to its component. usePresence(topic, state)sends the caller's state and returns the other users on the topic, each listed once. It drops the members that belong to the caller's own user, so the caller's other tabs do not appear. When one user's tabs have different states, a non-empty state wins over an empty one, and between two non-empty states the latestchanged_atwins. When its component unmounts, it sendsstate: null, which removes the caller from the topic. While the socket is down, it returns no members, because the browser cannot tell who is still there.- One fallback replaces per-feature polling. While the socket is
down,
RealtimeProviderrefetches every subscribed topic's queries every 30 seconds.
Delivery¶
Valkey pub/sub delivers each event at most once, and events from
different publishers arrive in no guaranteed order. The design tolerates
both because an event only tells the browser to refetch. Two refetches
give the same result as one, so order does not matter, and the browser
recovers from a missed event by refetching. A missed notify is lost,
but it only repeats information that pages show anyway.
A presence message is the exception, because the browser shows its
snapshot without refetching, so an older snapshot arriving last would
stay on screen. Presence explains how snapshots arrive in
order.
The browser can miss an event in four ways, and recovers from each:
- An event sent while the socket is down. After it reconnects, the browser refetches every topic it subscribes to.
- A write made between a REST read and the subscription. The server
sends
subscribedonly once its Valkey subscription for the topic is active. Whensubscribedarrives, the browser refetches the topic's queries that started before it. This happens on the first subscribe and on every reconnect. As a result those queries load twice. The implementation can avoid the second load by delaying a topic's queries untilsubscribedarrives, whenever the socket is open. - An event published while a pod's Valkey subscriber is
disconnected. When that connection drops, the pod closes its
sockets with code
1013(try again later) and refuses new ones until the subscriber reconnects. Until the browsers reconnect, they use the 30-second fallback. - A publish that fails after its retry. The writer cannot tell which browsers missed it. The page stays stale until the next event on the topic, a reload, or the user returning to a hidden tab, because the browser refetches its subscribed topics when a hidden tab becomes visible.
Multiple pods¶
The design needs no sticky sessions.
- A WebSocket is one long-lived TCP connection. The load balancer picks a pod at the handshake, and the connection stays on that pod until it closes.
- The only state a pod keeps is each socket's subscription list, which it discards when the socket closes. Presence state is in Valkey.
- A pod forwards every event it receives from Valkey to its own subscribed sockets, whichever pod or service published it.
- A reconnect can reach any pod. The browser sends its subscriptions and presence state again, then refetches.
Running more than one pod has three consequences that the design handles:
- A rollout or scale-down closes every socket on a pod at once. On
shutdown the server closes its sockets with code
1012(service restart). Browsers reconnect after a random delay, so they do not all reconnect and refetch in the same second. - Connections do not rebalance on their own. After a scale-up, old sockets stay on old pods. Because the server closes each socket when its token expires, connections spread over all pods within one token lifetime.
- Every pod needs Valkey, even with one replica. Writers in other services publish through Valkey, and during a rollout the old and new pods each hold sockets.
Presence¶
Presence updates move from the HTTP heartbeat in useDocumentPresence
to the socket.
- Each topic has a Valkey sorted set of members scored by expiry time,
as
document_presence.pydoes today. Each member is one socket. It records a user, that socket's current state for the topic, and when that state last changed. A user with two tabs open is two members, so closing one tab leaves the other. - The server refreshes a member while its socket is on the topic. It
removes the member when the browser sends
state: null, unsubscribes from the topic, or closes the socket. If a pod crashes, its members expire after the TTL. - The server detects a client that vanished without closing its socket
through WebSocket protocol pings. uvicorn sends one every 20 seconds
(
ws_ping_interval), the browser answers it on its own, and uvicorn closes a socket whose answer has not arrived after 20 seconds (ws_ping_timeout). Closing the socket removes its members. - A member's TTL is 30 seconds, and each pod refreshes its members every 10 seconds. A vanished client therefore leaves the member set within about 40 seconds, and so do the members of a crashed pod.
- Every change runs as one Valkey Lua script. The script updates the
set, removes expired members, reads the set, and, if any member was
added, removed, or changed state, publishes a
presencesnapshot on the topic. Valkey runs a script as one step, so no other change can happen between reading the set and publishing it. Snapshots therefore reach every subscriber in the order the changes happened. - A pod's periodic refresh runs the same script. Removing expired members counts as a change, so the refresh publishes when it removes the members a crashed pod left behind. Sorted sets do not expire members on their own, so without this step no snapshot would show them leaving.
- Presence is never stored past the TTL and never sent to ClickHouse.
Deploy status¶
When another system owns some state, one server-side job polls that system and publishes each change. Browsers never poll it.
Deploys are the first case. Every direct deploy, rollback, and inline
promote starts a watch job like the promote jobs in
release_promote/queue.py. The job polls the run, records each status
change, and publishes invalidate on the project topic for each one.
When the run ends, the job publishes notify to the user who started
it, so that person sees the outcome on any page, in any tab.
While a watch job runs, it holds a lease, the Valkey key
imbi:deploy-watch:<run_id> with a 120-second TTL, and renews it every
30 seconds. These are the claim timings that the promote queue uses.
The job renews the lease on its own timer, apart from polling, so the
lease does not expire while the job pauses for a GitHub rate limit.
list_current_releases still asks GitHub about in-flight runs, but only
about runs with no lease. That check keeps a run's status current if
its watch job died, and other page loads no longer call GitHub. The
check does not start a new watch job. Like a promote job, a stalled
deploy watch job is taken over by another pod through the queue's
claim. A run can stay in one
status for longer than any fixed interval, so the time since the last
status change cannot tell whether the job is still alive.
Relation to Iggy¶
Iggy (ADR 0019) is the durable record, and the message bus for services
that must process every message. The realtime channel is for open
browsers and stores nothing. A write that matters to both publishes to
both. A consumer of the planned Iggy notifications stream could
publish notify to user:<id>, so a notification appears in an open
tab immediately and also goes out by Slack or email.
What this enables¶
Each item is a separate feature, tracked as its own GitHub issue once this ADR is accepted:
- Conflict-safe saves for agents, prompts, workflows, and documents, with a notice when another person saves a newer version.
- Presence indicators on project, agent, prompt, and workflow pages.
- A warning when two people are about to deploy to the same environment.
- Active agent tasks shown on the pages of the projects they work on.
- Live comments on documents.
- Notifications on the
user:topic, such as mentions and tasks waiting on you. - A live webhook history for administrators.
- A "new version deployed" banner.
- Collaborative editing, which needs its own ADR.
Implementation¶
The work is one pull request for the core, then one per migration. Each pull request deletes the code it replaces. They can ship in the same release.
- The core and deployments. They go in one pull request because deployments are the first feature that uses the core, and the core cannot be tested end to end without one.
- The channel, the topic registry,
RealtimeProvider,useTopic, the invalidation bridge, the fallback, and the contract test. - The deploy watch job, the
notifyon completion, and the limited GitHub check inlist_current_releases. - Done when a second browser on the project shows a deploy within two
seconds, on the environments card and in
CurrentlyRunningCard, and shows each status change. - Presence. Move document presence onto the socket.
- The remaining pollers. One pull request for each remaining poller in the polling table.
What this removes¶
#533 already removed the unused deploy and promote modal, about 2,200 source lines and 1,000 test lines, along with the deploy banner that only that modal used. The counts below are in addition to #533.
| Step | Deleted | Reduced |
|---|---|---|
| 1 | DeploymentRunWatcher (200 lines, 186 test), ReleaseBuildWatcher (279, 280 test), useAdoptInFlightPromote (125, 203 test), GET .../deployments/runs/{run_id} and its UI function |
The watcher wiring in ProjectDetail.tsx, the onRunStarted and onBuildStarted callbacks through the deployments and releases components, the polling in releaseInFlight.tsx, and releaseBuildHandoff.tsx (84 to about 15 lines) |
| 2 | useDocumentPresence (56, 73 test), document_presence.py and its three routes (146, 153 test), their UI endpoint functions, query key, and generated types |
|
| 3 | The refetchInterval options and their constants in the sync hooks, taskQueries.ts, workflowQueries.ts, useMaintenanceOperations.ts, and AgentRunners.tsx |
Steps 1 and 2 delete about 830 source lines and 900 test lines outright, plus the reductions.
GET .../deployments/runs/{run_id} is also an MCP and agent tool, so
the release notes announce its removal. Once the watch job records each
status change, agents can read deploy status from the deployment
endpoints, which are also tools. Agents store their tools by name, so an
agent whose saved tools include the removed tool would lose deploy
status. The pull request that removes the endpoint also saves a new
version of each such agent, with the current-releases tool in place of
the removed one.
GET .../deployments/promote-status and the sync-status endpoints for
deployments, commits, and pull requests stay. Page loads read them, and
every invalidate event refetches them.
Not decided here¶
- Collaborative editing, including a
docmessage type, the CRDT library, and where to store the update log. - A notification center with unread state. Unread state must persist, so it needs a durable store.
- Realtime access for agent API tokens, MCP clients, and other non-browser clients.
- Autoscaling imbi-api.
Consequences¶
Benefits¶
- An open page shows changes from other people, agents, webhooks, and jobs within about a second.
- Server load grows with the number of changes instead of the number of open tabs. An idle socket carries one ping every 25 seconds.
- One watch job polls GitHub for each deploy run. Page loads call GitHub only for a run whose watch job died.
Costs and risks¶
- imbi-api holds long-lived connections. An imbi-api rollout closes them all, and every connected browser reconnects and refetches. The random reconnect delay spreads that load, but each rollout still causes a burst of requests.
- imbi-workflows, and any other service that publishes, depends on Valkey. A Valkey outage stops live updates, but no request fails. Pages fall back to the 30-second refresh.
- Each write that matters to the UI needs an explicit
publishcall. The contract test covers HTTP routes. A missed call elsewhere leaves pages stale until their next refetch. - A permission removed from a user takes effect on open subscriptions
when the
permissionsevent arrives. If that event is lost, it takes effect at the next reconnect, at most one token lifetime later. Until then the socket still receives the actors ininvalidateevents and the presence state for topics the user can no longer read. Refetches check permissions, so no resource data is exposed. - MCP clients and agents lose the run-status tool. Saved agents that use it get a new version with its replacement.
- The WebSocket endpoint is not in the OpenAPI schema. This ADR and the client types document the protocol.
Alternatives Considered¶
Polling with shorter intervals¶
Set a short staleTime and refetch on window focus. This is the
smallest change, and it fixes part of the deploy case. We rejected it as
the long-term design because load grows with open tabs, every page load
calls GitHub, and polling cannot carry presence. A 30-second refresh
stays as the fallback when the socket is down.
Server-Sent Events with POST for messages to the server¶
SSE would be enough for invalidate. Presence would need a POST for
each update, which makes two channels with separate failure modes.
EventSource cannot set headers either, so it has no authentication
advantage. Each SSE stream also holds one of the six HTTP/1.1
connections a browser allows per origin, unless every hop uses HTTP/2.
A separate realtime service¶
An imbi-realtime service would keep long-lived connections apart from
REST traffic and scale on its own. We rejected it for now. Every service
ships in one image and releases together, so a separate service would
still close every socket at each release. It would also add a console
script, a Caddy route, and a Helm mode. The protocol stays the same if
the endpoint moves later.
Iggy as the fan-out bus¶
Iggy is durable, has one partition per topic, and keeps consumer-group offsets on the server. Each imbi-api pod would need its own consumer state for messages that nobody needs after a second, and persistence would add latency and storage for no benefit. We rejected it.
Valkey Streams in place of pub/sub¶
Streams keep history, so a reconnecting browser could replay missed events. Refetching after a reconnect gives the same result with no history to trim, so we rejected Streams for now. They stay an option if a feature needs replay.
Sticky sessions and fan-out in memory¶
Writers in other services and the pod overlap during a rollout need a shared bus in any case. With the bus, sticky sessions add nothing, so we rejected them.
Events that carry data¶
An invalidate event could carry the new row so the browser skips the
refetch. Every event would then need its own permission filtering for
each subscriber, and the REST endpoints would no longer be the only read
path. We rejected it.
A hosted or third-party realtime server¶
Socket.IO, Centrifugo, Pusher, and Ably all solve fan-out. Each adds a service or vendor, a second authentication path, and a protocol to learn. The protocol here is small, and Valkey and imbi-api already provide everything it needs, so we rejected them.