What is the Oliver Inbox and why did we build it?
The Inbox is the second destination in Oliver's new UI: everything Oliver has to tell you that isn't a conversation.
The Inbox is the second destination in Oliver's new UI: everything Oliver has to tell you that isn't a conversation. This is the log of how we built it, and why most of the engineering was deciding what the surface refuses to do.
The short version
When we rebuilt Oliver's UI as a messenger (previous post), one destination on the sidebar was left as a name and a glyph: Inbox. Its charter fit in a sentence, everything Oliver has to tell you that isn't a chat, and that sentence turned out to be a surprisingly deep engineering project.
What shipped: a single-page briefing surface fed by one request, backed by a five-arm SQL union plus rows synthesized from state at read time, a needs-you section pinned on top, a watermark read model with no per-row unread state, a closed five-word action vocabulary where a row's button and its keyboard shortcut are the same function reference, and the first end of a memory→proposal pipeline where Oliver's brain composes concrete work offers and a human click is the only thing that fires them.
The hard part wasn't rendering rows. It was making a surface that spends the member's attention honestly, and enforcing that mechanically with tests so it stays honest as the codebase moves.
The What
/inbox renders exactly four things, top to bottom. Three of the four unmount entirely when empty.
1. The ambient strip. One line, Oliver is working on <task> · 2:41, rendered only while a delegation is actually running, elapsed counter ticking off the earliest start in the set. It's deliberately not a row: no id, no timestamp, no dismiss, which structurally keeps it out of the keyboard walk. And there is no "all quiet" fallback, because a surface fed by a 15/60-second poll can't honestly claim quiet.
2. Needs you. Rows where something is blocked on the member: a review assigned to you, an open tool-approval gate, a shadow waiting at an approval step. Mono lane label on the left, one-word act on the right, expiry always written as a duration ("times out in 20m"), never a bare timestamp. Two hard rules here. Needs-you rows have no dismiss: hiding something you genuinely owe is grooming, not deciding, and the row leaves when the state resolves. And decide** opens, it never decides**: there is no approve control on this surface, the row navigates to where the judgment gets made.
3. Oliver suggests. Oliver's own offers: title, one-line gist, at most two controls, Start and Decline. The interesting section; it gets its own chapter below.
4. The ledger. What finished, arrived, or broke: shipped previews, shadow runs, shares, resolved approvals and reviews, goals Oliver completed or set aside. Bucketed Today · Yesterday · This week · Earlier, hairline-separated, no cards, keyset-paged, ending in an honest terminal line: "That's everything from the last 30 days." A briefing surface, not an archive.
The Why
The first ruling: completed work never lands here
The easy design was obvious: everything generates a notification, notifications go to the Inbox, add filters when it gets loud. We ruled it out before writing a line, with the decision that shaped the whole surface: completed delegations never generate Inbox rows. When work you asked for comes back, Oliver processes the report and tells you in the thread; the thread is the record. Routing completions to a second surface you'd have to go check would rebuild the scattered-management problem the messenger UI was created to kill.
That subtraction gave the Inbox a sharp charter (blocked-on-you, finished-arrived-broke, offers), and the charter is why it can stay one calm page instead of becoming email.
The editorial cut
The legacy dashboard's activity rail renders eleven event kinds. The Inbox takes six. We cut file_added, chat_session_started, project_active_now, stage_transition, and project_idle outright: they fire constantly, nobody acts on them, and at volume they crowd out the handful of rows that matter. The rail still renders all eleven; the two surfaces are different products, which becomes an engineering decision later in this post.
The badge never sums
The sidebar badge is a pure truth table. Needs-you count > 0 → orange, showing that count only. Otherwise new-since-watermark → navy. Both zero → no badge at all, not a 0 chip. The counts are never added, because a member with one review to decide and eleven shipped builds is blocked on one thing. Proposals never light the orange badge: an offer is not an obligation.
A quiet week and a broken read must never render the same
The worst failure this surface can produce is showing "Nothing yet" because a query died. So the three states are structurally distinct: loading is a labelled skeleton; error is "The Inbox didn't load" plus the route's actual message plus a retry; empty is empty. The server holds the same line: cold is a 200 with empty arrays, broken is a real 500. Even the watermark reader throws rather than degrading to null. Same reasoning one level up: the needs-you section refuses to render an "all clear" banner, because saying all clear while a data lane is dark would be the most confidently wrong sentence on the surface. When there is nothing, there is nothing.
The proactive lane
The part we most wanted to build: "Oliver suggests" is the first surface where the memory Oliver collects comes back as a concrete, executable offer.
Oliver's brain runs on the org's own box, digesting what's happening (conversations, activity, things you've told it). Out of that it mints proposals. The pipeline shape was the defining product decision of the arc, and it's three steps:
memory digested → prompt composed → user clicks go.
Composition happens brain-side, at digest time, and the composer decides three things: the prompt, the target agent (main thread or a specific project agent), and the delivery. By the time a proposal reaches the Inbox it isn't a nudge; it's a loaded action waiting on one click. And the click is load-bearing: nothing auto-executes. Memory proposes; the member is the gate.
The first fully wired producer is the idle-project sweeper: nothing has moved on a project in 14 days → "Want me to take a pass and report back?" Accept, and the row converts in place into a receipt with a destination line (→ Atlas), the delegation dispatches through the same choreography as a thread-initiated ask, and the follow-up lands in the home thread in Oliver's voice.
The engineering under the guarantees:
-
Proposed once. Producers write with an idempotency key and
ON CONFLICT DO NOTHING. A dismissed row keeps its key, so a declined proposal can't sneak back in a later sweep. -
Decline is forever, re-offer is evidence-gated. Two conjuncts in the producer: a permanent declined set with no expiry or decay, and an evidence gate: a non-declined re-offer fires only if the project moved and went quiet again. Calendar buckets were demoted to a belt-and-braces cap. Proactivity that re-asks on a timer is spam with better branding.
-
Offers expire. An idle-project card is an offer about a moment; held for a month it stops describing anything true. Pickups get 7 days, everything else 30.
-
The payload is encrypted; the routing isn't. Proposal gists and bodies are ciphertext, decrypted only at the read edge; the API route never touches plaintext. The one part left readable is the routing envelope, which is exactly the part that decides whether Start renders. A proposal with no routing (today, that's the pure-memory kinds: insights, reminders, promotions) hides Start entirely rather than disabling it. Rendering a button whose only possible outcome is "that didn't go through" is worse than no button.
-
Accept is honest about dispatch.
dispatched: falseis a success response: the acceptance stands, the note says Oliver couldn't start it and nothing is running yet, and no destination link is invented. User-visible state is never silently unwound by infra failure. -
Double-dispatch is structural. The receipt conversion removes both controls on success: an accepted row that kept a live Start button was one click from dispatching the same work twice.
We're deliberate about calling this a first pass. The startable lane is project pickups; memory kinds are decline-only until their routing lands; and declines don't yet feed the preference model. That signal is built but held dark, because the event vocabulary it writes into hasn't been widened yet, and firing it today would be a silent check-constraint violation. The spec carries a line we hold to: no learning claims until the lane is real for that user.
The How: the parts that were actually hard
One request, or the badge lies
Three sections plus a badge come off GET /api/inbox, one request, because four requests are four chances for the badge to disagree with the rows underneath it. Stronger than that: the badge count and the ledger page are built from the same exported SQL fragment, so they are structurally incapable of drifting. The shell polls a counts-only variant (?limit=0) on the usual 15s-active / 60s-idle cadence, paused while the tab is hidden.
Rows synthesized from state
The ledger union has five arms: activity events, ready builds (a failed build is not a shipped preview), owner-scoped shadow runs, terminal goals, pending shares. But some of the most important rows have no stored event at all: nothing in the database says "you approved this." The status field does. A read-time module turns resolved approvals and reviews into synthesized rows, which also settles their dismissal story: synthesized rows merge after the dismissed filter, so they simply carry no ×. Time buries resolutions instead.
Opening the Inbox is reading it
There is no per-row read state. The model is a watermark: the page fires POST /api/inbox/seen on mount, the server's clock is the only clock (the request schema is an empty strict object; a client-supplied timestamp 400s), and the badge counts rows newer than the mark. Accepted consequence, written into the code: a row landing while you're on the page relights the badge and clears next open. That trade bought us: no unread dots, no select mode, no bulk actions, no "clear all", ever.
Deliberate quirks, pinned by tests
A recurring pattern in this build: when a behavior looks like a bug but is a decision, we pin it with a test so nobody "fixes" it later.
-
Bucket semantics are mixed on purpose: Today/Yesterday are calendar days, This-week/Earlier is a rolling 7×24h window. Two tests pin the seam.
-
Coalescing (
+3on repeated same-kind, same-project rows) is bucket-local and page-local; a group straddling a page boundary renders as two rows. Accepted artifact, pinned, rather than left to be rediscovered as a bug. -
The expander shows an honest count summary: the envelope carries only ids, and inventing sub-rows from data we don't have is a named failure mode.
-
The
replyact exists in the vocabulary with a test asserting it's unreachable, reserved for a lane that doesn't exist yet. -
The color grammar is enforced mechanically: orange means "alive or blocked on you," and a test asserts no ledger kind maps to it. A crashed preview from three hours ago is neither.
Agree-or-fail seams
The bucket order is declared in three files; a shared-constant test fails the build if they diverge. The client's type envelope is a byte-for-byte re-declaration of the server's (never an import, to keep pg and drizzle out of the client bundle), with a mutual-assignability test as the enforcement. Where two sources of truth must match, we make disagreement a compile failure, not a code review hope.
Fork the copy, don't share it
The Inbox's event copy started as a byte-level fork of the dashboard rail's humanizer, then diverged deliberately: six documented divergences, pinned by a parity test. We considered one shared function with a surface parameter and rejected it: the two feeds are the same copy on different products, and sharing would put every future Inbox editorial change one bad conditional away from silently rewording the dashboard.
One act per row, one function decides it
Every actionable row resolves to exactly one act from a closed vocabulary (go, start, decide, fix, reply), chosen by one pure function. The row's visible button and its keyboard binding are the same function reference, so they can't drift. The copy rule that fell out: a row that needs a sentence to explain its button is a row with the wrong button. (We learned this the fun way: the review lane originally rendered "REVIEW … REVIEW", lane label and act label, same word, and it read as a rendering bug. The act became "Decide.")
Keyboard: focus anchored to identity
The whole surface is walkable: arrows, Enter to go, y for the row's act, n decline, e dismiss. The roving index is anchored to a row id, not a list position, so a row landing above the focused one doesn't slide focus and make your next keystroke act on the wrong item. Dismissing the focused row moves focus to its successor, so e e e walks a list down. And y never approves; on a decide row it opens. A single letter that silently approves an approval is the worst affordance this product could ship.
Failure is communicated by structure, not toasts
There are no toasts on the surface. A dismissal is optimistic: the row vanishes, and if the write fails it returns at its original index with one quiet mono line saying it didn't stick and it's back for now. A failed accept leaves the row sitting there with live controls and a section-level role="status" note. The structure carries the message; the note only names it.
What's next
-
Startable memory. Insights and reminders gain routing envelopes so memory-derived offers carry a Start of their own.
-
Declines that teach. Widen the brain-event vocabulary, light up the dismissal signal, and every
×becomes a datapoint the preference model learns from. -
The remaining lanes. Crashed previews should pin as needs-you rows with a Restart act (today they report on the ledger);
replystays test-pinned unreachable until its lane is real. -
Fuller group expansion. Carry the swallowed rows' headlines on the coalesce envelope instead of a count summary.
The through-line, if you're building something similar: an agent that works for you emits two different things, conversation and obligations, and they deserve different surfaces. Keep the conversation in the thread. Give the obligations a ledger that refuses to inflate, never sums an offer into a duty, and never says "all clear" unless it knows. Enforce the honesty mechanically: same SQL for badge and rows, tests that pin deliberate quirks, buttons that hide instead of lying. And when your agent's memory gets good enough to propose work, compose the whole action brain-side, remember every no forever, and put a human click between memory and execution.