---
title: CLI Tags Control (manage the tag library from external AI)
---

# CLI Tags Control (manage the tag library from external AI)

## What it is

Omniscio's tag library — the curated colored labels you attach to sessions for triage (`bug`, `urgent`, `client-acme`, `followup`) — is reachable from outside Omniscio over the same `127.0.0.1:19519` localhost HTTP server that already exposes cron jobs, automations, recipes, and settings. 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 list every library tag, propose new ones, edit existing ones, soft-delete obsolete ones, and apply or unapply tags to specific sessions — all from a shell.

The library itself is the curated tag system described in [tag-manager.md](tag-manager.md): `tags` rows with name / color / description / icon / scope (`global` or `specific projects`), applied to sessions via the `session_tags` join. This page covers the **eight CLI routes** that expose that system over HTTP. The free-form per-session tag strings stored on the `sessions.tags` JSON column (described in [session-tags.md](session-tags.md)) are NOT reachable through CLI in v1 — only library tags.

The **gating model is split across the eight routes by blast radius**:

- **Library mutations are approval-gated.** `POST /tags` (create), `PATCH /tags/:id` (update), and `DELETE /tags/:id` (soft-delete with cascade) all enqueue `tags.create` / `tags.update` / `tags.delete` rows in the same `cli_pending_actions` queue used by cron jobs, automations, settings PATCH, and session lifecycle. The route returns HTTP 202 immediately; the change does not apply until the user clicks **Approve** in the Omniscio inbox. The user-in-the-loop guarantee is identical to the twenty-one other approval-gated CLI capabilities documented in [cli-pending-actions.md](cli-pending-actions.md), and shares the same open-pending cap.
- **Per-session apply / unapply is immediate.** `POST /sessions/:id/tags` (apply a library tag to one session) and `DELETE /sessions/:id/tags/:tagId` (remove that application) apply inline with no inbox row. The reasoning matches the in-app **T**-hotkey picker — applying a tag to a single session is reversible by removing the chip, the per-session 10-tag cap is enforced at insert time, and the project-scope visibility rule is checked synchronously, so adding an approval gate would just add latency without any new safety.
- **Reads are bearer-gated and read-budgeted.** `GET /tags` (list every active library tag with session counts) and `GET /sessions/:id/tags` (list applied tags for one session) require the bearer token and share the 60-reads-per-minute-per-token-hash budget with all other auth-gated GETs on this server.

The split mirrors the in-app surface: library management lives in **Settings → Tags** (deliberate, dialog-driven, low-frequency), per-session apply lives behind the **T** hotkey (frequent, lightweight, instantly reversible).

## Where to find it

There is no Omniscio screen for any of this — the whole feature lives outside the app. Its surface
is the local HTTP control server on your own machine, the same one that already answers for cron
jobs, automations, recipes, and settings; an outside AI agent or a script reaches it by holding
your Omniscio token. Anything that changes your tag library is not applied straight away: it is
queued and appears in the Omniscio inbox as a card you approve or reject by hand.

## How it behaves

### How to use it

The eight routes:

