---
title: Quick Replies via CLI (read, create, move, delete, export and import the library from an AI agent)
---

# Quick Replies via CLI (read, create, move, delete, export and import the library from an AI agent)

## What it is

Omniscio ships a **Quick Replies library** — the user's personal collection of pre-written messages they can paste into a running Claude session with a single click. Each row in the library is one of three shapes:

- **snippet** — an actual reply with a `label` and a body `text` (e.g. label `"Hi"`, text `"Hello — thanks for the message."`). An optional `autoSubmit: true` flag means clicking the snippet inserts AND sends the message in one step.
- **divider** — a visual section break with a `label` only (no body). Renders as a horizontal rule with its label in the picker.
- **folder** — a container that can hold snippets, dividers, or sub-folders. Has a `label`, no body, and can nest arbitrarily.

Every row has an optional `parentId` (the string id of its enclosing folder, or `null` for top-level rows) and a `displayOrder` integer used to sort siblings within the same parent. The full structure is therefore a tree — top-level rows have `parentId: null`, everything else points at a folder row's id. The in-app picker walks that tree depth-first sorted by `displayOrder` at every level. The in-app companion page is [quick-replies.md](quick-replies.md); this page covers the **ten CLI routes** that expose the same library over the `127.0.0.1:19519` localhost HTTP server so an external AI agent (Claude Code in another project, ChatGPT with the [omniscio-control skill](/.claude/skills/omniscio-control/SKILL.md), a curl script) holding the Omniscio bearer token can read, create, edit, reparent, reorder, delete, export and import entries.

The gating model is **uniform across all ten routes — and deliberately permissive compared to the rest of the CLI surface**:

- **Every mutation is apply-immediately.** There is no inbox approval round-trip. `POST /quick-replies/create`, `POST /quick-replies/move`, `PATCH /quick-replies/:id`, `DELETE /quick-replies/:id`, `POST /quick-replies/reorder`, `POST /quick-replies/restore`, and `POST /quick-replies/import` all execute inline and return `200`/`201` the moment the change has landed. Quick Replies are personal text templates — no session spawn, no cost, fully reversible by re-creating or moving — so the inbox round-trip every approval-gated surface uses (cron, automations, settings, tag-library, recipes, sessions) would just add latency without any new safety. Open Omniscio clients refresh via the `QUICK_REPLIES_CHANGED` push.
- **No idempotency dedup.** Unlike the cli-tags / cli-cron / cli-automations surfaces, which honor `X-Client-Request-Id` to fold a network-hiccup retry into the same pending row, the Quick Replies create body accepts a `clientRequestId` field for backward compatibility but the routes **do not consult it**. A blind retry on `POST /quick-replies/create` will produce two rows. Treat each mutation as one-shot; if you need to know whether a row landed, call `GET /quick-replies/list` — that is the source of truth.
- **Reads are bearer-gated but NOT read-budgeted.** `GET /quick-replies/list` (flat), `GET /quick-replies/tree` (nested), and `GET /quick-replies/export` (the whole library as a bundle) require the bearer token in `Authorization: Bearer <token>` and return `401` when it is missing or invalid. They do **not** share the 60-reads-per-minute-per-token-hash budget that gates the rest of the auth-gated GET routes on this server — `/list` predates that budget, and `/tree` and `/export` were deliberately given the same plain-bearer tier so that diverging gates between read shapes of the same data would not surprise.
- **Mutations share the global 10-mutations-per-minute bucket** with every other write route on this server, and return `401` when the bearer is missing or invalid. Every route on this surface — reads and mutations alike — answers a missing/invalid bearer with `401 unauthorized`; `403` is reserved server-wide for an authenticated-but-denied request, and this surface has no such case (L05-F10).

The route family was renamed from `/snippet/*` to `/quick-replies/*` in schema v228 — anything you may have seen referring to `/snippet/list` or `/snippet/create` belongs to a prior surface and no longer exists.

## Where to find it

Day to day the library is used from inside the app — the Quick Replies picker, where snippets, dividers and folders appear as a tree you can click, drag and edit. This page is about the other end of the same library: an AI agent reads and changes those rows from outside Omniscio, so the picker and the agent always see one set of entries. There is no separate screen to open for the agent side — it reaches the library over the same local control server the rest of Omniscio's automation is driven from, and needs the same access token.

