Inbox
The Inbox is your unified human-in-the-loop feed. Anything an agent — anywhere across your crews — needs you to look at lands here, in one ordered list, with the action you’d take inline on the detail panel. No tab switching, no hunting through/issues for the one paused routine, no separate “approvals queue” page to remember.
It lives at /inbox in the running web UI and is the first item in the Plan group of the sidebar. The bell in the top bar shares its unread count with the sidebar badge — both update over WebSocket so a peer triaging an item in another tab clears your bell instantly.
What lands in the Inbox
Items get written through toinbox_items the moment their source signal fires. inbox.AllKinds is the single source of truth for the set, and TestInboxKindsMatchSchema inserts one row per entry against the real migrated schema — a kind added there without a matching migration fails CI rather than failing silently at runtime, where the alert simply never arrives:
The attention contract
Every row carries — or can carry — three server-computed fields (PRD-ISSUES-AND-ROUTINES-2026 §12, work package B10):thread_key— the identity of the recurring condition the row is about, stable across every occurrence. Two producers describing the same underlying subject (a routine’s governance review and its trigger-activation review, both raised from oneroutine save --draft) share athread_keyand collapse onto one card instead of two, and the same condition recurring on a later sweep (a schedule still missing its window, a webhook still failing) refreshes that one card in place rather than piling up a new row. This is the server-side merge that replaces the old client-side one (internal/api/pipeline_governance.go,internal/api/pipeline_trigger.go,internal/pipeline/schedules.go,internal/api/pipeline_webhooks.go,internal/automation/registry.go).attention_class— one ofdecision|input|review|repair, naming what kind of ask this is, independent ofkind(which names the producer).actions[]— the permitted, named things a recipient can do about this row:{"id", "label", "effect", "irreversible"}. A merged card’sactions[]is the union of every producer’s own actions, so a routine that is both risky and carries a draft trigger offersapprove_routine/reject_routineandactivate_trigger/dismiss_triggeron the same card.
internal/inbox.WriteThreaded — it looks for an open (non-resolved) row under the same (workspace_id, thread_key) before deciding whether to raise a new card or refresh the existing one. A decision route resolving a possibly-merged card uses inbox.ResolveByThreadOrSource, which falls back from its own (kind, source_id) to the shared thread when the row’s stored identity belongs to a different producer than the one resolving it.
Not every kind has adopted the contract yet —
thread_key/attention_class/actions are additive columns, empty for a row no producer has migrated onto them. GET /api/v1/inbox and GET /api/v1/inbox/{id} return whatever is set; an empty actions[] means “read the kind-specific action row” (the table above), same as before B10.attention_class is the small badge beside a card’s title and on its row — Decision, Input needed, Review or Repair — so an ask for input and an ask for a verdict read differently before either is opened. A row without the contract carries no badge.
Acting on a NEEDS_HUMAN card
Arun_needs_human card renders its actions[] as buttons in the detail pane, where a waitpoint shows Approve / Deny. Each one calls POST /api/v1/inbox/{id}/act — the same door as crewship inbox act — and the card resolves in place:
- Answer opens a text box. Send posts your text as a comment on the issue, authored by you, and delivers it to the same agent session that asked; the run resumes from its checkpoint with your answer as its next delivery (queued behind the current run if the session is mid-run). Send stays disabled until there is text.
- Take over — you continue the work yourself. This is the issue’s take-over (see Work, hand-offs and Lead review): agent runs are stopped, waiting work is cancelled, the issue goes back to
TODOheld by you, and the agent’s session goes idle. - Dismiss — no further work now. The session goes idle; nothing else changes.
irreversible asks for confirmation first; none of the three does today. After a successful action the card flips to resolved without a reload and shows the receipt where the buttons were — the run the answer resumed, its position on the issue’s event log (event #<seq>, the same inbox_acted row GET .../events lists) and the agent version that answered. The receipt is read back from the card’s payload.receipt whenever the resolved card is opened from History.
Two 409s are not failures from your side: already acted on (someone closed the card first — the pane refreshes to show their decision) and could not be delivered (your answer is on the issue as a comment, but the agent’s session cannot take it — a held agent, an unconnected crew; the card stays open with the server’s reason so you can retry once that is fixed). Only run_needs_human cards have server-side actions; a waitpoint or an escalation is still decided through its own endpoint.
Receipts that name the version
A decision about a routine’s authored definition — not just its inbox card — writesroutine_version alongside the existing decision-record columns (decided_by/decided_at/payload on approvals_queue; decided_by_user_id/decided_at/decision_payload on pipeline_waitpoints), so “was it the same version that then ran?” is answerable without a separate receipts table (§9.8). Stamped by:
internal/api/internal_routines.go— an autonomy-held, agent-created schedule, at the pipeline’s pinned or head version at hold time.internal/pipeline/waitpoints.go— await: approvalstep, at the run’s actually-executing pipeline version.
NULL.
Where you can read it back differs by table. An approval row returns routine_version on GET /api/v1/approvals (omitted when NULL) and crewship approval list prints it beside the other decision fields. A waitpoint’s column is stored but not returned: the pending-waitpoints list omits it, and a decided waitpoint is not listed at all. For a waitpoint, the version before deciding is on the schedule-activation card’s payload.routine_version, and the version that then ran is pipeline_version on every run record (crewship routine records, Pipelines API) — compare those two if the question is “did the same version run”.
Digest
user_notification_prefs.state has allowed 'digest' since the notification-preferences schema shipped, but nothing ever wrote it (F30) — SUCCEEDED and NO_CHANGE outcomes create no inbox item on their own (the two hard rules of §12), so they were only ever visible in the Journal. internal/inbox.StartDigestScheduler closes that: every few hours it summarizes each workspace’s quiet runs from the last rolling day into one refreshed message-kind card (payload.subkind = "digest"), skipping any workspace with nothing to report. The card links View in Activity (/activity?status=done) — the Completed bucket of Activity over the same rolling day, where each of those runs can be opened. This is about what’s in the product inbox, not the separate (and still-unwritten) question of batching external channel delivery for users who set a category’s preference to digest — the same setting name, a different, larger piece of work.
Chat-reply items get their own place: in the Updates view they form the Replies & results section above everything else, and the open item carries an Open chat button that deep-links straight into the session — continue the conversation in one click; opening it clears the item.
Items are written by source-of-truth handlers, not synthesised by the inbox. So:
- Approving a waitpoint via
/inboxcascades back throughpipeline.waitpoints.CompleteApproval— the run resumes, the journal records the decision, and the inbox row flips toresolvedviainbox.ResolveBySource. - Retrying a failed run from
/inboxPOSTs to/api/v1/workspaces/{ws}/pipelines/{slug}/runwith the original inputs (captured at failure time on the row’spayload.inputs). The new run shows up in/activityimmediately. - Marking an escalation resolved on the inbox only flips the inbox state — the escalation itself lives in its own table and is closed via
crewship escalation resolve(or the escalation lifecycle API).
The inbox is a projection, not a queue. The authoritative state lives on the source row (waitpoint token, escalation id, pipeline_run). The inbox row exists to give you one place to see “is anything waiting on me right now?” and a one-click handle to resolve it.
States
The lifecycle is unread → read → resolved, surfaced through the three views (Needs action, Updates, History) with Gmail-style semantics: opening a row marks it read but keeps it in your inbox — it only leaves when you decide it or Archive it.- Needs action and Updates together show everything that isn’t resolved — unread and read. Clicking a row marks it read in place; it does not disappear. This is the fix for “I clicked a message, clicked away, and lost it.”
- Unread only is a filter, not the default. History is the resolved bucket, split into Decisions and Archived.
- Archive is the Gmail move: clear a row from the inbox without recording a decision. It maps to
resolvedwithresolved_action="archived", and is restorable from History — Restore in the item’s menu — or withcrewship inbox unread <id>. Archive is offered on non-decision rows (messages, failed-run notices, advisories) and on keeper-synthetic escalations (skill review / memory health) — those have no backing escalations row and thus no source endpoint, so the inbox row is the only handle. A real waitpoint/agent escalation is source-managed and still needs an explicit Approve/Deny/Resolve. - Resolving via an action button (Approve, Deny, Retry, Cancel, Dismiss) stores the shape on
resolved_actionso the audit trail shows what you decided, not just that you closed it. - “Mark unread” flips a row back; “Restore” un-archives. The bell count and sidebar badge both react.
Read vs. unread is per user; resolved is shared. A workspace- or
role-targeted item (e.g. addressed to every MANAGER) shows
unread for
each recipient until they themselves open it — one manager clicking a row
does not mark it read for the others. Resolved is different: it’s a
workspace-shared outcome, so once anyone resolves an item (Approve, Deny,
Archive, …) it’s resolved for everyone. See Reading is per-user for the API-level detail.The /inbox page
Two columns, on the same explorer shell Routines, Issues and Crews use:
The page header reads N need you · N updates · N in history with a Live pill while the realtime socket is connected. When one of the sources (inbox events, approval gates, mission signals) cannot be read, a banner says the inbox may be incomplete and offers Retry — the pane never pretends an empty feed means nothing is waiting.
Views. Needs action is every row waiting on a person: inbox decisions, pending approval-gate rows (owners and admins only —
GET /api/v1/approvals needs that role, so other members simply see no gate rows) and issue tasks that are blocked or ready for review. Updates holds the rest of what is open in two sections, Replies & results (your agents’ chat replies) and Important updates; a Mark all read action sits on the section header. History holds resolved rows under Decisions and Archived. The page opens on Needs action when anything is waiting there, otherwise on Updates, and a live arrival never switches the view you are reading.
Filter holds three facets, each option with its count: Type (the seven inbox kinds plus Approval gate and Mission signal), Deadline (Within the hour / Today / No deadline) and Unread only. Whatever is applied shows as a removable chip under the toolbar, and a filtered list that matches nothing offers Clear. Search matches the title, the body, and agent and crew names. The URL carries the state — ?view=action|updates|history, ?kind=<type>, ?item=<id> (an inbox item or an approval id) and ?agent=<name> for a search — so a link from the bell, a chat or a run opens the right row.
Every waitpoint carries timeout_at, and both the row and the decision card count down to it — in minutes, then hours, then days — because a day-long default rendered as “1428m” is a figure nobody converts in their head.
Decisions are gated by the role the server enforces, not by what the UI feels like offering. /api/v1/workspaces/{ws}/pipelines/waitpoints/{token}/approve and the escalation lifecycle are roleCreate (MANAGER and up); /api/v1/skills/proposed/approve and /api/v1/consolidate/proposed/{id}/approve are roleManage (OWNER/ADMIN only). When the caller cannot decide, the card names who can (“An owner or admin decides this”, never a role enum) instead of handing them a button that answers 403 — and the rows themselves are addressed to the role that can act, a pairing TestInboxTargetRoleMatchesDecider reads out of the source and requires to agree.
The reading pane
The detail names things. The header is the sender — the agent’s face and name (a link to the agent), the crew (icon and name, a link) and the arrival time — with a ⋯ menu for Mark unread, Archive or Restore. Below it a decision opens on its decision card: the kind pill, the attention badge, the question without the server’s “Agent escalation:” prefix, what is being asked for, and the buttons. There are no cuids and no field captions; identifiers become a Where this came from row of links — the chat the escalation was raised in, the run it paused, the issue, the routine, the crew. A LINK escalation shows What you are approving — the https address, its host and an Open link button; a credential proposal shows the credential’s name. Then, collapsed: How the run got here (aWaitpointRunDetail block showing which step is paused, waitpoints only) and Additional context — the payload as a humanised key/value card, with credential-looking values masked behind a reveal toggle.
The action row is what the source-of-truth call hangs off:
- Waitpoints show Approve + Deny. Both hit
/api/v1/workspaces/{ws}/pipelines/waitpoints/{token}/approve; the booleanapprovedin the body disambiguates. (An empty body decoded toapproved=falsebecause Go’sjson.Unmarshalgives bools their zero value when absent — the inbox always sends the explicit value.) A staged hire additionally gets Approve hire, Deny and Open crew — see Approving an ephemeral hire. - Approval-gate rows (the
/approvalsqueue, merged into the same feed) show Approve + Deny with an optional comment for the audit record; a decided row shows its outcome, who decided (“by you” / “by a person”) and when. - Failed runs show Retry + Cancel. Retry replays the routine’s
inputs_json. If the payload is missing apipeline_slugfor some reason, the row falls back to “cancelled” rather than getting wedged. - Escalations split two ways. A real agent escalation (carries an
escalation_typeand a backingescalationsrow) shows Approve / Reject — the decision is recorded through the escalation lifecycle, and the inbox just mirrors it. A keeper-synthetic escalation (skill review, memory health, behaviour verdicts —kind=escalationbut no backing row, so no/escalations/{id}/resolveendpoint) has no inline decision; the detail points you at Archive to clear it. These are the one escalation type the inbox lets you dismiss directly, because there is no source to defer to. - Messages with a chat deep link show Open chat; with an
issue_identifierin payload they show Open <identifier> as a direct link into/issues/<id>.
sender_type=system (“Keeper”, “Skill Curator”) while carrying agent_name in the payload, so keying identity off the sender alone put a system glyph on “casey requested GH_TOKEN”. Identity follows one rule everywhere — a square is a machine, a circle is a person — which matters most in History, where casey (agent) asked and pavel (user) approved sit in the same row.
The inbox is the one surface that aggregates messages from every agent, routine, and curator — so it’s also where raw machine output and leaked secrets would otherwise pile up. Several guards keep it human-readable and safe:
- Sender avatars. When the sender is a real agent, the row and detail header render that agent’s actual avatar — the same DiceBear seed/style its agent card uses, so “casey escalated…” reads as Casey’s face. The List/Get responses fill
avatar_seed+avatar_stylefor agent senders via a batched lookup against theagentstable (no schema change, works retroactively). Non-agent senders (crew / pipeline / system) fall back to a coloured glyph keyed onsender_type, so an agent message stays visually distinct from a routine failure. - Rendered bodies, not blobs.
body_mdis rendered as markdown (MarkdownContent), and thepayloadis shown as a humanised key/value Additional context card instead of a rawJSON.stringifydump. - Sanitised advisories. Curator routines (skill review, memory health) used to paste the gatekeeper’s internal decision text — e.g.
Keeper LLM unavailable: paymaster: workspace_id required — deny by default— straight into the body.inbox.SanitizeReasonnow swaps that for a friendly line and keeps the raw text inpayload.raw_reasonfor operators. When the cause is an infrastructure outage (curator LLM down), a pure advisory is suppressed entirely rather than written once per crew per sweep — this killed the recurring “Memory health advisory” flood. - Secret redaction.
inbox.RedactSecretsmasks credential material (connection strings,password=…, bearer tokens, high-entropy blobs) before it reachesbody_md, so an agent escalation that quotes a generatedredis://:…@…URL never broadcasts the secret to every MANAGER. CREDENTIAL-type escalations additionally lead with an explicit “secret handled in the credential flow” note. The source-of-truth row (escalations / credentials table) still holds the real value behind its own ACL; the UI also masks any remaining secret-looking Context values behind a reveal toggle.
Grouped notices and bulk operations
Advisory escalations without anescalation_type (curator sweeps, service incidents) are grouped in Updates: one row per sender and title prefix, opening on a Grouped system incident card that lists the related notices and offers Archive N updates plus a link to System Health. That button, and Mark all read on the Updates section, are the two callers of POST /api/v1/inbox/bulk — there are no checkboxes or per-row multi-select on the page; a decision item is still resolved one at a time from its own card.
A row whose kind is an unknown value renders as a generic notification (hardened fallback) instead of crashing the row — new server-side kinds degrade gracefully in an older UI.
Realtime
The list is refreshed by two WebSocket events on the workspace channel:
The
useInbox hook (hooks/use-inbox.ts) listens to both and re-issues the same workspace-scoped GET on each. There is no dedicated event for waitpoint creation; the bell badge uses useInboxUnreadCount which adds a 30-second poll alongside the same realtime listeners, and that poll is what surfaces a newly-parked waitpoint within the next tick.
The badge debounces correctly across tabs: an approve from your laptop clears the bell on your phone within a few hundred milliseconds because both subscribe to the same
inbox.updated workspace event.CLI
The Inbox has full CLI parity — everything the web surface exposes is scriptable. Same items, same lifecycle, same kind taxonomy. The contract is “anything an operator can do in the UI must be pipe-glueable”, so cron-driven Slack pings or CI gates on unread waitpoints are first-class.
Examples:
Permissions
Inbox items are user-scoped via three target fields on the row:inboxVisibilityClause in internal/api/inbox_handler.go enforces the predicate on every read endpoint (List, UnreadCount, PatchState). Admins do not automatically see other users’ targeted items — least privilege wins over convenience, the same way Linear’s own Inbox works.
If you genuinely need a workspace-wide view of who has what pending — say, to audit whether a payroll-grade routine has been sitting on someone’s bell all weekend — use a privileged data path: query the inbox_items table directly (admin-side SQL, bypassing inboxVisibilityClause) or the pipeline_waitpoints table for gate-side state. crewship inbox list --state all is still user-scoped — --state only widens the kind filter, it does not lift the ACL — so the CLI alone will not surface items targeted at other users. The journal records pipeline.run.failed + pipeline.step.validation_failed + escalation.created for related signals, but waitpoint creation itself lives on the pipeline_waitpoints row, not in the journal stream.
What this replaces
Before the IA refactor, the same signals were scattered across:- A modal-style Approvals queue at
/approvals(waitpoints only) - Per-mission notifications on
/missions/<id>(escalations, message-style nudges) - A failed-run banner that lived inside the run detail panel
- Top-bar bell that only showed the count, not the list
/approvals still exists for the workspace-admin “show me every pending waitpoint workspace-wide, including ones not targeted at me” use case, but the daily-driver surface is /inbox.
Approving an ephemeral hire
When an operator (or a LEAD agent via the sidecar/spawn endpoint) runs crewship hire against a guided-autonomy crew, the agent row is created with status='PENDING_REVIEW' instead of IDLE. The chatbridge refuses to start the agent in this state — see internal/chatbridge/bridge.go. A waitpoint-kind inbox row lands at the same time so the operator has one place to approve.
The card in the inbox UI (kind pill Hire) shows the agent, the crew it was hired into, the reason given, and three buttons: Approve hire, Deny and Open crew.
The Approve hire button calls POST /api/v1/agents/{agentId}/approve-hire. The handler does four things atomically:
UPDATE agents SET status='IDLE' WHERE id=? AND status='PENDING_REVIEW'— guarded WHERE so a concurrent approve (zero rows affected) returns 409 rather than silently writing nothing; a soft-deleted/missing agent already 404s at the preceding loadinbox.ResolveBySource— flips this inbox row toresolvedso it doesn’t reappear- Journal entry
agent.hire_approvedwith the actor’s user_id - WebSocket broadcast
agent.hire_approvedso the agent canvas + crew roster repaint instantly
kind=ephemeral_hire row in the approvals queue whose payload names the inbox item. The inbox finds that queue twin and wires its deny to the card, so pressing Deny calls POST /api/v1/approvals/{id}/decide with denied (the same call as crewship approvals deny). The staged agent ghosts immediately (expired_at = now, the terminal state the TTL sweeper writes — the audit trail stays and the crew’s ephemeral slot frees up; crewship rehire can bring it back), and the waitpoint resolves with action=denied. Both transitions are conditional on status='PENDING_REVIEW', so a decision racing the legacy hire approve path cannot clobber an already-live agent.
When the page cannot find the twin — an older server, or the queue row has not arrived yet — the card has no Deny and instead says To deny, fire the agent from its crew page, with the crew linked: firing (DELETE /api/v1/agents/{id}) is the same path as deleting any agent.
Network-failure handling
The Approve button’sfetch() is wrapped in try/catch. An offline / DNS / CORS preflight failure surfaces a toast (Approve failed: <network error>) instead of silently clearing the busy state — operator knows immediately that the request didn’t land and can retry without re-typing the action. Same pattern is applied to the retry-routine button (wrap("retried")).
Keeper Phase 2 inbox rows
The four F4 evaluators route every DENY or ESCALATE decision to the inbox (PR-C). Each row shows:- The evaluator type (Skill Review / Behavior / Memory Health / Negative Learning) in the header
- The proposed action (“Recommend archiving skill X”, “Block tool_name=shell_exec”, “Auto-consolidate workspace memory”, “Land lesson ‘check env vars before deploy’”)
- A risk score badge (0-10 colour-coded)
- An expanded section with the LLM’s full rationale (
reason) and the agent / crew context
- Approve lands the proposed action and writes a journal entry recording the decision
- Reject discards the proposal and records the dismissal; the evaluator gets the rejection as a
prior_lessonhint on its next run for the same context (dup-suppression — see F4.4 negative-learning)
Self-learning gated proposals
When an agent hasself_learning_enabled=0 (the default), the F4.4 negative-learning evaluator’s ALLOW path and the F6 persona-suggest gate-demoted path BOTH route through the inbox as blocking rows. The marker "self_learning_gate": "off" in the row’s payload_json distinguishes these from policy-routed inbox rows.
The row presents the proposed change verbatim:
lessons.md via consolidate.WriteLesson (PR-Z Z.7) — the same writer the auto-apply path uses, with the same schema + idempotency + flock contract. Rejection discards the proposal and records the dismissal so the same lesson doesn’t re-propose on the next failure.
Operators who don’t want to gate every proposal can flip self_learning_enabled=1 on the agent via the Agent canvas → Settings → Learning posture panel. See Autonomy + self-learning for the full posture-vs-policy semantics.
What’s next
- Routines — the workflow recipes whose
waitsteps create waitpoints - Harbormaster — the approval-gate framing for high-risk actions
- Activity — the live trace view where inline Approve/Deny on a paused step does the same thing as the Inbox’s Approve button
- Ephemeral agents — the hire/rehire lifecycle that creates the
pending-reviewinbox rows - Keeper Reviews panel — the dedicated triage surface for F4 evaluator decisions
- Autonomy + self-learning — the per-agent flag that gates F4.4 + F6 proposals through this inbox