---
title: CLI Pending Actions (approve external AI changes)
---

# CLI Pending Actions (approve external AI changes)

## What it is

When an external AI (Claude Code in another project, ChatGPT with the omniscio-control skill, a curl script) asks Omniscio to change a setting or alter a session via the CLI control server, the request does **not** apply immediately. Instead it lands in a shared approval queue called `cli_pending_actions`, and a row appears in your **inbox** as a yellow item titled with the request's preview text (no "Approve:" prefix) alongside your messages and notifications. You click the row, a modal opens showing exactly what's about to happen — the action kind, a humanized breakdown of the request (named fields, resolved targets like the real project / session / tag / recipe name, plain-English values), and a collapsible **Show technical details** section with the raw JSON for power users — and you approve or reject. Only after you approve does the change apply.

This is the user-in-the-loop guarantee for the approval-gated CLI capabilities. The `CliActionKind` union has **49** kinds in all (the complete list is `CLI_ACTION_KINDS` in [/src/shared/cli-pending-types.ts](/src/shared/cli-pending-types.ts)); the most commonly hit ones are:

1. **Settings PATCH** (`PATCH /settings/:key`) — change a single Omniscio setting (theme, notification volume, MemPalace toggle, etc.) — `settings.patch`
2. **Session pause** (`POST /session/:id/pause`) — `session.pause`
3. **Session unpause** (`POST /session/:id/unpause`) — `session.unpause`
4. **Session snooze** (`POST /session/:id/snooze`) — `session.snooze`
5. **Session archive** (`POST /session/:id/archive`) — `session.archive`
6. **Agent-driven session spawn** (`POST /agent/sessions`) — `session.spawn.agent_driven` (initial approval gates the spawn; follow-ups inside the user-approved turn + dollar caps are pre-approved per spawn request)
7. **Project delete** (`DELETE /project/:id`) — `project.delete`
8. **Project bug-intake update** (`PATCH /project/:id/bug-intake`) — `project.bug_intake_update` (enable/disable a project's bug-report email intake slug — flipping it on opens an external email side channel that auto-spawns Claude sessions, so it's approval-gated; slug uniqueness is enforced case-insensitively at queue AND apply time)
9. **Project docs upload** (`POST /project/:id/docs`) — `project.docs_upload`
10. **Project docs delete** (`DELETE /project/:id/docs/:filename`) — `project.docs_delete`
11. **Recipe run** (`POST /recipe/configs/:id/run`) — `recipe.run` (only when the recipe is NOT pre-approved via `agentTriggerable.enabled`; pre-approved recipes bypass this queue and fire immediately, subject to a 30-second cooldown per `(recipeId, projectId)`)
12. **Away-mode rule create** (`POST /away-mode/rules`) — `away_mode.create`
13. **Away-mode rule update** (`PATCH /away-mode/rules/:id`) — `away_mode.update`
14. **Away-mode rule delete** (`DELETE /away-mode/rules/:id`) — `away_mode.delete`
15. **Cron failure alert** (auto-emitted by Omniscio when a cron job fails) — `cron.failure_alert` (status semantics differ: `pending` = unread, `approved` = acknowledged, `rejected` = dismissed-as-noise with 24h cooldown; capped at 5 outstanding per-kind so cron-failure cards don't starve the global queue cap)
16. **Cron failure heal** (auto-emitted **escalation card** — "needs human attention" — after 3 self-heals fail; the only inbox-visible heal row, since regular heals auto-fire without queueing) — `cron.failure_heal` (acknowledge-only, same status semantics as the failure alert)
17. **Email summarizer rule create** (`POST /email-summarizer/rules`) — `email_summarizer.rule_create`
18. **Email summarizer rule update** (`PATCH /email-summarizer/rules/:id`) — `email_summarizer.rule_update`
19. **Email summarizer rule delete** (`DELETE /email-summarizer/rules/:id`) — `email_summarizer.rule_delete`
20. **Email summarizer rule toggle** (`POST /email-summarizer/rules/:id/toggle`) — `email_summarizer.rule_toggle`
21. **Email summarizer setup** (`POST /email-summarizer/setup`) — `email_summarizer.setup_wizard`
22. **Tags library create** (`POST /tags`) — `tags.create`
23. **Tags library update** (`PATCH /tags/:id`) — `tags.update`
24. **Tags library delete** (`DELETE /tags/:id`) — `tags.delete` (cascade preview shows session count before approval)

Cron-job creates and automation-rule creates do **not** land in `cli_pending_actions` — they ride on a separate per-row `approval_status='pending'` flag on the `cron_jobs` / `automations` tables (migrations v88/v89) and surface through their own dedicated UI inside the Cron Jobs / Automations panes. This page covers only the unified-inbox flow above.

The queue has a hard cap of **500 outstanding pending rows across ALL action kinds** (raised from 20 on 2026-09-14). When the cap is full, every new queue request returns HTTP 409 until you approve or reject some.

## Where to find it

The queue shows up as the **Approvals** section of your inbox, beside your messages and notifications, with a yellow badge so it stands apart from both. The switches that decide which requests need your click live in **Settings → CLI Control → Approval requirements**, and everything you have decided never to be asked about again is listed under **Settings → CLI Control → Always-allowed actions**.

## How it behaves

### Configuring which actions require approval (2026-06-05)

By default every capability above behaves exactly as described — but you can now decide **per action family** whether an external **cli-token** caller's request waits in this queue or applies immediately. Open **Settings → CLI Control → Approval requirements** and flip a family's switch:

- **On** (the default for everything already gated above) — requests queue here for your approval.
- **Off** — requests from an outside CLI caller apply immediately, running the _same_ code path an approved row would, so an un-gated action is identical to one you'd have approved by hand. Rate limits, idempotency, and the queue cap are unchanged. Paid / destructive families (recipe run, session spawn, Nighty-Tidy run-now, AI-coaching interview, project delete, bug-intake enable) carry a ⚠️ badge and a confirm step before you can turn approval off.

Every switch is seeded to today's behavior, so nothing changes until you flip one. Sessions Omniscio spawns itself are unaffected — the switches govern the external cli-token boundary only; an in-app session keeps applying an ordinary setting immediately. One exception: a setting that switches a safety guard off, changes one of these approval switches, widens what an agent may do, gives your consent, or arms spending or landing is treated exactly like an outside AI's request — the same card, following your App-settings switch — even from a session Omniscio spawned. Those sessions are AI agents, not you; your own Settings screen never goes through this route, so your own clicks still apply at once. The deep-link session spawn reuses the existing "Require approval for AI session spawn" switch. Mechanism + invariants: [/.claude/memory/cli-server-gating.md](/.claude/memory/cli-server-gating.md) and the contract [/.claude/memory/contracts/cli-approval-policy-contract.md](/.claude/memory/contracts/cli-approval-policy-contract.md).

**Always allow one specific action (narrower than a family).** Right on an approval card, the green **Approve** button carries a **▾** dropdown caret; opening it lets you stop being asked for _that one exact action_ — e.g. "Upload docs file" only, not the whole "project edits" family. Two scopes: **For this project** (only that project — offered when the action belongs to a project) or **Everywhere** (every project). Picking one approves the item in front of you **and immediately clears any other items already waiting in your inbox for that same action** — the ones that would have been auto-approved if you'd allowed it earlier (Everywhere clears them across every project; For this project clears only that project's). Money / delete actions confirm first; actions that must always be asked (like paid text-to-speech) never show the caret — they get a plain **Approve** button. Everything you allow this way is listed at **Settings → CLI Control → Always-allowed actions**, each with a **Remove** to go back to being asked. It layers over the family switches above and applies through the same approve path (rate limits, idempotency, and the queue cap unchanged). Note for AI agents: this is **human-only** — there is no CLI route to grant it, so an agent can never turn off its own approvals.