## How it behaves

Quick Replies is one of the few surfaces an AI can change with no approval step: every change is applied the instant it is made, and every open window is told to refresh itself, because these rows are personal text with nothing expensive or hard to undo behind them. The trade is that each request stands alone — repeating one that already half-succeeded makes a second copy rather than folding into the first — so the dependable way to find out what actually happened is to read the library back. Everything the agent can reach is also visible to you in the picker, and anything it gets wrong can be undone there in a couple of clicks.

## For agents

### How to use it

The ten routes:

| Method   | Path                     | Tier                             | Status                   | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------- | ------------------------ | -------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/quick-replies/list`    | read (bearer → `401` on missing) | `200 OK`                 | Flat `QuickReply[]` — every row, regardless of `parentId`. Each row carries its own `parentId` so callers can rebuild the tree themselves.                                                                                                                                                                                                                                                                                                  |
| `GET`    | `/quick-replies/tree`    | read (bearer → `401` on missing) | `200 OK`                 | Nested `QuickReplyTreeNode[]` — top-level array, each node `{ item, children }`, recursively. Children at every level are sorted by `displayOrder` ascending. **The endpoint to call when an AI needs to "see what's inside what".**                                                                                                                                                                                                        |
| `POST`   | `/quick-replies/create`  | mutation (`gateAuthMutation`)    | `201 Created`            | Create a snippet, divider, or folder. Body discriminates on `type`; `parentId` optional on every variant. Returns the created row.                                                                                                                                                                                                                                                                                                          |
| `PATCH`    | `/quick-replies/:id`     | mutation                         | `200 OK` / `404`         | Partial update — any subset of the editable fields: `label`, `text`, `autoSubmit`, the auto-title group (`autoTitleEnabled`, `autoTitleText`, `autoTitleAppendCount`, `autoTitleAppendTimestamp`), the `latestOverride*` pair, `prepend`/`appendQuickReplyIds`, and the engine/model pin (`pinnedProvider`, `pinnedModel`, `pinnedThinkingLevel`). **`.strict()` — rejects `parentId`, `displayOrder`, `type`, `id`.** Reparent via `move`. |
| `POST`   | `/quick-replies/move`    | mutation                         | `200 OK` / `404` / `409` | Reparent and/or reorder. Body `{ id, parentId, beforeId? }`.                                                                                                                                                                                                                                                                                                                                                                                |
| `DELETE` | `/quick-replies/:id`     | mutation                         | `200 OK` / `400`         | Delete a snippet or divider (idempotent silent SQL — missing id still returns `200`), OR delete a folder (requires explicit `?mode=cascade` or `?mode=promote`).                                                                                                                                                                                                                                                                            |
| `POST`   | `/quick-replies/reorder` | mutation                         | `200 OK`                 | Bulk reorder via `{ orderedIds: string[] }` (≤500 entries).                                                                                                                                                                                                                                                                                                                                                                                 |

| `POST`   | `/quick-replies/restore` | mutation                         | `201 Created` / `409`    | Undo a CLI delete: recreate a soft-deleted row from a **full snapshot** body (`{ id, type, … }` — the complete row, not a patch). `409` when a live row already holds that id: restore is fail-closed rather than overwriting a row that came back on its own. |
| `GET`    | `/quick-replies/export`  | read (bearer → `401` on missing) | `200 OK`                 | The whole library as ONE portable `QuickReplyExportBundle` (`{ kind, version, exportedAt, appVersion, items }`) — the identical structure the in-app **Export** writes to a file, so an agent can carry a library to another machine with no UI. Per-machine usage stats (`useCount`/`lastUsedAt`) are deliberately absent. |
| `POST`   | `/quick-replies/import`  | mutation                         | `200 OK` / `400` / `413` | Merge a bundle back. Body `{ bundle }` (what `/export` returned) or `{ text }` (that JSON as a string), plus an optional `sourceLabel`. **Add-only** — never deletes, replaces or edits an existing row — and runs the SAME shared merge the in-app Import button uses. Returns the counts: `added`, `skipped`, `renamed`, `tagsCreated`, `keysDropped`. No preview round-trip (nobody is watching a CLI call). |

All ten require the bearer token in `Authorization: Bearer <token>` (or `?token=<token>`). The mutating seven share the global **10-mutations-per-minute** rate limit. The three GETs are bearer-checked but not read-budgeted — see the gating-model paragraph above.

### Workflow

1. **Discover what's already there with `GET /quick-replies/tree`** before proposing new folders. The tree shape mirrors the in-app picker, so the response shows you what folders exist, what is inside each one, and the sibling order. Use `/list` instead if you want a flat array (e.g. to grep all snippet bodies for a phrase without walking the tree).
2. **Create a row with `POST /quick-replies/create`.** The body's `type` field discriminates the variant — `snippet` (with `text` + optional `autoSubmit`), `divider` (label only), or `folder` (label only). Each variant accepts an optional `parentId` — omit (or send `null`) to land at the top level; pass a folder's `id` to drop the new row inside it. Returns `201` with the full new row including its server-assigned `id`. The change is **immediately live** in every open Omniscio client (push fires on success).
3. **Edit an existing row with `PATCH /quick-replies/:id`.** Partial body — send any subset of the editable fields: `label`, `text`, `autoSubmit`, the auto-title group (`autoTitleEnabled` / `autoTitleText` / `autoTitleAppendCount` / `autoTitleAppendTimestamp`), `latestOverrideEnabled`/`latestOverrideText`, `prepend`/`appendQuickReplyIds`, and the engine/model pin (`pinnedProvider` / `pinnedModel` / `pinnedThinkingLevel` — the same pin the `snippet` create body accepts). The schema is `.strict()` — sending `parentId`, `displayOrder`, `type`, or `id` returns `400`. To reparent or reorder, use `POST /quick-replies/move` instead. To convert a snippet into a folder, you cannot — delete and re-create.
4. **Reparent or reorder with `POST /quick-replies/move`.** Body `{ id, parentId, beforeId? }`. `parentId: null` moves the row to the top level; `parentId: "<folder-id>"` moves it inside that folder. `beforeId` (optional, defaults to `null`) controls position within the destination — `null` appends at the end; a sibling id inserts directly before that sibling. The sibling must already live inside the destination folder. Cycles (moving a folder into one of its own descendants) return `409`; an unknown `id` returns `404`.
5. **Delete a snippet or divider with `DELETE /quick-replies/:id`.** No body, no query string. The legacy path is idempotent — a missing id still returns `200`. `?mode=` is ignored on non-folder rows.
6. **Delete a folder with `DELETE /quick-replies/:id?mode=cascade` or `?mode=promote`.** Folders are the **one** case on this surface where the route refuses an unqualified delete — to avoid silently orphaning everything inside:
   - **`?mode=cascade`** — delete the folder AND every snippet, divider, and sub-folder inside it, recursively. One-shot wipe. Returns `200`.
   - **`?mode=promote`** — move every direct child up one level (into the deleted folder's parent), preserving sibling order, then delete the now-empty folder. Use this when the user wants to "ungroup" a folder without losing its contents. Returns `200`.
   - **Omitting `?mode=`** on a folder delete returns `400` with the error string `Deleting a folder requires ?mode=cascade … or ?mode=promote …`.
   - **A missing id** (no row exists with that id) falls through to the legacy idempotent path and returns `200`, not `404`. This matters: do not assume a `200` from a folder-delete means the folder existed — it means "after this call, no row with that id exists." If you need that distinction, `GET /quick-replies/list` first.
7. **Bulk reorder with `POST /quick-replies/reorder`.** Body `{ orderedIds: ["id1", "id2", ...] }` (≤500 entries). The ids are not required to share a parent — the route assigns each id a sequential `displayOrder` matching its position in the array. Use this when you have a known desired order across the whole library; use `POST /quick-replies/move` with `beforeId` when you only need to slot one row into place.
8. **Export the whole library with `GET /quick-replies/export`.** No body; returns `200` with the bundle (`{ kind, version, exportedAt, appVersion, items }`). Plain JSON — save it, or pipe it straight into `POST /quick-replies/import` on another Omniscio machine. `items` is a flat array; each row names its own `parentId` and its prepend/append refs in the FILE's id space, so the tree round-trips. `useCount`/`lastUsedAt` are absent by design.
9. **Merge a bundle with `POST /quick-replies/import`.** Body `{ bundle: <the /export payload> }` or `{ text: "<the same JSON as a string>" }`, plus an optional `sourceLabel` (defaults to `"command line"`). The merge is **add-only and repeatable**: an incoming row whose `type` AND `label` AND `text` already match a row here is skipped, so re-importing the same file adds nothing; a snippet label taken by different text arrives renamed `"<label> (2)"`; a `numberKey` survives only where the digit is free here and unclaimed by another row in the same file. Rows land in ONE transaction, so a failure adds nothing. Returns `200` with `{ added, skipped, renamed, tagsCreated, keysDropped }`. A malformed / non-library / newer-version / over-cap payload returns `400` with a plain-English reason — there is no preview round-trip, because nobody is watching a CLI call.

### What "apply-immediately" means here, vs the rest of the CLI

If you have used the cli-tags / cli-cron / cli-automations / settings / sessions CLI surfaces, the standard response shape for a mutating call is `202 Accepted` — the change is queued in Omniscio's inbox and the user has to click **Approve** before it lands. **None of that applies on this surface.** A `200` or `201` from any Quick Replies mutation means the row already exists / has been updated / has been moved / has been deleted in Omniscio's database, and every open Omniscio client has been told via the `QUICK_REPLIES_CHANGED` push. Tell the user "Done — the change is already live in Omniscio's Quick Replies picker", not "open the inbox and approve."

### Idempotency

There is **none**. The create body's Zod schema includes an optional `clientRequestId` field, but the route does not consult it — it is accepted for backward compatibility with older callers and does nothing. A network-hiccup retry on `POST /quick-replies/create` will produce two rows. If you do not know whether the previous call landed, call `GET /quick-replies/list` and search for the row instead of re-sending the create.

This is a deliberate KISS choice documented in the [cli-quick-replies-contract.md § Architecture — current truth](/.claude/memory/contracts/cli-quick-replies-contract.md): Quick Replies are personal text templates, low-risk and fully reversible, so the cost of an accidental duplicate row (the user deletes it in two clicks) is lower than the cost of a real idempotency table the contract has to maintain. If your script genuinely needs at-most-once semantics, do a read-then-write yourself.

### Examples

```bash
# Read the bearer token from Omniscio's auto-delivered file
TOKEN=$(<~/.amc/cli-token)

