---
title: Settings patch approval (review CLI / AI proposed settings changes before they apply)
---

# Settings patch approval (review CLI / AI proposed settings changes before they apply)

## What it is

When something outside the main UI tries to change one of your Omniscio settings — typically an AI agent calling the local CLI control server, but also any external script driving the same endpoint — the change does **not** apply immediately. It queues as a pending approval that shows up in your inbox as a "Settings change?" item, and Omniscio renders the diff in plain language so you can see exactly what the patch will do before you click Approve. Reject and the patch is discarded; approve and the change applies.

The point of the feature is to keep settings-by-API safe. Settings touch the whole app — your theme, your channel adapters, your AI Manager wiring, your Pomodoro defaults. An unaudited path that lets a script overwrite the entire `aiManager` blob is a foot-cannon; an audited approval pane with a plain-language diff is not.

## Where to find it

### Where it lives in the UI

A pending settings patch appears as a row in the **CLI Pending** inbox (toolbar bell → CLI Pending tab, or the right-rail dock). Click the row and the approval pane opens inline. The **title** is the whole ask — for example **"Enable Decks?"** — so the body never repeats the setting's name; it shows only what the title can't: a short line of what the setting _does_ — the same plain-English description shown under that setting in Settings, reused so you don't have to know the setting to judge the change — followed by the concrete before/after, and a quiet line at the bottom naming the session that requested the change (clickable, so you can jump back to it). (Essentially every user-facing setting surfaces this "About" line now; a setting only omits it when it has no written description at all, and you still get the before/after either way.) You'll see one of these in the body, depending on what's being patched:

1. **A simple on/off toggle** — a plain line like **"It's currently off."** (the title's "Enable …" / "Disable …" tells you the direction).
2. **A single value changing** (for example `theme`) — the old value, an arrow, and the new value.
3. **A nested object changing some leaves** (for example the `aiManager` blob where only `router.enabled` flipped) — a "Will change N field(s):" header, one bullet per changed path (`router.enabled: No → Yes`), and an "(M other fields unchanged)" tail so you can confirm the diff is complete. For settings keyed by an internal id — a per-project or per-provider default model, say — the field name is shown as a **human label** (`Omniscio · Codex`), never the raw project UUID or provider slug; a project that no longer exists reads as `Unknown project` rather than its id.
4. **A no-op patch** — the payload happens to match the current value. The pane explicitly says **"No effective changes — this patch matches the current value."** so you don't reject a harmless re-apply by mistake.
5. **Prior value unavailable** — the rare fallback when Omniscio can't reconstruct what the key was before the patch was queued. The pane shows the new value and a hint to open the technical-details expander.

## How it behaves

### A list or an object always shows its real contents

Whatever the setting holds, the pane shows **what it actually is** — never a placeholder count. A list of branch names reads `main, develop, and release`; an object reads `Primary: red · Secondary: blue`; and a setting that stores project references resolves each one to the **project's name**, so opting a repo into the auto-lander reads `(none) → Omniscio · Enabled: on` rather than the old, useless `(none) → (1-item array)`.

Three rules keep that honest:

- **Nothing is hidden silently.** If a value has more entries than fit, the extras are announced as `+3 more` — the pane never quietly omits a field you'd be approving.
- **A big value is shortened, not replaced.** A large blob is truncated with an ellipsis and still points you at "Show technical details" for the byte-exact payload — you always get a real value plus the escape hatch, never only the escape hatch.
- **Credentials stay masked.** A field whose name reads like a secret (an API key, token, password, passphrase) renders as dots. Settings that store credentials can't reach an approval pane at all — this covers a credential tucked inside a larger setting.

If a project reference can't be matched to a name yet (the project list hasn't loaded), it degrades to a labelled id like `Project: 4f2c…` — still a real value you can act on.

Below the body, the collapsed-by-default **"Show technical details"** expander reveals the full raw JSON payload — but only when it adds something. For a simple on/off or single-value change (which the body already shows in full) the drawer is **hidden**, because the raw `{"value": true, "priorValue": false}` is just developer noise. It stays available for nested or large payloads, where you might want to inspect or copy a value verbatim.

### How the diff stays trustworthy

When a `PATCH /settings/<key>` request arrives at the local CLI control server (running on `127.0.0.1:19519` by default), the server **snapshots the current value of `settings[key]` before it queues the pending row**. That snapshot — the `priorValue` — is stored alongside the new value inside the pending row's payload. When you later open the approval pane, the diff is computed between that frozen `priorValue` and the proposed new value, not between live state and the proposed value.

That ordering matters: between the moment the CLI client submitted the request and the moment you click the approval, some other surface in the app might mutate the same setting. If the diff compared the proposed value to live state, it would look wrong — the approval pane would show a confusing "change" that no longer reflects what the script intended. Snapshotting at queue time keeps the diff time-correct.

