Skip to main content

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 to inbox_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 one routine save --draft) share a thread_key and 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 of decision | input | review | repair, naming what kind of ask this is, independent of kind (which names the producer).
  • actions[] — the permitted, named things a recipient can do about this row: {"id", "label", "effect", "irreversible"}. A merged card’s actions[] is the union of every producer’s own actions, so a routine that is both risky and carries a draft trigger offers approve_routine / reject_routine and activate_trigger / dismiss_trigger on the same card.
The write-through helper every attention-contract producer shares is 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.
In the web inbox, 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

A run_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 TODO held by you, and the agent’s session goes idle.
  • Dismiss — no further work now. The session goes idle; nothing else changes.
An action the server marks 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 — writes routine_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 — a wait: approval step, at the run’s actually-executing pipeline version.
Both columns are nullable: most approvals and waitpoints (a credential use, an ordinary production-change gate) have nothing to do with a routine’s definition and stay 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 /inbox cascades back through pipeline.waitpoints.CompleteApproval — the run resumes, the journal records the decision, and the inbox row flips to resolved via inbox.ResolveBySource.
  • Retrying a failed run from /inbox POSTs to /api/v1/workspaces/{ws}/pipelines/{slug}/run with the original inputs (captured at failure time on the row’s payload.inputs). The new run shows up in /activity immediately.
  • 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 resolved with resolved_action="archived", and is restorable from History — Restore in the item’s menu — or with crewship 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_action so 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 (a WaitpointRunDetail 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 boolean approved in the body disambiguates. (An empty body decoded to approved=false because Go’s json.Unmarshal gives 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 /approvals queue, 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 a pipeline_slug for some reason, the row falls back to “cancelled” rather than getting wedged.
  • Escalations split two ways. A real agent escalation (carries an escalation_type and a backing escalations row) 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=escalation but no backing row, so no /escalations/{id}/resolve endpoint) 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_identifier in payload they show Open <identifier> as a direct link into /issues/&lt;id&gt;.
After a decision the pane replaces itself with a confirmation card — the outcome (“Approved”, “Retry started”, “Schedule re-enabled”, …), the item’s title and “the record stays in History” — with Next to review · N when more decisions are waiting, View record and Back to inbox. The decided row moves to History; opened from there, a resolved item dims and its footer reads “Resolved 5m ago · approved” (or “Archived …”, with Restore in the menu). A deep link to a row that has been decided elsewhere, archived or belongs to another workspace says so — That item is no longer in your inbox — rather than opening the first row in the list with an armed Approve button. The subject is who the row is ABOUT, not who sent it: ten producers write their rows as 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_style for agent senders via a batched lookup against the agents table (no schema change, works retroactively). Non-agent senders (crew / pipeline / system) fall back to a coloured glyph keyed on sender_type, so an agent message stays visually distinct from a routine failure.
  • Rendered bodies, not blobs. body_md is rendered as markdown (MarkdownContent), and the payload is shown as a humanised key/value Additional context card instead of a raw JSON.stringify dump.
  • 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.SanitizeReason now swaps that for a friendly line and keeps the raw text in payload.raw_reason for 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.RedactSecrets masks credential material (connection strings, password=…, bearer tokens, high-entropy blobs) before it reaches body_md, so an agent escalation that quotes a generated redis://:…@… 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 an escalation_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.
Bulk Resolve never silently closes a decision item. If the selection contains any waitpoint / escalation row or any blocking=true row, a confirmation dialog spells out how many items will be left open and resolves only the dismissable remainder. If the entire selection is decision items, there is no Resolve action at all — open each one individually and act on its source. Mark read is always one click and closes nothing. Even if a client bypasses these guards, the server backstop skips the same rows, so a real decision item can never be resolved by the bulk path. The one exception, applied identically client- and server-side, is a row whose source is gone: a keeper-synthetic escalation (kind=escalation, no backing escalations row), or a waitpoint whose pending pipeline_waitpoints token, staged PENDING_REVIEW hire or pending approval has already been swept. Those have no source left to defer to, so bulk Resolve (and a single-row resolve) clears them instead of skipping them; the detail read exposes the same probe as source_missing.
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.
GET /api/v1/inbox answers limit (100 by default, 500 max) and offset with no cursor. The web page walks the whole list in pages of 500 on load, so History is complete but grows with the workspace; a script that reads one page sees the most recent window only, and any count it computes describes that window. The unread count on the bell comes from /inbox/count, not from the loaded rows.

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:
crewship inbox resolve only flips the inbox row. It does not call the source endpoint — approving a waitpoint through to the executor requires crewship approvals approve <id>, and resolving an escalation in its own lifecycle requires crewship escalation resolve <id>. The inbox CLI is the read + acknowledge surface; the source CLIs are the act-on-it surface.

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
The Inbox folds all four into one feed. /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:
  1. 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 load
  2. inbox.ResolveBySource — flips this inbox row to resolved so it doesn’t reappear
  3. Journal entry agent.hire_approved with the actor’s user_id
  4. WebSocket broadcast agent.hire_approved so the agent canvas + crew roster repaint instantly
Deny goes through the other half of the same decision: a staged hire is one decision in two places — this waitpoint and a 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’s fetch() 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
Operators see the row, decide, and click Approve or Reject:
  • 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_lesson hint on its next run for the same context (dup-suppression — see F4.4 negative-learning)
For the full operator workflow on these rows — when to approve, what to investigate, how to override an evaluator — see Keeper Reviews panel which is the dedicated triage surface (the inbox shows individual rows; the panel shows the queue).

Self-learning gated proposals

When an agent has self_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:
Approval lands the lesson into the agent’s 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 wait steps 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-review inbox 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