# 1. List everything (flat)
curl -s -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/quick-replies/list

# 2. Read the library as a real tree
curl -s -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/quick-replies/tree
# {
#   "ok": true,
#   "data": [
#     { "item": {"id":"f1","type":"folder","label":"Greetings","parentId":null,...},
#       "children": [
#         { "item": {"id":"qr1","type":"snippet","label":"Hi",...}, "children": [] }
#       ]
#     },
#     { "item": {"id":"qr-top","type":"snippet","label":"Standalone","parentId":null,...}, "children": [] }
#   ]
# }

# 3. Create a top-level "Greetings" folder
curl -X POST http://127.0.0.1:19519/quick-replies/create \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"quickReply":{"type":"folder","label":"Greetings"}}'
# 201 with {ok:true,data:{id:"<folder-uuid>",type:"folder",label:"Greetings",parentId:null,...}}

# 4. Create a "Hi" snippet INSIDE the Greetings folder
curl -X POST http://127.0.0.1:19519/quick-replies/create \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "quickReply": {
      "type": "snippet",
      "label": "Hi",
      "text": "Hello — thanks for the message.",
      "autoSubmit": false,
      "parentId": "<folder-uuid>"
    }
  }'

# 5. Create a divider at the top level
curl -X POST http://127.0.0.1:19519/quick-replies/create \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"quickReply":{"type":"divider","label":"— Greetings —"}}'