| Method   | Path                        | Approval-gated? | Status         | Purpose                                                                                                                                            |
| -------- | --------------------------- | --------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET`    | `/tags`                     | n/a (read)      | `200 OK`       | List every active library tag with derived `scope`, `projectIds`, `sessionCount`. Optional `?projectId=X` narrows to tags visible in that project. |
| `POST`   | `/tags`                     | **Yes**         | `202 Accepted` | Queue creation of a new library tag. Body: `{ name, color?, description?, icon?, projectIds? }`.                                                   |
| `PATCH`  | `/tags/:id`                 | **Yes**         | `202 Accepted` | Queue an update. Body is a partial — only included fields change; omitted fields are preserved. At least one field is required.                    |
| `DELETE` | `/tags/:id`                 | **Yes**         | `202 Accepted` | Queue soft-delete. Preview text embeds the cascade size (`Delete tag "foo" (used by 3 sessions)`).                                                 |
| `GET`    | `/tags/:id/sessions`        | n/a (read)      | `200 OK`       | Reverse lookup — every session id carrying this library tag (what the Tags view uses to show "everything tagged X"). Unknown id returns an empty list. |
| `POST`   | `/sessions/:id/tags`        | No (immediate)  | `200 OK`       | Apply a library tag to one session. Body: `{ tagId }`.                                                                                             |
| `DELETE` | `/sessions/:id/tags/:tagId` | No (immediate)  | `200 OK`       | Remove that application. No body.                                                                                                                  |
| `GET`    | `/sessions/:id/tags`        | n/a (read)      | `200 OK`       | List `TagApplication` rows (`{ sessionId, tagId, appliedAt, appliedBy }`) for one session.                                                         |

All eight require the bearer token in `Authorization: Bearer <token>` (or `?token=<token>`). The mutating five share the global **10-mutations-per-minute** rate limit; the three GETs share the **60-reads-per-minute** read budget per token hash.

### Workflow

1. **Discover what's already there with `GET /tags`** before proposing duplicates. The response includes `scope` (`'global' | 'project'`), `projectIds` (empty array when global), and `sessionCount` per row, so you know what's in use and what's free.
2. **Propose a new tag with `POST /tags`** — name + color + scope. Send `X-Client-Request-Id` (≤64 chars) so retries are idempotent in a 30-day window. The route returns HTTP 202 with the queued row's `id`. Tell the user "open Omniscio's inbox and approve to apply" and stop. The tag does not exist until the user clicks **Approve**.
3. **Edit a tag with `PATCH /tags/:id`** — partial body, only the fields you want to change. Omitted fields are preserved at dispatch time (the dispatcher merges the patch into the existing row), so you can rename a tag without re-sending its color and scope. Same approval flow as create.
4. **Delete a tag with `DELETE /tags/:id`** — no body. Approval card preview embeds the cascade size — "Delete tag 'foo' (used by 3 sessions)" — so the user sees the blast radius before approving. Approval cascades: the dispatcher hard-deletes every `session_tags` row for that tag, then soft-deletes the library row (`is_deleted = 1`). Free-form tag strings of the same name on `sessions.tags` are NOT touched.
5. **Apply a library tag to a session** with `POST /sessions/:id/tags` body `{ tagId }`. Returns 200 immediately on success. Returns 409 with `reason: 'cap'` if that session already carries 10 tags (combined library + free-form), or `reason: 'visibility'` if the tag's project scope excludes this session's project.
6. **Remove an application** with `DELETE /sessions/:id/tags/:tagId`. Returns 200 if a row was actually deleted, 404 if the tag wasn't applied to that session.
7. **Inspect what's applied** with `GET /sessions/:id/tags` — returns the raw join rows. Pair with `GET /tags` if you need the tag color / name.

### Approval flow recap

For the three approval-gated routes (`POST /tags`, `PATCH /tags/:id`, `DELETE /tags/:id`):

1. Route returns HTTP 202 with `{ ok: true, data: { id, actionKind, status: 'pending', previewText, ... } }`.
2. The pending row appears in Omniscio's inbox as a yellow card titled with the preview text (e.g. `Create tag "urgent"`, `Update tag "bug"`, `Delete tag "stale" (used by 7 sessions)`).
3. The user clicks the card — a dedicated **TagsApprovalPane** opens (separate from the generic JSON-payload pane) showing a per-kind preview: name + color swatch + scope for create, before/after for update, name + cascade size for delete.
4. **Approve** → the dispatcher executes the side effect (create row + scope rows / merge patch / cascade-delete + soft-delete), emits `tags:changed` (with `affectedSessionIds` for delete), and stamps `dispatched_at`. **Reject** → row flips to `rejected` with the reason, no side effect.
5. The renderer's open clients receive the `tags:changed` push and refresh the library + session chip strips automatically.

The dispatcher distinguishes **permanent** from **transient** dispatch failures. Permanent — name collides at approve-time (someone else created the same name in-app), tag soft-deleted between enqueue and approval, target row missing — flips the pending row to `rejected` with `PermanentDispatchError` so the user doesn't loop forever clicking Approve. Transient — DB lock, network blip — reverts the row to `pending` for retry.

### Idempotency

The three approval-gated routes accept an `X-Client-Request-Id` header (≤64 chars). When set, the same id within a 30-day window returns the existing pending row (HTTP 200, body includes `idempotent: true`) instead of queuing a duplicate. The lookup is **scoped per `action_kind`**, so the same id can map to a `tags.create` and a `tags.update` and a `tags.delete` independently.

The two immediate routes (`POST /sessions/:id/tags`, `DELETE /sessions/:id/tags/:tagId`) are idempotent at the database layer (`INSERT OR IGNORE` on apply, no-op delete on absent join row), so client-side idempotency keys aren't needed there.

### Examples

```bash
# Read the bearer token from Omniscio's auto-delivered file
TOKEN=$(<~/.amc/cli-token)