**Plugin actions get a narrower scope than either of those.** A plugin action (`plugin.cli_dispatch`) is never covered by **For this project** or **Everywhere** — those two scopes don't apply to it at all. Its own approval card instead offers "Always allow this action for \<plugin\>", which always-allows exactly that one method + path of that one plugin, nothing wider (a confirm step, with a stronger warning when the plugin itself flagged the action as needing confirmation every time). It's listed alongside every other always-allowed action at **Settings → CLI Control → Always-allowed actions**, and shows "Not active" if the plugin is later removed or stops declaring that action. Same human-only rule: there is no CLI route to grant it.

**Keyboard-shortcut settings are always instant.** One settings carve-out sits below the App-settings switch: a `settings.patch` that only binds or toggles a keyboard shortcut — any global hotkey (focus, Omni, Quick Launch + per-tab, KMS, voice), the screen-recorder hotkeys, the push-to-talk hold keys, plus each one's on/off toggle — applies immediately and never queues here, regardless of the App-settings switch. Rebinding a shortcut is a benign personal preference, the same trust level as Omniscio's in-app keybindings editor. Feature on/off switches (e.g. "Enable KMS", "Enable Voice") and contact data are NOT treated as hotkeys and still queue for approval. `hotkey-settings-are-always-instant` in the [cli-approval-policy contract](/.claude/memory/contracts/cli-approval-policy-contract.md).