# 6. Rename a snippet / change its text (PATCH does NOT accept parentId)
curl -X PATCH http://127.0.0.1:19519/quick-replies/<id> \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label":"Hello there","text":"Hello — thanks for reaching out."}'

# 7. Move a snippet into the Greetings folder (append at the end)
curl -X POST http://127.0.0.1:19519/quick-replies/move \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"<snippet-id>","parentId":"<folder-id>"}'

# 8. Move a snippet into the Greetings folder, positioned before a specific sibling
curl -X POST http://127.0.0.1:19519/quick-replies/move \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"<snippet-id>","parentId":"<folder-id>","beforeId":"<sibling-id>"}'

# 9. Move a snippet back to the top level
curl -X POST http://127.0.0.1:19519/quick-replies/move \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"<snippet-id>","parentId":null}'

# 10. Delete a snippet (legacy idempotent path)
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/quick-replies/<snippet-id>

# 11. Delete a folder AND everything inside it
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:19519/quick-replies/<folder-id>?mode=cascade"

# 12. "Ungroup" a folder — move its children up one level then delete the empty folder
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  "http://127.0.0.1:19519/quick-replies/<folder-id>?mode=promote"

# 13. Bulk-reorder the top-level rows
curl -X POST http://127.0.0.1:19519/quick-replies/reorder \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"orderedIds":["id-a","id-b","id-c","id-d"]}'