# 1. List every library tag with session counts
curl -s -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/tags

# 2. Propose creating a global "urgent" tag (red, with description)
curl -X POST http://127.0.0.1:19519/tags \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Client-Request-Id: tag-create-urgent-2026-05-07" \
  -d '{
    "name": "urgent",
    "color": "#ff4444",
    "description": "Needs response within 1 hour",
    "icon": "alert"
  }'
# Returns 202 with the pending row. The tag doesn't exist until the user
# approves in Omniscio's inbox.

# 3. Propose creating a project-scoped tag (visible only to two projects)
curl -X POST http://127.0.0.1:19519/tags \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Client-Request-Id: tag-create-acme-2026-05-07" \
  -d '{
    "name": "client-acme",
    "color": "#1f9d55",
    "projectIds": ["proj-uuid-a", "proj-uuid-b"]
  }'

# 4. Rename a tag (PATCH only the name; color, description, scope preserved)
curl -X PATCH http://127.0.0.1:19519/tags/<tag-uuid> \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Client-Request-Id: tag-rename-2026-05-07" \
  -d '{"name": "high-priority"}'

# 5. Delete a tag (cascade size shown in approval preview)
curl -X DELETE http://127.0.0.1:19519/tags/<tag-uuid> \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Client-Request-Id: tag-delete-stale-2026-05-07"

# 6. Apply a library tag to a session (immediate — no approval)
curl -X POST http://127.0.0.1:19519/sessions/<session-uuid>/tags \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tagId": "<tag-uuid>"}'
# 200 OK on success
# 409 {"reason":"cap"} if the session already carries 10 tags
# 409 {"reason":"visibility"} if the tag's scope excludes this session's project

# 7. Remove a tag from a session (immediate)
curl -X DELETE http://127.0.0.1:19519/sessions/<session-uuid>/tags/<tag-uuid> \
  -H "Authorization: Bearer $TOKEN"
# 200 if a row was actually removed
# 404 if the tag wasn't applied to that session

