---
title: Inbox alerts (how an agent gets your attention)
---

# Inbox Alerts (agent-sourced persistent inbox rows)

## What it is

**Inbox Alerts** is a primitive that lets any Claude Code session (or the CLI control server) drop a persistent inbox row on demand via `POST /alert`. Unlike ephemeral notifications that vanish after display, an alert survives restarts, syncs across sessions, and lives in the inbox until the user archives it or it expires via snooze.

Alerts are **on by default** — the feature exists so any agent can surface **out-of-band** attention the user would otherwise miss (a background job, a failure in a session they've moved on from, a decision left after the work finished). It is **not** for repeating what you already say in a session's final message — that just double-shows the same thing in two places. A user who doesn't want agents posting cards turns that off at Settings → Features → **Allow agents to raise inbox alerts** (`agentAlertsEnabled`). That switch closes only the AGENT door: `POST /alert` answers `403` (an owner-critical fleet page still goes out), a plugin's inbox card is not posted, and a card the spool was holding for later replay is set aside in the spool’s dead-letter file instead of delivered. It never hides the cards Omniscio itself raises — the Alerts section of the inbox is always shown. (Until 2026-09-28 it hid every card, the app's own included, while agents' spooled cards still came back through the replay.)

**One door, every rule.** Every card — whether an agent posted it, a scheduled script spooled it, or Omniscio raised it in-process — is written by one function, `createAlert`, and the delivery rules run inside it: the per-type mute, the first-day hold, the operator-only and developer-only drops, the cross-device "another machine owns this session" drop, the settling and digest holds, the owner-critical phone page, the cloud self-heal fixer, the team-chat bridge, and write-failure escalation. Every door that writes a card also runs it past the **Overseer's batch-and-judge hold** (urgent, scheduled and awaited cards are never held) and stays quiet while it is held. Before 2026-09-28 the phone page, the fixer and the bridge lived in the `raiseAgentAlert` wrapper and the CLI route, so a producer that called `createAlert` directly skipped them.

Omniscio itself uses the same primitive for a handful of built-in advisories — the low-memory warnings; since 2026-07-04, the **"Mobile is loading slowly"** card (raised when the median of the last 10 phone loads exceeds 3.5 s; once per episode, at most one card per 24 h, standard Start-session button; see [mobile-perf-prevention.md](mobile-perf-prevention.md)); and the **"cleaned up stuck dependency installs"** notice (the Install Orphan Reaper's — see below). None of them reads `agentAlertsEnabled` — that switch governs only agents and plugins, and a build guard keeps it that way. A **backup-failure card** is also raised for each off-machine backup — Setup Backup to Gmail after 3 consecutive send failures, Backup Mirror on any unresolved auto-sync push failure — so a broken off-site backup surfaces in the inbox instead of silently failing; it self-clears once a backup succeeds (`backup-failure-alert-contract.md`).

The dedicated **"Alerts" virtual project is hidden from the sidebar by default** (`alertsSidebarEnabled`, off). Its list of active alerts lives in **Settings → Notifications → Active alerts**, where a **"Show Alerts in sidebar"** switch flips the sidebar row back on. Sidebar visibility is a pure UI choice — independent of `agentAlertsEnabled` — and alerts still surface in the unified inbox either way (see [Inbox behaviour](#inbox-behaviour)).

## Where to find it

In the **inbox**, where an alert appears as a card among your sessions and messages, and in the **Alerts** screen for a wider view including recently dismissed ones. The switch for turning a built-in notice off sits on the card itself, and the per-type controls are in **Settings → Notifications**.

## How it behaves

### Turning an alert type off

Every kind of alert this app can raise has its own off switch. There are 688 of them, and each is
declared in one place with a plain-language name, a category, and whether it may be silenced.

**Two ways to turn one off:**

- **From the card.** Nearly every alert card carries a quiet "Turn off these alerts" button. This is
  the usual path — you turn something off at the moment you decide you are done with it. The one
  exception is a card you can reply to directly: its bottom holds the reply box, that card's own
  action, and nothing else, so it carries no mute. Turn those types off in Settings → Notifications
  → Alert types instead.
- **From Settings → Notifications → Alert types.** A browsable list of every type, grouped by
  category and searchable by name or internal key. Each row shows how often that type has actually
  fired on this machine, which is usually what decides it: "never seen on this computer" and "seen
  12 times, yesterday" are very different cases. This is also where you turn one back on.

**The list only shows alerts that can actually reach you.** A large block of alert types — around
180 of them — report on the shared cloud fleet and are raised _only_ on the one computer designated
as its operator; everywhere else they are discarded before anything is written. Those are left out
of this list rather than shown as switches that would control nothing, and the card says at the
bottom how many were left out and why. On the operator machine itself they do appear, each tagged
**Operator only**, so the one person who sees both kinds in one list can tell them apart at a glance.

**Two smaller blocks are filtered for the same reason, from the other end.**

- **Alerts about a development checkout** — the auto-lander, the worktree sweeper, master sync, the
  post-land build check, cloud machines this install started. Each is declared **developer-only**: it
  is dropped when Omniscio runs as an installed build, and delivered when it runs from source. So the
  developer running the checkout still gets their own machine's alerts, and nobody else gets a card
  about machinery they do not have.
- **Some types ship switched off.** These report the app's own internals — crash reporting, telemetry
  pipelines, internal startup and IPC faults — where there is nothing for a reader to do. They are
  real alert types with a real switch, and the switch is simply **off when you first see it**: turn
  one on from this list and it behaves like any other. The card at the bottom of the list is your
  window into what is currently off.
See `alert-type-registry-contract.md`
(`only-what-can-fire-here-is-listed`, `a-cloud-fleet-row-declares-its-delivery-scope`).

**It asks first, and tells you the real scope.** Most switches cover a whole FAMILY of alerts, not
the one card you are looking at — so the button opens a confirmation that spells out what you are
about to lose before anything changes. It names the reach ("every future _Calendar event added_
alert — not just this one card"), says the feature behind it keeps running and you just stop being
told, lists the related alerts that stay on, and points at where to turn it back on. Cancel changes
nothing and leaves the card where it was. The blunt "Turn off desktop notifications" button asks the
same way, and makes the important part clear: nothing is lost, everything still arrives in your
inbox, it just stops popping up over your other windows.

**What "off" actually does.** The alert is dropped at the single point every alert passes through,
before anything is written. That is what makes it cover alerts raised by background scripts and
scheduled jobs over the local HTTP route, not just the ones raised inside the app — no producer has
to remember to check.

**Muting stops the card, never the cure.** Eighteen alert types automatically start a session to
repair what they are warning about. Turning one of those off still lets the repair run; you just
stop being told about it.

**A few cannot be turned off**, and they say why. Sign-in expired, backup failures, critically low
memory, low disk, and below-minimum hardware all show a disabled switch with its consequence
("silencing this would hide a backup problem until you needed the backup"), rather than simply not
appearing. Each one is a way to lose the account, the data, or the machine — not a preference.

**If anything goes wrong with the switch, you still get the alert.** An alert type nobody has
described yet, an unreadable preference, a type declared un-silenceable — all deliver. An off switch
must never fail toward silence.

**Alerts that have fired here but are not listed** appear in a footer on that Settings screen,
saying plainly that they cannot be turned off yet. A screen claiming to list every kind of alert
while quietly omitting some would be lying.

### Alert types

Each alert has a `contentType` discriminator:

- **text** — free-form prose or Markdown. **Every alert renders its text through the same renderer as a chat message** — whether an agent or Omniscio itself raised it, live or archived, on desktop, phone or Simple Mode (one shared component, `AlertTextBody`) — so headings, lists, bold, inline code, links (including a tappable `omniscio://session/…` deep link) and images work as they do in the transcript. A question written in the standard chat shape renders as an **answerable question widget** on a card whose raising session can still receive the answer, and the answer is delivered back to that session; a question-shaped body posted with **no** session to answer it is refused at the door rather than delivered, so a question can never land as text nobody can act on. See [Answerable cards](#answerable-cards-the-standard). An agent-raised card draws that text inside the **same bubble a chat message uses** — one shared frame both surfaces compose — so the card reads as a message rather than as card prose. **Every newline in `text` is drawn as a line break and nothing rejoins wrapped lines**, so write each paragraph on ONE line, put a blank line between paragraphs, and fence pasted logs or command output in a code block — a sentence hard-wrapped in the source shows as a broken line on screen (inbox-alert-contract `alert-text-is-chat-markdown`).
- **link** — URL with optional og: metadata (`linkTitle` / `linkDescription` / `linkImageUrl`) rendered as a preview card.
- **file** — attachment stored under `userData/alerts/<alertId>/`. An **image file renders inline** (the picture itself, not a pill — see [Inline image alerts](#inline-image-alerts)); an **HTML file renders live as a styled page** (see [Rich HTML alerts](#rich-html-alerts)); any other file renders as a name/mime/size pill.
- **embed** — a **hosted Omniscio Shares page** rendered **live inline** in an opaque-origin sandbox (see [Embedded Shares pages](#embedded-shares-pages)). The page URL rides `text` (like `link`) and is fail-closed to a genuine Shares-origin `/s/<token>` URL.

### Answerable cards: the standard

**If a card exists because an agent needs a decision, the question goes on the card.** An agent that
asks in prose and waits for a reply makes the reader leave what they were doing, find the session, and
type the answer back. A question written in the card body in the standard chat shape renders as an
**answerable widget**: the reader picks an option (or types an answer) and submits, and the answer is
delivered into the session that raised the card — which carries on from there.

```markdown
**Approve the engine-parity contract?**

- **A.** Approve
- **B.** Reject
- **C.** Explain what changes first
```

**A question that nobody can answer is refused, not delivered.** The answer has to go somewhere, so
a card can only offer it when it has a session to hand it back to: a question-shaped body posted with
no source session is rejected outright (`400`, and no card is raised) instead of arriving as a
question with nothing to answer it with. The refusal names the missing piece — post it from the
session that wants the answer. Nothing else changed: an ordinary report posted with no session, which
is how every background monitor posts, delivers exactly as before.

- **A question needs its options.** The widget fires only on a question carrying at least two lettered
  options (`- **A.** …`). A bare `**Question?**` renders as ordinary prose — a question the reader
  cannot act on.
- **The raiser names no destination.** The card knows which session raised it; answers go there.
- **Every agent-raised card also carries the chat bar.** Under the bubble sits the same composer the
  session has — the attachment button, quick replies, dictation and the split Send with its ▾ Send
  Later menu — so a reader answers a card the way they would answer in the session, and a file
  attached there rides the same send. It is present whether or not the body also carries a question:
  a chat message with a question still has its composer beneath it, and the answer is one-shot
  either way, so using both paths delivers once.
- **The reply box is pinned to the bottom of the card** and does not scroll away: the message
  scrolls above it, however long or short that message is. Beside the reply box that card keeps its
  own action and the one-click "Go to Session", and carries neither "Start session" nor "Turn off
  these alerts" — the card you answer directly is the one card whose bottom belongs to the answer.
- **A stopped session is resumed** by an answer, so the agent that asked is woken with the reply.
- **Answering takes you to the next card, not to a spinner.** The moment you submit — Send, Enter, or
  the question widget's own button — the card clears and you are on the next item in your inbox. The
  answer goes out behind you: an answer takes a second or two to reach a running session and longer
  still when it has to wake a stopped one, and none of that is time you spend looking at a card you
  have already dealt with.
- **If an answer cannot be delivered, the card comes back — with your answer in it.** Nothing reached
  the session, so the card returns to your inbox saying the send failed, and whatever you had typed
  or attached is still in the box. Press Send again; nothing has to be retyped.
- **The interactive path needs the question-widget setting on.** With it off, the question renders as
  prose and the card stays readable.
- **A purely informational card should carry no question** — an unneeded question costs the reader a
  tap. Its body still renders as markdown.
- **The card names its sender once, inside the message.** An agent-raised **text** card draws its
  body in the same chat bubble a message uses, and the sender rides in that bubble's own header row:
  the kind glyph, the session link, and the timestamp — the way a chat message carries them. There
  is no separate “Updated {time}” pill and no “Generated by” line above the message, because those
  repeated what the pane's title already said and cost roughly 75px at the top of a phone screen.
  It holds whether or not the raising session still resolves: a reaped raiser keeps its message and
  shows a short-id link, and what it loses is the ANSWER path (no reply box), never the shape of the
  message. An unresolved raiser therefore still names where the card came from.

### Rich HTML alerts

A `file` alert whose attachment is an **HTML page** (name ends `.html` / `.htm`, or `text/html` mime) renders **live as a styled page** the moment you open the alert — like opening an email — instead of the name/size pill. This is how an agent turns a report (a trending-repos digest, a build summary, a chart) into a formatted page in the inbox rather than plain Markdown.

It **reuses Drip's HTML-preview stack verbatim** — the same `serveDripPreview` security core, the same sandboxed webview (desktop) / opaque-origin iframe (mobile), the same contained CSP — so the containment guarantees are identical:

- **Self-contained only.** The sandbox is **no-network** (`connect-src 'none'`; no external fetch / script / frame) — the agent must inline everything: CSS in a `<style>` block or `style=` attributes, images as `data:` URIs. An external `<link>` / `<img src="https://…">` is blocked, so the page must not depend on one.
- **Isolated.** Opaque origin, no same-origin access, no reach into Omniscio internals or your token — a hostile page can display but can't touch anything.
- **Size.** The ~750 KB alert-file ceiling applies first at post time (a larger file is rejected before it's stored); the preview stack additionally enforces the shared 5 MB `DRIP_HTML_MAX_BYTES`.
- **Kill switch.** One shared **`dripHtmlPreviewEnabled`** setting (on by default) governs both Drip and agent HTML alerts; off → the alert falls back to the plain file pill.
- **On a phone the page gets the whole pane.** The frame fills the detail view instead of standing as a fixed-height card, and the pane's own “Updated …” / “Generated by …” lines are not drawn above it. A live preview is a separate scroller, so anything stacked over it can never be swiped out of the way — it would simply eat the top of the screen for good. Those lines still show on desktop (in the pinned header) and on a phone for every card whose body scrolls with the pane — except an agent-raised **text** card, which carries no such lines at all (its sender row is inside the message). See `inbox-alert-contract.md` (I22).

Only `source_kind = 'agent'` alerts take this path (a drip-sourced row resolves via Drip's own lookup first). See `inbox-alert-contract.md` (I17) and `drip-html-preview-contract.md`.

### Inline image alerts

A `file` alert whose attachment is an **image** (`fileMime` starts with `image/`) renders the **picture inline** in the alert view — the same way a released Drip image item does — instead of the name/size pill. This is how an agent delivers a screenshot, a chart, or a rendered card (e.g. an X "tweet card" captured as an image) straight into the inbox.

- **How.** On open, the alert view reads the image's bytes via the `ALERT_ITEM_FILE_READ` IPC channel — scoped to the alert's OWN file (`source_kind = 'agent'` + an `image/*` mime, read through the alerts-root-scoped `readAlertFile`) — and shows them as a `data:` URL. It shares ONE inline-image component with Drip (`InboxInlineImage`) — the image mirror of the shared HTML preview — behind the thin `AlertInlineImage` wrapper.
- **Reachable on your phone**, like Drip images (the channel is on the mobile WS-bridge allow-list).
- **Fallback.** A reaped / unreadable / non-decodable image falls back to the plain file pill — never a broken image.
- **No setting, no network.** Image alerts ALWAYS render inline — there is no toggle (only HTML has the `dripHtmlPreviewEnabled` gate). The bytes are read locally; nothing is fetched. An `<img>` can't execute a script, so even an odd image type (e.g. SVG) is inert.

Only `source_kind = 'agent'` alerts take this path. See `inbox-alert-contract.md` (I27).

### Embedded Shares pages

An **embed** alert (`contentType: "embed"`) carries the URL of a **hosted Omniscio Share** — an `https://shares.omniscio.com/s/<token>` page you already published (see [share-cli.md](share-cli.md)) — and renders it **live inline** in the alert view, in the same opaque-origin sandbox a developer broadcast uses (`BroadcastEmbedCard`). This is how an agent surfaces a **full published Share** (a dashboard, chart, or interactive report — unlimited size) in the inbox, versus inlining a self-contained `.html` file (the `file` path above, capped ~750 KB).

- **Fail-closed to the Shares origin.** The page URL rides the wire `text` field and is validated at post time by `isValidEmbedUrl` — only a genuine `https://shares.omniscio.com/s/<token>` URL is accepted; an off-origin, wrong-scheme, or non-`/s/<token>` URL is rejected with a `400`, so a leaked CLI token can never embed an arbitrary page in the trusted inbox. The renderer independently falls back to a plain text card if a stored URL is ever invalid.
- **Sandboxed like every embed.** Opaque origin, `allow-scripts` but **no** `allow-same-origin` — the page's JS and forms run, but it can't read your cookies, storage, or token, or navigate the app; the height is clamped so one card can't take over the inbox.
- **Optional heading.** Set `linkTitle` for the card's heading; omit it and the heading defaults to the alert `title` (parity with a developer-broadcast embed).

```bash
# Publish an artifact to Shares first (see share-cli.md), then embed its share URL:
curl -X POST http://127.0.0.1:19519/alert \
  -H "Authorization: Bearer $AMC_CLI_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Weekly metrics","contentType":"embed","text":"https://shares.omniscio.com/s/<token>"}'
```

See `inbox-alert-contract.md` and `src/shared/broadcast-embed.ts`.

### Deduplication

Supply a `dedupKey` to group related alerts into a single row with a ×N badge. On each new alert with the same `dedupKey`, the existing row's `dedupCount` is incremented and its `updatedAt` refreshed — a single row stays at the top of the inbox rather than accumulating duplicate rows.

### The limits on raising and withdrawing one

Four numbers govern the CLI surface an agent uses, and each is worth knowing before you build a
detector on top of it.

- **A source may raise at most 60 alerts a minute.** Past that the alert is **dropped** — it is not
  created, and the call is refused with `429`. One runaway loop therefore cannot flood your inbox.
- **Listing stops at 1000.** `GET /alert` returns at most **1000** active cards and says nothing
  about the truncation, so a listing of exactly 1000 means there are more behind it.
- **`POST /alert/clear` withdraws a card; a human's Archive is the same disposition.** A detector
  that raised a card by `dedupKey` calls `clear` with that key as soon as the condition it warned
  about is gone. It **archives** the row rather than deleting it — recoverable, still visible to
  the dismissed-window reader, exactly what clicking **Archive** produces. The two differ only in
  who acts: a person archives a card they have read, a detector clears a card whose problem fixed
  itself, whether or not anyone ever looked at it. "Nothing to clear" is a **success**, not an
  error — a detector calls this on every clean tick, so no matching card answers
  `200 { cleared: false }` — which is what makes it safe to call unconditionally.
- **`DELETE /alert/:id` is the one irreversible call here.** It removes the card permanently: there
  is no un-delete route and no un-delete button anywhere in the app, and `POST /alert/:id/unarchive`
  restores an *archived* card, which is a different state. Use `clear` or Archive when you mean to
  put a card away; reach for `DELETE` only when the row itself should cease to exist.

### Inbox behaviour

**`projectId` decides WHICH part of the inbox a card lands in, so it decides whether the operator sees it.** It has three states, and `null` is not the same as leaving it out: a real id files the card under that project; **an explicit `null` raises it GLOBALLY**, in the shared **Alerts** pile the operator scans; **omitting it** derives the raising session's project. So a card raised from inside a project-bound session and left unscoped lands in that project's own group, mixed among its sessions — which is how a genuine question for the human can sit unread. An agent asking for a decision should send `projectId: null` explicitly. The card keeps its source session either way, so being global costs no provenance. Honest limit: the Alerts pile is a pile, not an alarm — global makes the ask reachable, not guaranteed to be read. See `inbox-alert-contract.md` (I4 / I4-global).

Alerts surface as amber-dot rows in the unified inbox (integration `alert`, `dotColor: 'bg-amber-400'`). When an alert has a `projectId` that is a real user project, it also surfaces in that project's **Needs You** attention section (mirroring how doc-token-alert and cron-approval rows work). There it orders **by its timestamp, interleaved with the session rows** — it is NOT appended after them, so an old alert sits where its age belongs instead of being pinned to the bottom. Every Needs-You row (session or notice) orders by the same key via the one `orderNeedsYouItems` rule, and the rendered sidebar order equals the keyboard/swipe/next-selection order; see `project-needs-you-attention-contract.md` (`render-order-equals-nav-order` / `advance-can-land-on-a-notice`) and the ordering-parity axis in `frontend-inbox-row-contract.md`.

**Tasks reminders get their own "Tasks" section.** A Tasks reminder — raised when a task's due date arrives or a snoozed task comes back — is an ordinary agent alert, but instead of landing in the shared **Alerts** pile it surfaces in its own **"Tasks"** inbox section (its own header + task-list icon), so task reminders don't get mixed in with system alerts. Its rows behave like any alert (archive, snooze, its own **Dismiss all**), and clicking the section header opens the **Tasks** view. Tasks is a Lab / in-development feature, so this section only appears when Tasks is enabled. See `inbox-alert-contract.md` (I21).

**Team Chat messages get their own "Team Chat" section.** A team-chat new-message notice is an ordinary agent alert, but instead of landing in the shared **Alerts** pile it surfaces in its own **"Team Chat"** inbox section (its own header + chat icon) — the SAME section the connection-request rows already use, so messages and connection requests sit together. Its message rows behave like any alert (archive, snooze, their own **Dismiss all** — which clears message notices only, never the connection-request approvals in the same section), and clicking the section header opens the **Team Chat** panel. Team Chat is an in-development feature, so this section only appears when Team Chat is enabled. See `inbox-alert-contract.md` (I8) and `team-chat-desktop-contract.md` (D16).

**A built-in channel's notices file under that channel's own inbox section.** The notices raised by the built-in channel pollers — Calendar ("starting soon" reminders, "calendar updated" changes, and a "calendar event alerts stopped" health notice), Gmail ("Gmail is disconnected"), Drive ("Google Drive needs reconnecting"), RSS ("an RSS feed stopped updating"), and Webhooks ("a webhook source is failing") — pass a `virtualProjectPath` naming their channel's virtual project (e.g. `__rss__` / `__calendar__`). So they group **under that channel's inbox section**, merging with that channel's other rows (an RSS broken-feed notice sits alongside your RSS feeds), instead of the generic **Alerts** bucket. `resolveProjectId` honours `virtualProjectPath` via the pure `isChannelVirtualProject` membership check and stores the sentinel folder_path verbatim; it takes precedence over `projectId`/session resolution and, touching no database, can never fail and swallow the notice. The one-click **Reconnect** buttons and self-clear-on-recovery behaviour are unchanged (they key on `dedupKey`, not the project). Calendar and Drive get their inbox-section name + icon + colour from `src/renderer/src/features/dashboard/inbox-helpers.ts` (they are seeded from `CHANNEL_VIRTUAL_PROJECTS`, not the integration registry). See `inbox-alert-contract.md` (I4).

**Omniscio's own notices file under your Omniscio project.** A notice about Omniscio's own machinery — a **Dev Pipeline** card (a branch that would not land, a worktree that would not delete, a gate that died, a test baseline that regressed, a workspace that needs you) or an **app-internal** one (the disk guardian, a background-metadata storm, a cloud pointer that stranded, the folded "background machinery" rollup) — now files under **your Omniscio project** (the checkout Omniscio runs from, e.g. a row named **Omniscio**) instead of the generic **Alerts** pile. The classifier is the dedup key: an exact list plus prefix families for the build → gate → land → workspace lifecycle, unioned with the existing about-Omniscio class. It is the LAST step of the grouping fallback, so anything already carrying a channel, an explicit project or a raising session's project is untouched — a real project always wins. On a machine with no Omniscio checkout the step returns nothing and these notices stay in the pile exactly as before.

  This changes where you **find** those cards, and one thing about how you clear them: a card filed under a project appears in that project's **Needs You** attention row and is dismissed **one at a time** — project-scoped alerts deliberately have no **"Dismiss all"** (that button lives on the unscoped buckets). What did NOT change: the alerts themselves, their dedup keys, their one-click actions, their re-raise cadence and their archive behaviour are all identical. Cards that were already in your pile move on their **next** re-raise, so the pile empties progressively rather than all at once. See `inbox-alert-contract.md` (I4c / I4c-live / I4c-fill).

Standard inbox actions apply:

- **Archive** (`E` / middle-click / mobile X / the detail-viewer Archive button) — removes the row from the inbox and optimistically removes it from the store slice; rolls back on IPC failure. **Ctrl+Z undoes it**: the store's `archiveInboxItem` arms an undo (once the archive succeeds) that restores the alert via `ALERT_UNARCHIVE`, so undo works from every archive surface, not just the keyboard. The restore is keyed on the card's IDENTITY (`dedup_key`), not its row: if the condition re-fired between the dismiss and the undo, a fresh card for it is already in the inbox, so the undo reports success and leaves that live card alone rather than trying to restore a second copy of the same alert.
- **Snooze** — routes through the universal `inbox_snoozes` table (kind `'alert'`); the selector gates on `isInboxItemSnoozed('alert', id)`. The server list queries do NOT filter snoozes, so every alert list applies that gate itself — the inbox in `alertInboxSelectItems`, the **Agent Alerts screen** in `hubVisibleAlerts`, and the sidebar badge by counting the filtered list. A snoozed alert therefore disappears from **all three** until it wakes; before this, the Agent Alerts screen alone kept showing it, which made snoozing look like it had done nothing.
- **Dismiss all** — when a section of the inbox holds 2+ alerts, its group header shows a **"Dismiss all N"** button — on the desktop sidebar **and** on the phone's inbox list alike (the alert-section twin of the Approvals section's **"Approve all" / "Reject all"**, which share one dropdown in the Approvals header — also on both surfaces — and cover every kind of pending approval: cron, automation, CLI, and recipe, not just one). One confirm archives every alert in that section at once — each row routed through the same per-row **Archive** above, the open one dismissed **last** so the cursor advances just once. It self-hides below 2 (you'd just archive the single row) and appears on the unscoped **Alerts** bucket **and the "Tasks" reminders section** (each button clears only its own section, never the other) — a project-scoped alert sitting in its project's Needs You is still dismissed one at a time. See `frontend-inbox-row-contract.md`.
- **Enter runs the card's one primary action** — while you are reading a card, a bare `Enter` fires its primary button (`Update now` on a tool-update card, `Restart now`, `Resume sessions`, …) exactly as clicking it does, so an action that asks you to confirm first still opens that confirmation. It fires only while the card is what holds the keyboard — if focus is on the inbox list, in a field, on a button, or anywhere else, `Enter` keeps whatever meaning it has there. A held `Enter` does not repeat the action, and no modifier combination counts.
- **Auto-recovery** — when you archive an alert (or the one you're reading is removed out from under you — archived from another session, the CLI, a dedup collapse, or a background sweep), the inbox advances to the **next item in the list, whatever it is** — the next notification _or_ the next session that needs you — rather than stranding you. There is no per-integration filter in that advance: the inbox is one ordered list and "next" is simply the next row (the sidebar order is the nav order). On a heavily loaded machine a worker session can briefly still look like it needs you just after it has gone back to running; if the cursor lands on such a stale session for a moment, the general auto-eject + idle auto-select move you straight on, so the old "blink → empty inbox" can't strand you. That advance only fires for an alert you were **already viewing** when the list last refreshed — so **opening** an alert never bounces you back to the list, even if you click it (e.g. going from a session row to a just-arrived alert) while a background refresh is mid-flight: at worst you briefly see _Loading alert…_ until it appears. A refresh that began before your click took its snapshot before the alert was selected, so it can't be trusted to declare the alert "gone"; a later refresh resolves it. The reading pane (`AlertInboxViewer`) also renders **nothing** — never a stale "Select an alert to view" message — when no alert is selected, so the panel's close transition can't flash a misleading empty pane (the inbox layout owns the "Select an item" / "All clear" empty state). See `inbox-alert-contract.md` (I12 cursor advance + mid-load selection guard, I13 no-flash empty pane) and `inbox-navigation-contract.md` `dismiss-advance-one-chokepoint` (the integration-blind advance).

## Related

How an agent authors an alert — the developer-facing route, the special characters it has to survive, and the app-generated cards with their own actions — is on [Inbox alerts — authoring them](inbox-alerts-part-2.md). What happens on the Alerts screen and over an alert's life, from the dismissal cooldown to auto-remediation, is on [Inbox alerts — the lifecycle](inbox-alerts-part-3.md). The inbox they land in is the [Inbox overview](inbox-overview.md).