# 14. Export the whole library as a portable bundle
curl -s -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/quick-replies/export
# {"ok":true,"data":{"kind":"omniscio.quick-replies","version":1,"items":[ ... ]}}

# 15. Import a bundle (add-only merge) — wrap the exported file in a "bundle" key
jq -c '{bundle: .}' library.json > import-body.json
curl -X POST http://127.0.0.1:19519/quick-replies/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @import-body.json
# 200 with {"ok":true,"data":{"added":12,"skipped":3,"renamed":1,"tagsCreated":2,"keysDropped":0}}
```

### Errors

| HTTP  | Meaning                                                                                                                                                                                                                                                                                              | Recover                                                         |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `400` | Body failed Zod validation (empty label, missing required field, unknown key on a `.strict()` schema — including `parentId` / `displayOrder` / `type` / `id` on `PATCH`), OR folder delete without `?mode=cascade` or `?mode=promote`, **or double-encoded (mojibake) text** in `label`/`text`/`name`, OR `POST /quick-replies/import` with a payload that is not a readable library (bad JSON, wrong `kind`, a `version` newer than this build, over the 2000-item / 5 MB cap, or an entry with no id) — the message says plainly which. | Fix the body (re-send the text as clean UTF-8) / add `?mode=` / pick a file Omniscio exported. |
| `401` | Missing or invalid bearer on **any** route — reads (`GET /list`, `GET /tree`, `GET /export`) and mutations (`POST /create`, `POST /move`, `PATCH /:id`, `DELETE /:id`, `POST /reorder`, `POST /restore`, `POST /import`) alike. This surface has no authenticated-but-denied case, so `403` never appears here.                                        | Re-read the token (see [cli-control.md](cli-control.md)).       |
| `404` | `PATCH /quick-replies/:id` on an unknown id, OR `POST /quick-replies/move` with an unknown `id`. **Folder delete on an unknown id falls through to the idempotent legacy path and returns `200`, NOT `404`** — see workflow step 6.                                                                    | Re-list with `GET /quick-replies/list`.                         |
| `409` | `POST /quick-replies/move` would create a cycle — moving a folder into itself or into one of its own descendants.                                                                                                                                                                                    | Pick a different `parentId`.                                    |
| `413` | The request body is bigger than the route accepts. Every route defaults to a **1 MB** body cap; `POST /quick-replies/import` raises its own to the feature's own 5 MB envelope plus slack for the `{ "bundle": … }` wrapper, so this fires only past that. Rejected by the body parser, before the route runs. | Nothing was written. Split the library into two files and import them one after the other — the merge is add-only and repeatable. |
| `429` | Shared 10-mutations-per-minute rate limit.                                                                                                                                                                                                                                                           | Wait 60 seconds.                                                |
| `500` | Server-side failure.                                                                                                                                                                                                                                                                                 | Surface the `error` and tell the user to check Omniscio's logs. |

### How it works

The ten routes are registered by `registerQuickReplyRoutes()` in [src/main/services/cli/cli-server-quick-reply-routes.ts](/src/main/services/cli/cli-server-quick-reply-routes.ts), called once at Omniscio startup alongside the other route-registration functions on the same server.

**Schemas** (Zod, in [src/shared/ipc-schemas/quick-replies.ts](/src/shared/ipc-schemas/quick-replies.ts)):

- `quickReplyRestCreateSchema` (line 130) — a **discriminated union** on `type`. The `snippet` variant requires `label` (1-50 chars, trimmed) + `text` (1-500,000 chars — the same cap the IPC create and REST update schemas use), with optional `autoSubmit: boolean` (default `false`) and optional `parentId: string | null`. The `divider` variant requires `label` only, optional `parentId`. The `folder` variant requires `label` only, optional `parentId`. `clientRequestId` is accepted for backward compatibility but ignored by the route (see "Idempotency").
- `quickReplyRestUpdateSchema` — `.strict()`. Accepts any subset of the editable fields: `label`, `text`, `autoSubmit`, the auto-title group (`autoTitleEnabled`, `autoTitleText`, `autoTitleAppendCount`, `autoTitleAppendTimestamp`), `latestOverrideEnabled`/`latestOverrideText`, and `prepend`/`appendQuickReplyIds` — at parity with the IPC `updateQuickReplySchema`. Every other key (including `parentId`, `displayOrder`, `type`, `id`) returns 400. Reparent via `POST /quick-replies/move`. The strictness is **load-bearing** — it stops a script that meant to reparent from accidentally silently no-op'ing because the route happily accepted and dropped the `parentId`.
- `quickReplyRestMoveSchema` (line 205) — `.strict()`. Strict `{ id: string, parentId: string | null, beforeId?: string | null }`. Maps 1:1 onto `queries-quick-replies.moveItem(id, parentId, beforeId)`.
- `quickReplyRestReorderSchema` (line 196) — `{ orderedIds: string[] }` capped at 500 entries.
- `quickReplyCliImportSchema` (line 516) — the body for `POST /quick-replies/import`: exactly one of `bundle` (object) or `text` (string), plus an optional `sourceLabel`. The route hands the chosen form to `importQuickReplies(text, sourceLabel)` in [src/main/services/quick/quick-reply-transfer.ts](/src/main/services/quick/quick-reply-transfer.ts) — the SAME merge the in-app Import button runs (contract: `both-doors-are-the-same-door`) — and `GET /quick-replies/export` returns `buildQuickReplyExport()` from that same module. A `QuickReplyImportPayloadError` maps to `400` with its plain-English reason; anything else is a `500`. The import emits the `QUICK_REPLIES_CHANGED` push and the `imported_cli` telemetry verb, with no metadata (reply text can hold PII, so the `quick_reply` registry entry allow-lists none — the counts ride the response and the log line).

**Corruption guards.** Every write schema (`create`/`update` for both IPC and REST, plus folder-create) carries `.superRefine(noReplacementCharacterRefinement)` (rejects `U+FFFD`) **and** `.superRefine(noMojibakeRefinement)` (rejects CP1252 double-encoded text — an em-dash arriving as `â€"`, ≥2 corrupted runs, via the shared [`detectLikelyMojibake`](/src/shared/mojibake-detector.ts)); both surface as `400`. This stops a mis-decoded capture (the PowerShell `Get-Content` without `-Encoding utf8` trap) from persisting garbled text that then replays on every use. See contract invariant **I8**.