Keys that store sensitive material (credentials, encryption keys, anything denylisted) never reach the snapshot path — they are rejected earlier in the same handler, before the pending row is created.

### What can trigger one

Any caller that hits `PATCH /settings/<key>` on the local CLI control server can queue a settings-patch approval. In practice that means:

- **AI agents** running on your machine that have been granted CLI access and want to adjust an Omniscio setting on your behalf.
- **Plugins** or recipes that call the CLI server during their own workflow.
- **External scripts** — `curl`, automation runners, anything you've authorized with a bearer token.

The CLI server enforces bearer-token auth on the patch endpoint, so a random local process can't queue patches without the token. Endpoints that **apply immediately** vs **route through this approval queue** are listed in the CLI server gating table; settings patches sit firmly on the approval-required side.

**The agent gets a one-click link to the approval.** When the patch queues, the CLI create response now carries an `omniscio://inbox/item/<id>` deep link straight to this approval — so instead of telling you to "go find it in your inbox," the agent can hand you a clickable "approve this" link. If you open the approval **via that link**, approving or rejecting it returns you to the **session that requested it**; resolving the same approval the normal way in the inbox just moves to the next approval. This applies to every agent-queued approval, not just settings patches. See [deep-links.md](deep-links.md).

### The 50,000-character technical-details cap

The "Show technical details" expander has a hard cap of **50,000 characters** on inline `<pre>` rendering. The realistic worst case it was sized for is an `aiManager` patch carrying both `value` and `priorValue` (a roughly 45 KB twin payload); that fits inline with a `max-h-64 overflow-auto` scroll region without freezing the renderer. Larger payloads render a **"(too large to display inline)"** stub above the toggle. If you need to inspect a giant payload byte-for-byte, copy it out via the CLI server or the database; the cap exists specifically to keep the renderer responsive on mobile, where multi-megabyte `<pre>` blocks can lock the device.

### Limits and edge cases

- **Approvals are persistent.** They survive an Omniscio restart. If you don't decide today, the row is still there tomorrow.
- **Rejecting is final.** A rejected patch is discarded; the CLI client gets back a rejection on its long poll (or, if it timed out, the next `GET /settings/<key>` reflects the unchanged state). To re-propose the same change the script has to queue a new patch.
- **A patch that matches the current value still queues.** It renders as a Branch 3 no-op. This is deliberate: the queue is a verification mechanism, not an optimisation; it's better to see "this patch would do nothing" and approve than to silently drop it.
- **Schema validation happens before the snapshot.** If the patch's value doesn't pass the settings schema, the CLI client gets back a 400 immediately and no row is queued — the snapshot path is only entered for payloads that would, if approved, apply cleanly.

## For agents

### Where to inspect this in the source

- **Backend snapshot path:** the PATCH handler in `src/main/services/cli/cli-server-settings-routes.ts` is where `priorValue` is captured. The corresponding test file (`tests/unit/services/cli-server-settings-routes.test.ts`) locks the snapshot shape and the null-coercion for absent keys.
- **Renderer diff and rendering:** `SettingsPatchOverride` in `src/renderer/src/features/cli-pending/friendly-payload/overrides/SettingsPatchOverride.tsx` is the override that picks the render branch. The pure diff helper is `computeSettingsDiff` in `src/renderer/src/features/cli-pending/friendly-payload/settings-diff.ts` — it returns leaf-level paths and an `scalarFallback` flag that drives Branch 1 vs Branch 2.
- **The "About" line:** `AboutRow` (same file) resolves the description via `describeSettingForApproval(key)` (gated-feature copy) then `helperForSettingKey(key)` in `setting-helper-lookup.ts`, which looks the setting's own Settings-search description up by an explicit `settingsKey`. Registry-defined settings carry that key automatically; a hand-written `*Settings-search.ts` entry opts in by setting `settingsKey` on its own entry (done for ~200 user-facing settings). A guard, `tests/unit/lint/settings-search-settingskey-validity.test.ts`, keeps every such key valid + unique so a wrong key can never attach a wrong description.
- **Technical-details expander:** `src/renderer/src/features/cli-pending/friendly-payload/TechnicalDetailsExpander.tsx` owns the 50,000-character cap.
- **Contract:** `.claude/memory/contracts/settings-patch-approval-contract.md` is the present-tense single source of truth and lists every test that locks an invariant. Read it before changing any of the above.

## Related

### Related pages

- [cli-pending-actions.md](cli-pending-actions.md) — the CLI Pending inbox surface that hosts settings patches alongside every other approvable CLI action.
- [deep-links.md](deep-links.md) — the `omniscio://inbox/item/<id>` link the agent receives in the create response; opening it and resolving the approval returns you to the requesting session.
- [share-cli.md](share-cli.md) — the CLI control server's authentication, bearer token rotation, and the broader endpoint map.