### How to use it

1. **An external AI tells you it queued something.** A typical chat reply: "I asked Omniscio to set theme to dark — open the inbox and approve to apply." The AI got HTTP 202 from the CLI server, which means "queued, not applied". The response carries `disposition: "awaiting-user-approval"`, `requiresUserAction: true`, the approval link, and an instruction for the AI to end its current turn without retrying. Your approval can reach the AI only after that turn ends. Retrying while the card is pending returns the same stop disposition and link; once the row is approved or rejected, completed replays omit those fields.

2. **Open Omniscio's inbox.** Click the **Inbox** button in the sidebar (or press your inbox shortcut). The pending row appears with the title "\<preview\>" — for example, "Pause session: My Claude Project". **Settings changes get a humanized, change-aware headline** rather than the raw key + JSON, naming the setting by its real Settings-screen label (not a title-cased key): "Set Theme to dark", "Set Quick Launch shortcuts (Calendar) to Ctrl+Alt+J", "Turn on Plain Speak" (a toggle reads "Turn on \<name\>" / "Turn off \<name\>"), or "Change AI Manager (3 settings)" — never "Turn Keep Db In Private Cache on" (the mangled key) or "Set quickLaunchTabHotkeys: {…}". The row carries a yellow accent badge so it stands apart from chat messages and notifications.

