feat(service,host): the account inbox is followed over Neon Realtime
The request was to make Luke as realtime as possible with Neon Realtime, following Neon's docs exactly, and to get rid of our hand-rolled freshness logic. This is the first piece. It moves the desktop's one always-on read, what waits across the account (the Dock badge, notifications, and the Inbox list), off the held long-poll and onto a live subscription. It also adds the plumbing every later read will reuse.
What changes
Service: sealing, following "Defining queries" → "Sealing authorized queries" → "Exposing an endpoint"
GET /api/live/inbox(packages/service/src/live/live-app.ts) resolves the bearer, then builds one live statement per part of the account inbox: items, planless items, plans, automations, failed runs, and what waits in chat (open asks and unread DMs or mentions). Each statement embeds that account's id, and the endpoint seals all seven.- Each statement uses the same joins and the same rules for who may read the rows as main's held read (
inbox-store.ts,automation-store.ts,chat-store.ts). That includes each item's per-member unread state, and the archived tasks holding items that the Inbox's Archived tab lists (the held read'sarchived=include, which every desktop asks for). - Every column is aliased to its wire key and every instant is returned as epoch milliseconds, so the desktop decodes each row with the existing wire schemas.
- Each statement uses the same joins and the same rules for who may read the rows as main's held read (
packages/database/src/core/live-statement.tsrenders a Drizzle statement into the raw SQL and values that Realtime takes.- Neon's Drizzle adapter supports Drizzle
<0.46; we're on 1.0 rc.
- Neon's Drizzle adapter supports Drizzle
LiveSealing(live/realtime.ts) readsNEON_REALTIME_URLandNEON_REALTIME_SECRET. If either is missing, or the secret is malformed, the deployment is misconfigured and the route answersunavailable.- Live queries must be deterministic, so the failed-runs statement drops its 7-day window. The desktop keeps the last week instead.
Host: subscribing and renewing, following "Fetch from an API endpoint" → "Subscribe to the sealed query" → "Renew the sealed query"
account-inbox-follow.tsfetches the sealed statements and subscribes with Neon's own client at the address the service returns.- The client (about 24 kB gzipped) is loaded with a dynamic import on the first follow, so it stays out of the web app's first load.
- Each part's rows are decoded with Schema as they arrive.
- Seals are renewed 10 s before they expire, and a failed fetch is retried after 1 s, matching the timing of Neon's bindings.
- Once every part has rows, each change emits the full answer as an
accountInbox.changedGateway event. The answer's cursor digests what the held read's cursor stamps (items' revision, instant, and unread state; failed runs; chat), so the desktop'sobserveWaitinghears every change that a held answer would show. - The answer is published again when a failed run ages out of the last week, since Realtime follows no clock.
- Realtime alone: there is no held-read fallback. A seal the service didn't answer, or a subscription that stopped, is asked for again after 5 s.
- Signed queries stay in the host. They are bearer credentials, so they live next to the account token and never travel in a Gateway answer or event.
- Gateway method changes:
codingAgents.accountInboxis retired.codingAgents.accountInboxFollowandcodingAgents.accountInboxUnfollowreplace it.- Sign-out lets the follow go.
Main: agent-notices.ts no longer loops on the held read. It follows the inbox and listens for the event, per signed-in account.
The held route /api/inbox stays, because released desktops still poll it and there's no forced-update gate.
Production
Realtime is enabled on the production Neon branch, and NEON_REALTIME_URL and NEON_REALTIME_SECRET are in Vercel. The desktop follows the account inbox over Realtime alone; the held route stays only for released desktops.
Tests
inbox-store.test.tsruns the seven live statements as raw SQL against a real Postgres (PGlite), with several readers. For each reader, it decodes the rows with the desktop's schemas, builds the answer, and checks it is equal to the held read's answer witharchived=include. The fixture includes chat asks and DMs, items read by one member and not another, and an archived task that holds an item. That covers the SQL, the aliasing, unread state, and who may read what.live-app.test.tschecks the refusals:- no bearer is refused;
- a misconfigured deployment answers
unavailable; - every part is sealed for the bearer's account and nobody else's;
- a refused seal answers
unavailable.
realtime.test.tschecks the environment handling and real local sealing with a synthetic secret: the SQL and values are unreadable in the seal, and the fingerprint is stable.compose-coding-agents.test.ts, against a scripted Realtime, checks:- subscribing at the returned address;
- publishing the whole answer in the held read's order;
- the 7-day failed-run window;
- that no signed query is published;
- renewal at expiry − 10 s;
- restarting after a bad row;
- a seal the service didn't answer is asked for again after a pause;
- unfollow and sign-out;
- re-publishing when a failed run ages out.
live-wire.test.tschecks that the live cursor moves for a DM, a read, a re-post, a replaced item, a new failed run, and an aged-out run, and stays put otherwise.agent-notices.test.tsnow drives the notices through the follow and the event.- Bundle budget: Realtime's client (about 24 kB gzipped) is loaded lazily, so the web entry stays within main's budget (937,816 of 980,869 locally).
- Rebased onto main at #3958, past the oRPC move (#3920) and the Archived tab (#3724).
apps/web/server/function-externals.jsonrecords that the default bundle now inlinesws, which Realtime's SDK loads.
Next
These follow as separate PRs:
- plan and agent inboxes
- the plans list, agent summaries, and automations
- coding-agent transcripts
- the planning conversation and
plan.work - Mac commands and the service's internal polls
- a minimum-version gate, then deleting the held routes and
held-read.ts
Made with Luke
Summary by cubic
Follows the desktop's always-on account read (the Dock badge, notifications, and the Inbox list) over Neon Realtime instead of the held long-poll, following Neon's guide. Where Realtime isn't available, it falls back to the held read.
Changes
- The service seals one live statement per part of the account inbox for the bearer's account at
GET /api/live/inbox, using the same joins and read rules as the held read. - The host subscribes at the returned address, decodes rows with the existing wire schemas, renews seals 10 s before they expire, and emits every change as the full answer in an
accountInbox.changedGateway event. - The failed-runs statement drops its 7-day window because live reads must be deterministic; the host keeps only the last week, and the held read now shares the same window constant.
codingAgents.accountInboxis retired;codingAgents.accountInboxFollowandcodingAgents.accountInboxUnfollowreplace it, and sign-out releases the follow.- Sealed statements are bearer credentials, so they stay in the host and never travel in a Gateway answer or event.
- The held
/api/inboxroute stays because released desktops still poll it.
Rollout
- Enable Realtime on the production and preview Neon branches.
- Add
NEON_REALTIME_URLandNEON_REALTIME_SECRETto Vercel for Production and Preview. - Until both are in place, everything runs through the held-read fallback.