**Route pipeline** (mutating routes — `POST /create`, `POST /move`, `PATCH /:id`, `DELETE /:id`, `POST /reorder`, `POST /import`):

1. **`gateAuthMutation`** at the dispatcher level — checks bearer (`401` on missing), enforces the global 10-mutations-per-minute bucket (`429` on bust). This is the same gate every other mutation on the CLI server uses.
2. **Body parse** via the matching Zod schema — `400` on validation failure with the Zod error message in the `error` field.
3. **Direct call into the underlying query** in [src/main/db/queries-quick-replies.ts](/src/main/db/queries-quick-replies.ts):
   - `createQuickReply(label, text, autoSubmit, {}, [], parentId)` for snippets,
   - `createQuickReplyDivider(label, parentId)` for dividers,
   - `createFolder(label, parentId)` for folders (line 421),
   - `updateQuickReply(id, patch)` for PATCH,
   - `moveItem(id, parentId, beforeId)` for move (line 503),
   - `deleteQuickReply(id)` + `clearQuickReplyReferences(id)` for snippet / divider delete,
   - `deleteFolder(id, mode)` for folder delete (line 611) with `mode: 'cascade' | 'promote'`,
   - `reorderQuickReplies(orderedIds)` for reorder.

   `DELETE /quick-replies/:id` looks up the row first via `getQuickReply(id)`. If the row exists and has `type === 'folder'`, the `?mode=` gate runs and `deleteFolder()` dispatches; otherwise the route falls through to the snippet/divider path (which is idempotent — a missing id still returns 200). That `type` check is load-bearing: a folder silently demoted to a snippet would orphan its children at delete time.