3. **Click the row to open the approval modal.** For consent requests the modal's title is just the row's preview text plus a "?" (no "Approve:" prefix): **"\<preview\>?"** (e.g. "Archive session \"My Claude Project\"?", "Run recipe?"); the five informational kinds keep **"Acknowledge: \<preview\>"** (no question mark) — cron failure alert, cron failure heal (escalation), session budget warning, session budget exceeded, and Nighty Tidy summary. The title names the specific target, not just the action kind. The body shows:
   - **Note (only when the agent supplied one)** — if the requesting AI attached an `X-AMC-Approval-Note` header (see below), a labeled **Note** closes the approval's details as their **last labeled row**, in the same bordered card style as the pane's other facts (never a separate callout box), carrying the agent's stated reason — the _why_ behind the request — so the concrete change reads first and the agent's reasoning last. Absent when no note was sent. It is the agent's claimed reason, not Omniscio's verification; the concrete change and the requesting session are still shown below.
   - **Details block** — the card body is one bordered block of identical rows (icon + label + value). It leads with **Type** (the kind — "Settings change", "Run recipe", "Archive session", etc.), then the target rows, and then — for the generic kinds — the request's own payload fields (each an icon + label + value row), so the entire card reads as one unified list, closing with the **"Generated by \<session\>"** provenance row and, as its own separate labeled field just below it, a **"Submitted \<date\>"** row (carrying the full queued date + time — "July 11, 1:48 PM"; the year only when it differs from now) at the very bottom. (The non-settings override kinds — recipe.run, drip.update, Nighty-Tidy run-now — instead keep their bespoke payload layout just below the block.) A **Settings change** card takes this further — the whole card is field rows (Type · About · Change · "Requested by \<session\>" · "Submitted \<date\>", then **Note** last when the agent supplied one). The created date is its OWN "Submitted" row on every approval card — a separate labeled field, never crammed onto the "Requested by" byline (approval-standard-ui-contract I7).
   - **Target context** — for session and project actions, that same block continues with rows naming exactly what is acted on: the **session** (or project) name, its **project**, and the **repo folder** on disk — plus the **worktree** path + branch when the session is isolated — resolved live from the row's target. This is what tells you _which_ session "Archive session?" means, even when the action carries no payload of its own. Spawn approvals likewise show the **folder** the new session will open in. **When the title already names the target** — the consent session/project kinds whose preview is a naming sentence (Archive / Pause / Unpause / Snooze a session, Delete / Docs / Bug-intake a project) — the redundant name row is dropped and the block leads with **Project + Folder** (+ Worktree): the name is shown once (in the title), not twice. Budget alerts keep their Session row, because their title is the generic "Session budget …" and doesn't name the session.
   - **Friendly payload view** — the heart of the modal. Instead of raw JSON, every field of the request is rendered as a labeled row with a human-readable value: keys are title-cased ("New value", "Project", "Items per release"), ID fields are resolved against the live store to show the real **project / session / tag / cron job / recipe / automation / coaching artifact / email rule name** (with the truncated id beside it), timestamps are formatted ("May 20, 2026 9:43 AM"), dollar amounts and `*Cents` fields are formatted as currency, booleans render as "Yes" / "No", string lists join as "a, b, and c", and long strings (>240 chars) truncate with a pointer to the technical details. A **cron expression** never appears as a raw labeled field — it renders as a single plain-English **Schedule** row ("9:00 AM daily", or the submitter's own natural-language phrasing), and the raw `0 9 * * *` survives only in Show technical details (the redundant cron-natural-language key folds into that one Schedule row). Three kinds get a tailored layout. **Settings change** computes a leaf-level diff against the prior value Omniscio stashed at queue time and renders only the fields that actually change — a top-level scalar swap reads as "Change: \<old\> → \<new\>", a nested-object change (e.g. a tweak inside `aiManager`) renders a "Will change N field(s):" list with one row per changed leaf path (`router.enabled: No → Yes`) plus an "(M other fields unchanged)" tail, a patch that exactly matches the current value shows "No effective changes — this patch matches the current value." instead of an empty list, and the rare case where Omniscio can't recover the prior value falls back to showing the raw new value with a hint to open Show technical details. **Run recipe** lists the recipe, project, optional custom message, and each variable on its own line. **Drip edit** (a `drip.update` approval) unwraps the nested `{ fields }` change-set and renders only the changed fields through the same friendly view, so an edited schedule reads as a Schedule row rather than a raw-JSON blob. When an action carries no payload of its own (e.g. archive / pause / unpause), the **Target context** block above stands in for the field list; the "(no details to display)" placeholder now appears only for the rare kind that has neither a target nor a payload.
   - **Friendly payload view** — the heart of the modal. Instead of raw JSON, every field of the request is rendered as a labeled row with a human-readable value: keys are title-cased ("New value", "Project", "Cron expression"), ID fields are resolved against the live store to show the real **project / session / tag / cron job / recipe / automation / coaching artifact / email rule name** (with the truncated id beside it), timestamps are formatted ("May 20, 2026 9:43 AM"), dollar amounts and `*Cents` fields are formatted as currency, booleans render as "Yes" / "No", string lists join as "a, b, and c", and long strings (>240 chars) truncate with a pointer to the technical details. ID fields resolve to names across eleven entity types now (projects, sessions, tags, cron jobs, recipes, automations, coaching artifacts, email rules, plugins, drips, saved prompts), so a plugin / drip / away-mode card shows a real name instead of a raw id. A few kinds get a tailored layout. **Settings change** computes a leaf-level diff against the prior value Omniscio stashed at queue time and renders only the fields that actually change — a top-level scalar swap reads as "Change: \<old\> → \<new\>", a nested-object change (e.g. a tweak inside `aiManager`) renders a "Will change N field(s):" list with one row per changed leaf path (`router.enabled: No → Yes`) plus an "(M other fields unchanged)" tail, a patch that exactly matches the current value shows "No effective changes — this patch matches the current value." instead of an empty list, and the rare case where Omniscio can't recover the prior value falls back to showing the raw new value with a hint to open Show technical details. **Run recipe** lists the recipe, project, optional custom message, and each variable on its own line. **Run Nighty Tidy audit** (`nighty_tidy.run_now` / `nighty_tidy_2.run_now`) leads with the Project + Folder target block, then shows the human **Audit** name and a plain-English **Mode** line (read-only reassurance vs read-write "can edit files to apply fixes") — no "Audit Slug" jargon, no doubled slug, no raw project id, and no raw-JSON drawer. When an action carries no payload of its own (e.g. archive / pause / unpause), the **Target context** block above stands in for the field list; the "(no details to display)" placeholder now appears only for the rare kind that has neither a target nor a payload.
   - **Show technical details** — a collapsed `<details>` section at the bottom. Expand it to see the raw pretty-printed JSON request body exactly as the server received it. It is **hidden entirely when there is nothing to show** — a no-arg action (archive / pause / unpause) carries an empty `{}`, and a delete carries only the project id the card already names, so the drawer would reveal nothing but noise. (Payloads over 50,000 characters render a stub message instead of inlining, to protect the renderer — sized for the realistic worst case where a settings.patch carries both the new value and the prior value side by side, e.g. an `aiManager` twin at ~45kB.)
   - **Approve / Reject buttons** — green and red. **Approve** carries a **▾** caret that opens the "always allow this action" options described above (a kind that can't be always-allowed shows a plain Approve); folding that affordance into Approve keeps the footer to two buttons that stay on one row on a phone. For the informational (acknowledge) kinds the labels read **Acknowledge** and **Dismiss** instead.
   - **Keyboard:** with the pane open, **Enter** approves and **X** rejects-immediately — the keyboard equivalent of the green / red buttons (a muted "Enter … · X …" hint sits in the footer on desktop, and **hovering a button shows its key as a key-cap** right on the button). Typing in a text field never triggers them, and holding a key can't mass-approve the queue. These two keys are fixed (not rebindable).
   - **Esc** closes the modal without changing the row's state (you can come back later)

4. **Approve or reject.** Approve applies the change immediately — for settings, you'll see the UI react (theme flips, volume adjusts); for session lifecycle, the session's status badge changes. **When approving a settings change empties your inbox**, Omniscio then takes you straight to that setting in Settings — scrolled into view and briefly highlighted — plus a one-tap "How do I use it?" link that opens the in-app help agent for that feature (shown only when the help agent is enabled; it opens nothing until you click it). When other items still wait in your inbox it keeps advancing through them instead, so a triage burst is never interrupted. Single-source-of-truth: invariant 15 in [settings-patch-approval-contract.md](/.claude/memory/contracts/settings-patch-approval-contract.md). **Reject** dismisses the request in a single click (the informational "Acknowledge" kinds show a **Dismiss** button instead). Either way the external AI's request is permanently resolved.

5. **The row disappears from the inbox.** Both approve and reject remove the row from the pending list. You can view your full history in the queue by listing rows via the CLI server (see [cli-control.md](cli-control.md)) or by checking the Omniscio main process logs.

   **If the action can't reach the backend** (e.g. a transport stall under heavy load), it does NOT silently do nothing. The optimistic removal is rolled back, the row returns to the inbox, and a _"Couldn't approve — it's back in your inbox to retry"_ toast appears with a **Retry** button. A stuck call self-heals (a retry is safe — the backend applies each approval at most once) rather than leaving a button that does nothing until you reload. The **reject** request is bounded by a 60-second timeout; the **approve** request uses a LONGER bound that exceeds the backend's per-dispatch deadline, because approving can kick off a slow PAID action (a session / recipe / master-debt spawn) that legitimately runs for minutes — a shorter bound would falsely report "back in your inbox to retry" while the backend is still working and the row is not actually back. Implementation invariants: [inbox-approval-load-resilience-contract.md](/.claude/memory/contracts/inbox-approval-load-resilience-contract.md) I7 (the bound self-heals a stuck call) and I9 (the approve bound exceeds the backend dispatch deadline).

## Related

- [cli-control.md](cli-control.md) — the CLI server itself, token handling, read-only endpoints
- [create-cron-job-with-ai.md](create-cron-job-with-ai.md) — sister AI flow with the same user-in-the-loop guarantee but a different storage path: cron job creates ride on `cron_jobs.approval_status='pending'` (migration v88) and surface through the Cron Jobs pane, not this queue
- [omniscio-control skill](/.claude/skills/omniscio-control/pending-actions.md) — the external-AI side of the contract: how an AI queues these actions

This page is split across two parts: [part 2](cli-pending-actions-part-2.md) covers what happens after you decide — how the session that asked is told, what the queue does when a card is never answered, which settings can never be patched from outside, and the rules that govern the note attached to a request.