# 8. Inspect what's applied to a session
curl -s -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:19519/sessions/<session-uuid>/tags
# {"ok":true,"data":{"applications":[{"sessionId":"...","tagId":"...","appliedAt":"...","appliedBy":"user"},...]}}
```

### Out of scope (v1)

- **Free-form per-session tag strings** are not reachable through the CLI — only library tags. Free-form tags live on `sessions.tags` JSON column and are managed exclusively through the in-app **T**-hotkey picker. (See [session-tags.md](session-tags.md) for what those are.)
- **`applied_by: 'cli'`** — the database has the field but the CLI routes hard-code `applied_by: 'user'` so a CLI-applied tag is indistinguishable from a user-applied tag in the UI. Distinguishing them was deferred until there's a UX surface that benefits from the distinction.
- **Bulk apply / unapply** — there is no `POST /sessions/tags/bulk` route. Apply per-session if you need to fan out across many sessions; the 10-mutations-per-minute rate limit caps the burst at 10 per minute (and the in-app UI doesn't have a bulk-apply surface either).

## For agents

### How it works

The eight routes live in [src/main/services/cli/cli-server-tags-routes.ts](/src/main/services/cli/cli-server-tags-routes.ts), registered once at Omniscio startup from [src/main/index.ts](/src/main/index.ts) (after `registerBookmarkRoutes()`). Tests register them explicitly inside `beforeAll` after `startCliServer()` to keep the canonical real-HTTP + real-SQLite test pattern.

**Schemas** (Zod, strict-mode):

- `cliTagCreateSchema` — `name` required (1-30 chars, trimmed), `color` / `description` / `icon` / `projectIds` all optional. Lives in [src/shared/ipc-schemas.ts](/src/shared/ipc-schemas.ts) at line 3933.
- `cliTagUpdateSchema` — same fields all optional, plus a `.refine()` that requires at least one key (so the dispatcher always has something to do).
- `cliSessionTagApplySchema` — strict `{ tagId: string }`.

The schemas are deliberately a **subset** of the in-app `tagCreateSchema` / `tagUpdateSchema` accepted by the IPC handlers — every field a CLI caller can send is a valid value for in-app dispatch, so there's no approval-time-rejection footgun where a CLI-built payload passes the route's Zod check but fails the dispatcher's revalidate.

**Approval-gated route pipeline** (`POST /tags`, `PATCH /tags/:id`, `DELETE /tags/:id`):

1. **Rate-limit check** — `checkRateLimit()` from [cli-server.ts](/src/main/services/cli/cli-server.ts) (10 mutations / minute global). 429 on bust.
2. **Body parse** — `cliTagCreateSchema.safeParse(body)`. 400 on validation failure.
3. **Existence pre-check** (PATCH / DELETE only) — `getTagById(id)` → 404 if missing. Saves the user from approving a card whose target is already gone.
4. **Idempotency lookup** — `findIdempotent(clientRequestId, actionKind)` from [queries-cli-pending.ts](/src/main/db/queries-cli-pending.ts) returns the existing row if the same `(clientRequestId, action_kind)` was inserted within the last 30 days. The lookup uses the unique partial index `idx_cli_pending_request ON (client_request_id, action_kind) WHERE client_request_id IS NOT NULL`. Returns HTTP 200 with `idempotent: true` on hit.
5. **Open-cap check** — `getOpenCount() >= CLI_PENDING_MAX_OPEN` (20). 409 on bust.
6. **Insert pending row** — `insertPending({actionKind, targetId, payloadJson, previewText, clientRequestId})`. The previewText is built per-kind: `Create tag "<name>"`, `Update tag "<existing-name>"`, `Delete tag "<existing-name>" (used by N session(s))`. All preview text is sliced to 80 chars.
7. **Emit push** — `emitPush(IPC.CLI_PENDING_CHANGED, { id: row.id })` so the renderer's inbox refreshes.
8. **Track event** — `trackEvent('cli_control', 'hit', { path: '/tags/<verb>' })`.
9. **Respond** — `202 { ok: true, data: row }` on insert, `200 { ok: true, data: row, idempotent: true }` on idempotent replay.

**Immediate route pipeline** (`POST /sessions/:id/tags`, `DELETE /sessions/:id/tags/:tagId`):

1. Rate-limit check (10/min).
2. Body parse (apply only — unapply has no body).
3. Direct call into the underlying query: `applyTagToSession(sessionId, tagId, 'user')` or `unapplyTagFromSession(sessionId, tagId)` from [queries-tags.ts](/src/main/db/queries-tags.ts). The `applyTagToSession` query enforces the 10-cap and the project-scope visibility rule inside a single transaction; the `unapplyTagFromSession` query just deletes the join row.
4. **Map internal reason codes** to friendlier external names: `TAG_CAP_REACHED` → `reason: 'cap'`, `TAG_NOT_VISIBLE` → `reason: 'visibility'`. Both surface as 409. External CLI consumers don't pin to internal reason names.
5. Emit `IPC.SESSION_TAGS_CHANGED` with `{ sessionId }` on success.
6. Respond — `200 { ok: true }` on apply / unapply success, `409 { ok: false, reason, error }` on cap / visibility, `404 { ok: false, error: 'Tag is not applied to this session' }` on unapply-of-absent.

**GET routes**:

- `GET /tags` — bearer-gated, read-budgeted (60/min/token-hash). Optional `?projectId=X` narrows to `listTags({ projectId })`; otherwise `listTags()` returns every active tag. Response: `{ ok: true, data: { tags } }`.
- `GET /sessions/:id/tags` — bearer-gated, read-budgeted, pre-checks `getSession(id)` for 404 (so unknown / soft-deleted ids surface explicitly rather than returning `[]`), then returns `tagsAppliedToSession(sessionId)`.

**Dispatcher arms** for the three approval-gated kinds live in [src/main/services/cli/cli-pending-dispatcher.ts](/src/main/services/cli/cli-pending-dispatcher.ts):

- **`tags.create`** — re-validates the payload, checks for a name collision against the live library (case-insensitive on `LOWER(name) WHERE is_deleted = 0`; throws `PermanentDispatchError` with `TAG_NAME_TAKEN` on hit), calls `createTag({...})`, emits `tags:changed`.
- **`tags.update`** — re-resolves the target row by id (404 → `PermanentDispatchError`), checks for a rename collision if `name` is in the patch, calls `updateTag(targetId, payload)` (which performs the partial merge — undefined fields preserved), emits `tags:changed`.
- **`tags.delete`** — calls `softDeleteTag(targetId)` which atomically `DELETE`s every `session_tags` row for that tag, then `UPDATE`s the library row to `is_deleted = 1`, returns `{ affectedSessionIds }`. The dispatcher emits `tags:changed` with `affectedSessionIds` so the renderer can invalidate per-session caches.

The dedicated **TagsApprovalPane** at [src/renderer/src/features/cli-pending/TagsApprovalPane.tsx](/src/renderer/src/features/cli-pending/TagsApprovalPane.tsx) is selected by `CliPendingApprovalModal` early-return when `row.actionKind` is one of the three tag kinds. It parses the payload JSON per-kind and renders a structured preview: name + color swatch + icon + scope label for create, target + per-field "New X" rows for update (only the keys that were patched), and tag name for delete (the cascade count is in the row's `previewText` shown above the pane). Approve / reject go through the standard `resolveInboxApproval('cli-pending', row.id, decision)` consumer in [stores/inbox-actions.ts](/src/renderer/src/stores/inbox-actions.ts), so post-resolution navigation is identical to every other CLI pending kind.

## Related

The in-app library these routes drive is described on the [tag manager](tag-manager.md) page; the
free-form per-session tags that sit outside them are on the [session tags](session-tags.md) page;
the approval queue every mutation lands in is on the [CLI pending actions](cli-pending-actions.md)
page; and the server itself — token handling and the full route catalog — is on the
[CLI control](cli-control.md) page.

- [tag-manager.md](tag-manager.md) — the in-app library system (Settings → Tags), scope rules, sticky behaviour, deletion cascade, IPC contract.
- [session-tags.md](session-tags.md) — the free-form per-session tag flavour that coexists with library tags but is NOT reachable through CLI in v1.
- [cli-pending-actions.md](cli-pending-actions.md) — the shared approval queue every approval-gated CLI mutation lands in.
- [cli-control.md](cli-control.md) — the CLI server itself, token handling, the full route catalog.
- [omniscio-control skill](/.claude/skills/omniscio-control/tags.md) — the external-AI side of the contract: how an AI queues these actions in practice.