4. **`moveItem` error mapping** in [cli-server-quick-reply-routes.ts](/src/main/services/cli/cli-server-quick-reply-routes.ts):
   - `'Item not found'` → `404` via `notFoundResponse(res)`.
   - Message containing `'descendant'` OR `'Cycle'` → `409 { ok: false, error: <message> }`.
   - Any other thrown error rethrows to the outer `internalErrorResponse` 500 handler.

5. **Emit push** — `emitPush(IPC.QUICK_REPLIES_CHANGED, {})` (zero payload — clients refetch on the signal).
6. **Track event** — `trackEvent('quick_reply', '<verb>_cli', { source: 'cli' })`. The verbs are `created_cli`, `updated_cli`, `moved_cli`, `deleted_cli`, `folder_deleted_cli`, `reordered_cli`.
7. **Respond** — `201` on create (with the new row), `200` on every other successful mutation.

**Route pipeline** (read routes — `GET /list`, `GET /tree`, `GET /export`):

1. **`authenticateRequest()`** — plain bearer-check. Returns `401 { ok: false, error: 'unauthorized' }` on missing or invalid token. **Not** `gateAuthRead` — the deliberate parity with the pre-existing `/quick-replies/list` is documented in the contract: both endpoints expose the same data, only in different shapes, and diverging gates between them would surprise.
2. **Direct call** into `listQuickReplies()` (flat) or `getFolderTree()` (line 453, nested).
3. **Respond** — `200 { ok: true, data: <rows-or-tree> }`.

**Tree node shape** — `QuickReplyTreeNode` in [src/shared/types/quick-replies.ts](/src/shared/types/quick-replies.ts):

```ts
type QuickReplyTreeNode = {
  item: QuickReply
  children: QuickReplyTreeNode[]
}
```

Top-level nodes carry `item.parentId === null`. Children at every level are sorted by `displayOrder` ascending. `getFolderTree()` builds the tree in a single SQLite pass — there is no N+1.

**Telemetry verbs**: `created_cli`, `updated_cli`, `moved_cli`, `deleted_cli`, `folder_deleted_cli`, `reordered_cli`, `restored_cli`, `imported_cli` — the `_cli` suffix lets the in-app analytics distinguish CLI-driven changes from in-app picker edits.

**Test coverage** — every invariant on this surface is locked by a test:

- [`tests/unit/quick-reply-rest-schemas.test.ts`](/tests/unit/quick-reply-rest-schemas.test.ts) — schema discriminated-union shapes, `.strict()` rejections on PATCH and move, optional `parentId` defaulting to `null`, folder-variant acceptance.
- [`tests/unit/cli-server-quick-replies-routes.test.ts`](/tests/unit/cli-server-quick-replies-routes.test.ts) — every route's auth tier (uniform `401 unauthorized` on missing/invalid token, reads and mutations alike), parentId forwarding into the correct DB helper, tree shape, the folder-delete `?mode=` gate, the `'Item not found'` → 404 / `descendant`/`Cycle` → 409 error mapping, and the push + telemetry wiring on each mutation.
- [`tests/unit/quick-reply-mojibake-guard.test.ts`](/tests/unit/quick-reply-mojibake-guard.test.ts) — the five write schemas reject CP1252 double-encoded (mojibake) text while accepting genuine em-dashes, arrows, smart quotes, emoji, and accented / non-English letters (contract I8).

The contract at [.claude/memory/contracts/cli-quick-replies-contract.md](/.claude/memory/contracts/cli-quick-replies-contract.md) enumerates the eight invariants I1–I8 and cites the exact `describe(...)` block in those tests that fails when each one is touched. Read it before changing this surface.

### Out of scope (v1)

- **No idempotency dedup.** `clientRequestId` is accepted in the create body for backward compatibility but ignored. A retry produces a duplicate row. Documented above; deliberate KISS choice. (`POST /quick-replies/import` is the one exception in spirit: re-importing the same bundle is a no-op because identical label+text rows are skipped, but that is a property of the merge, not a request-id dedup.)
- **No replace / overwrite mode on import.** `POST /quick-replies/import` only ever ADDS rows — there is deliberately no mode that replaces, clears or edits the existing library, so a bundle can be applied without any risk to what is already there. A script that wants a clean slate must delete first with `DELETE /quick-replies/:id`.
- **No transactional bulk endpoint.** There is no `POST /quick-replies/bulk` that creates a folder + its children atomically. Build the tree top-down: create the folder, read back its `id`, then create each child with that id as `parentId`. If you discover a mistake mid-script, walk the changes back individually with `POST /quick-replies/move` and `DELETE /quick-replies/:id`.
- **No `type` conversion via PATCH.** The update schema does not accept `type` — you cannot turn a snippet into a folder or vice versa. Delete and re-create.
- **No AHK / hotkey CLI control.** The Quick Replies CLI manages the library only. The flow that pastes a snippet's text into a running session's textarea via an AutoHotkey hotkey is configured in **Settings → Quick Replies → Behavior** and is not reachable through this surface.
- **No per-session apply route.** Tags have a `POST /sessions/:id/tags` route for attaching a library tag to one session. Quick Replies do not — the picker lives on the user's screen, not on a session row, so applying a snippet "to a session" doesn't have a clean meaning at the CLI layer.

## Related

How the picker itself is arranged and used from inside the app — dragging a snippet into a folder, the inline editor, and the two ways of deleting a folder — is on the [Quick Replies](quick-replies.md) page. If what you want is the moment a snippet is the right reply, see [Use quick responses](use-quick-responses.md), and [Quick reply declutter nudge](quick-reply-declutter-nudge.md) covers keeping a library that keeps growing under control.

- [quick-replies.md](quick-replies.md) — the in-app companion: the virtual project's three-tab sidebar, the drag-into-folder behavior, the inline editor's Content + Behavior tabs, the two-button cascade-vs-promote delete dialog, and the Export / Share / Import actions that share a code path with the two routes above.
- [cli-control.md](cli-control.md) — the CLI server itself: token handling, the full route catalog, the 10-mutations-per-minute mutation bucket, the 60-reads-per-minute read budget that this surface is exempt from.
- [cli-tags.md](cli-tags.md) — the closest sibling CLI surface (multi-route library management for tag library). The contrast is instructive: cli-tags has an inbox-approval round-trip on library mutations and honors `X-Client-Request-Id` for idempotency; cli-quick-replies has neither, because Quick Replies are personal text and tag library changes carry blast radius across sessions.
- [cli-pending-actions.md](cli-pending-actions.md) — the inbox-approval surface used by every approval-gated CLI mutation. **Quick Replies do not land here** — every Quick Replies mutation skips the queue and applies inline.
- [omniscio-control quick-replies skill](/.claude/skills/omniscio-control/quick-replies.md) — the external-AI side of the contract: how an agent decides which route to call when the user says "add a quick reply for X" or "make a Greetings folder".
- [cli-quick-replies-contract.md](/.claude/memory/contracts/cli-quick-replies-contract.md) — the test-locked invariants I1–I8 with citations into the routes and schemas tests. Read this before changing the surface.
