Skip to content

Latest commit

 

History

History
929 lines (722 loc) · 47.9 KB

File metadata and controls

929 lines (722 loc) · 47.9 KB

CLIque API

Everything the panel does, it does through this API — there is no action in the UI that is not an HTTP call you can make yourself. That is deliberate: an agent driving CLIque should never be reduced to pretending to be a browser.

This file is checked against the code by tools/api_drift.py, which runs as part of the test suite. A route or a setting that is not documented here fails the build, so this cannot quietly fall behind the app.

Authenticating

Two ways in, and they are not equivalent.

A session cookie — what the browser uses. Writes additionally require a same-origin Origin/Referer, so a cookie alone is not enough for a hostile page to act on your behalf.

A bearer token — what an agent or a script should use. Tokens are minted on the box, never over the network: an endpoint that creates credentials turns any other hole into permanent access.

python3 -m clique token create my-agent
python3 -m clique token create watcher --read-only
python3 -m clique token list
python3 -m clique token revoke tk_xxxxxxxx
curl -H "Authorization: Bearer $CLIQUE_TOKEN" http://127.0.0.1:3200/api/state

Driving CLIque from an agent — start sessions, send prompts, wait for one to finish — is written up in skills/drive-clique/SKILL.md.

A read-only token is refused on every write with 403 this API token is read-only. Bearer tokens skip the same-origin check — they are not a browser credential and nothing can attach them to a cross-site request by accident.

Requests to an unrecognised Host are refused with 403 before authentication runs at all.

Reading

GET /api/state

The whole panel in one object, and what the browser polls every three seconds.

Key
version e.g. 0.21.0+8f32d69
home the home directory of whoever started the server — where a new session starts when nothing better is known
home the home directory of whoever started the server — where a new session starts when nothing better is known
folders id, name, color, collapsed, order
sessions see below
clis every CLI the registry knows: id, label, command, installed, modes, color, icon
settings the full settings object — see Settings
stats the same snapshot as /api/stats

Each session carries own_input — whether this CLI draws its own input box, which is what input_mode: "auto" reads — plus id, name, cli, cli_label, cwd, project, folder, mode (and modes, mode_label), adopted, archived, pinned, draft, state, saying — the last line a session actually printed, and only for one that is waiting or has errored. It is what the sidebar shows in place of the working directory when something is asking for you: the ring says a session is blocked, this says what on. Empty for every other session, and captured only for the handful that are not, cached against the pane's own activity clock.

cols and rows are the shared tmux window's size, which is not necessarily what any one client is drawing at — every client attached to a session shares one size, so a second browser resizes the first one's pane. A client that finds these differ from its own terminal should say so; that is how a pane recovers from being resized by somebody else instead of sitting in tmux's dot-fill until something happens to jog it.

Each session also carries created, last_seen, order, rss (process-tree resident bytes), plus the live facts: alive, attached, command, activity (tmux's own clock) and busy.

branch and dirty come from git in that session's working directory — the branch name, and how many paths status --porcelain reports. Cached per repo for a few seconds so the poll does not pay for git every time. "" and 0 where there is no repo, no git, or git has not answered yet. This is not a git UI: it is the sentence the folder tree was missing.

busy starts from the activity clock — output within the last two seconds — but does not end there. A redraw counts as output, so a CLI that animates while it waits ticks the clock forever and would sit permanently on "working". Once a pane has claimed to be busy for eight seconds it has to prove it: the visible screen is captured and compared, and text that has not changed for four seconds is not work whatever the clock says. Quiet panes are never captured, so this costs nothing in the ordinary case. There is still no vendor API behind any of it, which is why it works for any CLI.

GET /api/stats · GET /api/stats/history?minutes=60

CPU, memory, swap, disk, load and connected terminals; the history form returns a series, clamped to 180 minutes.

GET /api/resumable

Every past conversation CLIque can find on disk, with the folder it belongs to already worked out. Feed a row's cli_session_id to POST /api/sessions to resume it.

GET /api/prompts?limit=400

Individual prompts you have sent, newest first and deduplicated by text, for the command palette's prompt search. Read from each CLI's own history — a prompt-log CLI whole, a transcript CLI from a bounded tail — never logged a second time by CLIque. Each row carries cli, cwd, project, text (the full prompt, to reuse), when, and cli_session_id. limit is capped at 400.

GET /api/adoptable

tmux sessions started by another tool that CLIque could take over, with the CLI guessed from the process tree. Already-known sessions are filtered out.

GET /api/sessions/<id>/transcript

The session's conversation as turns — {cli, name, turns} where each turn is {role, text}, oldest first, consecutive same-role turns merged. For a CLI that draws over the alternate screen (Claude, Grok) and so keeps no scroll-back of its own. Read from the CLI's own transcript, bounded to a tail (never the whole file), and only the typed turns: user prompts and the assistant's prose, not its thinking or tool calls. Empty for a CLI whose history is prompts-only.

GET /api/sessions/<id>/usage

Tokens a session has spent, summed from its own transcript — {name, cli, tokens: {input, output, cache_read, cache_creation, total}, messages, has_data}. has_data is false for a CLI that keeps no usage (only Claude's dashed-dir transcript carries it) or one with nothing logged yet. Read on demand, not on the poll.

GET /api/sessions/<id>/diff

What the session's agent has changed but not committed, for review — {repo, name, diff, empty, truncated, untracked_hidden}. diff is git's own unified diff: tracked changes (against HEAD, or the empty tree in a repo with no commit yet — staged and unstaged together) plus new files git is not yet tracking, shown as all-additions and capped, with any beyond the cap named in untracked_hidden. The whole diff is capped by size too; truncated is true when it was cut. empty is true for a clean checkout; repo is false when the working directory is not a git repository (then there is no diff) — and, being git's answer, also false if git times out. Computed on demand, not on the poll.

GET /api/sessions/<id>/wait

Blocks until the session reaches one of the for states — a comma list of working, waiting, error, idle, stopped — or timeout seconds pass (default 60, capped at 300). Returns {id, state, matched, waited}; matched is false on a timeout. The primitive for driving CLIque from a script or an agent: start work, then wait for idle (done) or waiting (needs a person) instead of polling the whole panel. See skills/drive-clique/SKILL.md.

GET /api/orphans

Leaked sessions: live tmux on our own socket, past a short grace window, that no record points to — the process keeps running and holds its memory, but nothing in the panel can see or stop it. mux, command, pid, idle seconds and rss bytes, heaviest first. A record removed without killing its tmux is how they arise. Reclaim them with the route below.

GET /api/changelog

Release notes parsed out of CHANGELOG.md: version, date, time, zone, and blocks of spans. Structure rather than markup, so nothing has to render someone else's HTML. The settings sheet shows the newest five and links to the file on GitHub for the rest; this endpoint still returns the lot.

GET /healthz

No authentication. For Uptime Kuma, Gatus, Healthchecks or anything else that watches a URL — making a monitor carry a credential is how things end up unmonitored.

Anonymously it returns {"ok": true} and nothing else: no version, no session names, no counts. With a cookie or a token it adds version, uptime, tmux (reachable at all), sessions, alive and attached.

Sessions

POST /api/sessions201

{"cli": "claude", "cwd": "/home/you/project", "name": "project",
 "folder": "f-abc123", "mode": "default", "cli_session_id": null}

Only cli and cwd matter; the name defaults to the directory. mode is one the CLI declares. cli_session_id resumes a past conversation — the same code path as starting a new one, differing only in the argv the registry returns.

Pass worktree: true with a branch to run the session in a fresh git worktree of the repo at cwd — an isolated checkout, so several agents can work the same repo at once without touching each other's files. The response then carries the worktree path. Deleting such a session removes the worktree, but only when it has no uncommitted changes; a dirty one is left alone so nothing is lost.

A missing directory, an unknown CLI, or a CLI whose command is not installed is a 400 with the reason in error.

PATCH /api/sessions/<id>

Fields: name, folder (null means Ungrouped), mode, archived, pinned, draft. Only the fields you send are touched — absent and null are different, so a rename cannot silently unfile a session.

POST /api/sessions/<id>/kill

Stops the process. The record stays in its folder, stopped. Start it again with /start. Closing a tab does not come here.

POST /api/sessions/<id>/start

Starts a stopped session again — same id, name, folder and directory. When the CLI was first launched with our session id, that is also the resume key (Claude's --session-id). A shell has nothing to resume; it starts again in the same place. Already running is a 400.

{"id": "3afc8da8-...", "resumed": true}

resumed says whether the CLI's resume form was used rather than a fresh launch. A resume key can outlive the conversation it points at — a session opened, never typed in, then stopped — and the CLI then exits within a couple of seconds, leaving a tab that does nothing. Where the key is CLIque's own session id, the server watches the pane for eight seconds and, if it is gone, drops the key and starts the session clean. Nothing to call: it is the same endpoint, arriving a few seconds later.

DELETE /api/sessions/<id>

Forgets the record, and stops the process if it is still running. This is the destructive one. The UI only offers it on a session that is already stopped.

POST /api/sessions/<id>/send

{"text": "run the tests", "enter": true}
{"key": "C-c"}

Text is typed into the pane; enter decides whether it is submitted. key sends a single key by tmux name instead.

POST /api/sessions/spawn201

The new-session body plus count (1–20) — a fleet in one call. Each is an ordinary session with a numbered name; with worktree: true, each gets its own branch (<branch>-1, <branch>-2, …) so they never collide. Returns {created, errors} — the ids that started, and any messages for ones that did not. count: 1 is the same as a plain create.

POST /api/broadcast

{"text": "pull main and re-run the tests", "enter": true, "folder": "f-abc"}
{"text": "carry on", "ids": ["a1b2c3d4", "e5f6a7b8"]}
{"key": "Enter"}

The same send, to every live session at once — one instruction, everyone hears it. Narrow it with folder (one folder) or ids (an explicit list of session ids); omit both for all. Dead sessions are skipped. Returns {count, sent} — how many received it, and their ids.

POST /api/sessions/<id>/paste

{"data": "<base64>", "name": "shot.png"}

Writes an image into <cwd>/.claude-images and returns the path it saved. The type is sniffed from the bytes rather than trusted from the name, the cap is 10 MB, and containment is checked after symlink resolution — the write can only ever land inside that directory.

POST /api/sessions/<id>/upload

{"data": "<base64>", "name": "contract.pdf"}

The drag-and-drop sibling of /paste: a named file of any kind — a PDF, a log, a spreadsheet — dropped onto the window. Writes into <cwd>/.clique-drops under a sanitised basename (path separators and .. reduced away, so a name can only ever be a basename) and returns {path, relative, bytes}. The name is put through the same credential-and-fence gate a read is: a credential name (.env, id_rsa, a key) is refused, and with CLIQUE_FENCE_READS on the write cannot escape the session directory. A drop never overwrites — a colliding name gets a (1) suffix, and the write is an exclusive create. Cap 10 MB, 413 over it. Behind the write gate.

POST /api/sessions/<id>/checkpoint

Save a before-you-run snapshot of the session's git checkout: the current HEAD and branch, plus the uncommitted diff (staged, unstaged and untracked, bounded the same way /diff is) with a --stat summary. Written to a timestamped file under <cwd>/.clique-checkpoints/git apply -R <file> reverses the recorded changes — and returns {path, relative, head, branch, shortstat, empty}. 400 when the directory is not a git repository. A record, not a lock: it captures state, it does not freeze it.

POST /api/sessions/<id>/export

Write the session's scrollback to a timestamped text file. tmux holds the history; this captures it unstyled (a clean log, no colour codes) and drops it under <cwd>/.clique-exports/, returning {path, relative, bytes, lines}. 400 when there is nothing to capture (a session that is not running has no pane to read).

GET /api/sessions/<id>/notes and POST /api/sessions/<id>/notes

A per-session notes outline: a nested checklist shown in the side panel. GET returns {version, items, updated}, where each item is {id, text, done, collapsed, created, updated, remindAt, reminded, children[]} (items empty when there are none). POST with {items} replaces the whole outline (the browser owns the tree and sends it back on every change), or deletes it when items is empty. remindAt is a Unix time; when a webhook is configured a due reminder is delivered to it once (event reminder), which is what reminded records. Stored as a sidecar .json file under the panel's home (<CLIQUE_HOME>/notes/), keyed by session id, so a note never lands as an untracked file in the project it is about; a pre-outline .md note is migrated on first read. Capped at 200 KB, 2000 items and 6 levels deep (413 over the byte cap).

GET /api/storage

Returns {files, bytes, cleanup_days} — how much the scratch folders (.clique-drops, .claude-images) hold across every session, and the current auto-cleanup age. Nothing outside those folders is counted.

POST /api/storage/purge

Deletes every file in those scratch folders now, across all sessions, and returns {ok, files, bytes} freed. Only ever the flat contents of a scratch folder — never the folder, never anything nested, never a followed symlink, and never a project file. Behind the write gate.

POST /api/webhook/test

Fires one test event at the configured webhook immediately. 400 if no URL is set. There to answer "did I paste that right" without waiting for something to finish at three in the morning.

The webhook body, for all events:

{"event": "waiting", "at": 1787167125,
 "session": {"id": "...", "name": "api rewrite", "cli": "claude",
             "cli_label": "Claude Code", "folder": "...", "cwd": "/srv/app"},
 "text": "api rewrite is waiting for you",
 "url": "https://box.ts.net/clique/?session=..."}

event is waiting, error, finished, died, test or reminder. Each fires on the edge — a session waiting for an hour is not news every ten seconds, and a note reminder fires once per remindAt, guarded by a reminded flag the server keeps and the browser never sends. With webhook_secret set, X-CLIque-Signature: sha256=<hmac> covers the exact bytes sent. One attempt, five second timeout, no retry: a dropped notification is superseded by the next change, and a retry queue means durable state.

The watcher only runs while webhook_url is set, so a panel without one still costs nothing when idle.

POST /api/sessions/<id>/attention

An optional note alongside state says why it is waiting — "permission" (the inbox then offers Approve/Deny) or "idle" (a question or finished turn). Bounded, cleared with the signal.

{"state": "waiting"}

waiting, error, or clear. Lets a session say for itself that it is stuck, which is the only tier of the attention ladder that is not a guess — wire it to a hook your CLI already has:

curl -XPOST -H "Authorization: Bearer $CLIQUE_TOKEN" \
     -H "Content-Type: application/json" -d '{"state":"waiting"}' \
     "$CLIQUE_URL/api/sessions/$ID/attention"

The signal is stamped with the pane's activity clock and goes stale by itself the moment output arrives after it — a session that carried on is no longer waiting, and a stuck "waiting" would teach you to ignore the mark. Returns the resulting signal.

Sessions in /api/state carry signal: "waiting", "error" or "", from whichever tier could answer — this endpoint first, then the per-CLI patterns in clis.toml matched against a pane that has gone quiet, then nothing.

GET /api/workspace?cwd=/srv/app

What is already going on in a directory, asked before starting something in it.

{"cwd": "/srv/app", "exists": true, "branch": "main", "dirty": 2,
 "touched": 13, "sessions": [{"id": "...", "name": "api rewrite", "cli": "claude"}]}

sessions are live CLIque sessions whose working directory resolves to the same path; touched counts files written in the last 15 minutes; dirty and branch come from git and are 0/"" where there is no repo, no git, or a repo too large to answer within three seconds.

Advisory only — nothing is locked, refused or enforced. Pulled, never polled: this touches the disk, and it runs when someone has stopped typing a path.

GET /api/browse?path=/root/pers

Directories that could complete a partial path, the way a shell completes one: a trailing slash lists what is inside, anything else matches the last segment against its siblings. Directories only; hidden ones appear only once the segment being typed starts with a dot. Capped at 60.

{"dirs": ["/root/personal/whatbox-media-stack"]}

This is what the new-session dialog uses once you start typing a path. The dropdown beside it answers a different question — everywhere you have already worked — and neither is a substitute for the other.

GET /api/projects?q=sentinel

Project roots matching a name, for when you cannot remember where one lives. /api/browse completes a path you already know the start of; this answers the other question. Add refresh=1 to force a fresh walk instead of the cached one.

{"projects": [{"path": "/root/platform/wsg-sentinel", "name": "wsg-sentinel",
               "kind": "git"}],
 "partial": false, "total": 140}

A directory counts as a project when it holds .git, pyproject.toml, package.json, Cargo.toml, go.mod, pom.xml, Gemfile, composer.json or CMakeLists.txt; kind says which. Ranked by how the match happened, not by string distance: the directory called sentinel comes before one that merely has it somewhere in its path.

Empty q returns the shallowest roots, which is the nearest thing to "the projects you would name first". partial is true when the walk hit its depth, count or three-second budget and stopped, so a short answer is never silently short. total is how many were indexed in all.

Where it looks is the project_roots setting, and the home directory when that is empty. Hidden directories are never descended into, which is what keeps a cache or a virtualenv out of the walk, and the result is cached for two minutes.

POST /api/workspace201 with the same shape

{"cwd": "/srv/new-project"}

Creates the directory, parents included, and returns what GET would now say about it. A path that already exists as a directory is a success; a relative path, an empty one, or something that exists and is not a directory is a 400 with the reason in error.

Never implicit — nothing calls this except a person pressing the button that names the path. There is no sandbox on where, deliberately: anyone who can reach the panel already has a shell as this user, so a restriction here would protect nobody while breaking the ordinary case of working outside $HOME.

GET /api/sessions/<id>/peek?lines=8

The last few lines of a pane, so "is that one waiting on me" can be answered without opening the tab and changing what you are looking at.

{"lines": ["Ran 1 shell command", "Flummoxing… (4m 41s · thinking)"],
 "alive": true, "activity": 1787200000}

The lines that said something, not the last N raw rows. A modern CLI's pane is mostly frame — box rules, separators, an input box drawn around nothing — and showing that verbatim buries the one line that answers the question. A line is dropped when every character in it is a box-drawing glyph, a rule, a prompt mark or whitespace; that is a property of the text, not knowledge of any CLI, and a line with one real word in it is always kept.

Colour is stripped. lines is how many to return, clamped to 2–40, default 6; a wider window of scrollback is searched to find them. Nothing captures a pane until this is called — there is no poller behind it — and the answer is cached against the pane's own activity clock, so repeated calls while nothing is printed cost one capture.

GET /api/sessions/<id>/file?path=<path>

Read-only glance at a path the pane printed. Relative paths are against the session's working directory; absolute paths and ~/ are allowed because anyone who can reach the panel already has a shell as this user.

{"asked": "docs/foo.md", "path": "/srv/app/docs/foo.md", "name": "foo.md",
 "kind": "text", "size": 1204, "text": "# Foo\n", "truncated": false}

kind is text, image, binary, dir or missing. Text is capped; truncated is true when there is more. ?raw=1 on an image returns the bytes, typed from magic, same as an artifact.

A directory also carries entries: {name, kind, path} for each child, directories first, then files, capped. kind here is dir or file. path is the child as a path this same endpoint will accept. Listing does not follow a symlink out of the folder (the name stays in this directory); a click still goes through resolve, which follows and fences. truncated is true when the directory had more children than the cap. Parent .. is included when the directory is inside the session folder, not the folder itself.

POST /api/sessions/<id>/file{path, text}

Save edited text back to a file. Write scope. The path is resolved and fenced exactly as the read is — a credential file and (with the fence on) anything that resolves outside the session's directory are refused, symlinks followed — and a save only ever overwrites an existing regular file (never creates one, never a directory or device). Atomic (temp then rename), the file's mode preserved, capped at 2 MB. Returns {"ok": true, "bytes": n}; a refused or too-large save is a 400 with a reason.

A compiler-style suffix (foo.py:12 or foo.py:12:4) is stripped. This is not an editor.

GET /api/sessions/<id>/artifacts

Images that appeared in the session's working directory after the session started, newest first, capped at 30:

[{"name": "shot.png", "rel": "screenshots/shot.png",
  "path": "/srv/app/screenshots/shot.png", "size": 40122, "mtime": 1787200000.0}]

rel is what GET .../artifact takes; path is what an agent can open. Which directories are searched is the artifact_dirs setting, nothing is searched recursively, and symlinks are skipped. Returns [] when artifacts_show is off.

The since the session started filter is what makes this the agent's output rather than the project's artwork — a repository cloned during the session is the case it cannot tell apart.

GET /api/sessions/<id>/artifact?rel=<path>

The image itself. rel must be relative, must not climb with .., and is re-resolved against the working directory with containment checked after symlink resolution — a path that leaves it is 404, never served. The Content-Type comes from the file's magic bytes, not its extension; anything that is not an image CLIque recognises is 415, and over 10 MB is 413.

POST /api/sessions/<id>/seen

Marks it looked-at. Returns the new last_seen, which is what the unread dot compares tmux's activity clock against.

POST /api/sessions/adopt

Takes over every adoptable tmux session found. Safe to run twice — it repairs earlier runs rather than duplicating them. The sockets scanned for adoptable work default to a predecessor tool's (codeman, codeman-grok); set CLIQUE_FOREIGN_SOCKETS to a comma-separated list to override.

POST /api/orphans/reap

Kills leaked sessions (see GET /api/orphans) and reclaims their memory. Body {"muxes": [...]} limits it to those names; an empty or absent list reaps all of them. A mux that belongs to a real record is never touched. Returns {"killed": [...]}.

POST /api/reorder

The sidebar's drag-and-drop. Either list, or both. Unlisted items keep their place at the tail. Order is what /api/state returns.

{"sessions": ["id1", "id2", "id3"], "folders": ["f-aaaa", "f-bbbb"]}

Model providers (bring your own key)

Optional. Storing a key needs the crypto extra — pip install 'clique-panel[llm]' — so the key can be encrypted at rest (AES-256-GCM, a data key in a 0600 secret.key). Without it, a create/update carrying a key is refused 400 with that hint. The key is never returned by any endpoint; a provider reads back with key_set only, and it never rides /api/state.

  • GET /api/llm/providers{"providers": [...], "encryption": <bool>}. Each provider is {id, label, kind, base_url, model, key_set}. Read scope.
  • POST /api/llm/providers201 the redacted provider. Body {label, kind, base_url, model, key}. kind is openai (any OpenAI-compatible endpoint — OpenRouter, Groq, Together, local Ollama) or anthropic; base_url must be http(s). Write scope.
  • POST /api/llm/providers/<id> — update. Same fields; an absent or empty key keeps the stored one. Returns the redacted provider, or 404.
  • POST /api/llm/providers/<id>/test{"ok": true, "model", "sample"} or {"ok": false, "error"}. A cheap live probe: decrypts the key in-process, sends one bounded completion, returns whether it worked. 404 if unknown.
  • POST /api/llm/providers/<id>/delete{"ok": true}, or 404.
  • POST /api/llm/routes — point a feature at a provider. Body {feature, provider_id}; an absent/empty provider_id clears the route. feature is one of the names in GET /api/llm/providers's features list (currently inbox and theme). Returns {"routes": {...}}. Deleting a provider drops any route to it. Write scope.

Outbound calls refuse a base_url that resolves to a cloud-metadata / link-local address or (by default) an internal-network host; loopback stays allowed for local models. CLIQUE_LLM_ALLOW_PRIVATE=1 opts into private ranges.

Plan usage

GET /api/usage{"usage": [{"cli", "windows": [{"label", "percent", "resets_at"}], "checked"}]}. How much of a plan each running CLI has spent. Read scope.

Nothing in the panel knows whose API is being asked. A CLI's usage block in clis.toml says where its token file is, which field in it holds the token, which URL to ask and which fields in the reply are the numbers; the panel runs that description. Teaching it about another vendor is a block of TOML.

Only CLIs that declare a probe and have a session open are asked, so a panel with nothing running makes no outbound call. Readings are cached for five minutes and shared by every connected browser, and a failure is cached for the same five minutes so a machine with no credentials does not retry forever.

The token never leaves the process. It is read from disk, spent on one request, and dropped; what comes back over this route is a percentage and a reset time. Anything unexpected (no token, an expired one, no network, a reply in a shape the block did not describe) returns no entry for that CLI rather than an error. Set usage_bar to false to turn the whole thing off.

Themes

A theme is nine colours somebody chose and eighteen worked out from them. The built-in presets ship in the front end; this is only what was made here, so a fresh panel returns an empty list.

Both routes that create one run the same derivation and the same contrast pass, so a theme posted by hand gets exactly what a generated one gets. That matters more than it sounds: the settings sheet you would use to pick a different theme is drawn in the theme you are wearing, so one whose text vanishes into its background is not a bad theme, it is a panel you cannot navigate. Colours that cannot be read are pushed away from the background until they can be, rather than refused.

  • GET /api/themes{"themes": [...], "can_generate": <bool>}. Each theme is {id, label, base, panel, term, created}, complete and ready to use. can_generate is whether a model provider is routed to the theme feature. Read scope.
  • POST /api/themes201 the stored theme. Body is a seed: label, base (light/dark), bg, fg, accent, and the six hues red, green, yellow, blue, magenta, cyan, each #rgb or #rrggbb. The panel tokens, all sixteen ANSI colours with their brights, the cursor and the selection are derived. A colour that is not a colour, a missing hue, or a base that is neither light nor dark is a 400 naming the problem. Write scope.
  • POST /api/themes/generate201 the stored theme. Body {prompt}, a description in words. Asks the provider routed to the theme feature for a seed and then treats it exactly as POST /api/themes would. 400 when no provider is set up, when the description is empty, or when the model's reply was not a theme. Write scope.
  • POST /api/themes/<id>/delete{"ok": true}, or 404. Deleting the theme currently in use clears the setting back to the default, because leaving it pointing at a theme that no longer exists is a panel that comes back unpainted. It also comes out of the rotation pool. Write scope.
  • POST /api/themes/rotate{"theme": "<id>"}. Put on a different one from the rotation pool now, ignoring both the schedule and whether the rotation is switched on at all. 400 when the pool is empty. Write scope.

Forty are kept; the oldest goes when a new one would exceed that.

Rotating through the ones you like

Five settings, and the server never learns what a theme is — the presets live in web/themes.js and are the browser's business. It moves an id you chose.

theme_rotate_pool is the list of theme ids it may pick from, mixing preset ids and stored ids freely. There is no "all of them" default: an empty pool does nothing, because the point is the ones you like. Every theme_rotate_hours hours, anchored on theme_rotate_at, the next poll picks one at random and sets theme. Never the one already on, so a change always looks like one.

It rides the panel's own poll, so nothing happens while nobody has CLIque open, and the first poll after opening catches up to the most recent slot that has gone by — one slot, never a week of them.

Working groups

Sessions you open and see together. Not folders, and the difference is the whole feature: a folder files a session in the sidebar and a session has exactly one, while a group is about launching several things at once and being able to tell at a glance which tabs belong to which piece of work. A group can pull from several folders or none, a session can belong to more than one, and joining a group changes nothing about where a session lives.

Groups arrive whole in GET /api/state as groups, because the tab strip has to colour a tab by its group on the first paint and a second round trip for that is a flicker nobody asked for.

POST /api/groups201

{"name": "Duchamp morning", "color": "#7aa2f7", "members": []}

Returns the record. Capped at 40 groups; past that it is a 400, because a sidebar of forty groups is a worse sidebar than one of five.

PATCH /api/groups/<id>

Fields: name, color, members, order. Returns the record, so a colour that failed validation is visible rather than silently kept.

POST /api/groups/<id>/add and /remove{"session": "<id>"}

Membership. add stores a snapshot, not just the id: the session's cli, cwd and name go in beside it, so a member whose session is later deleted can be offered back instead of the group quietly being one short. A group that opens two of the three things it promised is worse than one that says so.

POST /api/groups/<id>/open

Starts every member and reports what happened to each, separately rather than as a count:

{"group": {...}, "sessions": ["a", "b"], "started": ["b"],
 "missing": [{"session": "c", "cli": "grok", "cwd": "/srv/x", "name": "old"}],
 "failed": []}

sessions is what is now running and ready for a tab, in member order. started is the subset that was stopped and has been started. missing is members whose session no longer exists: they are not recreated unless you pass {"recreate": true}, because a group silently spawning something somebody deleted on purpose is the worse failure. Recreating rewrites the member to point at the new session, so the next open does not strand it again.

Opening tabs is the browser's job. This is the half that has to happen on the server, and it is a route so that a script can open a working group too.

POST /api/groups/<id>/delete

Removes the group. The sessions are untouched.

Folders

  • POST /api/folders201 with the whole folder — body name, color
  • PATCH /api/folders/<id> → the folder — name, color, collapsed
  • DELETE /api/folders/<id> — sessions inside become Ungrouped

color is three or six hex digits with a leading #, and nothing else — it is written into a style attribute in the sidebar. Anything else is ignored and the folder keeps the colour it had, so read the response back rather than assuming the value you sent is the value that stuck.

Service status

GET /api/state carries services: the providers behind your running CLIs that are reporting a problem, worst first. Almost always empty — it holds the exceptions, not a commentary on four status pages being fine.

[{"cli": "claude", "label": "Claude Code", "indicator": "major",
  "description": "Elevated error rates", "url": "https://status.claude.com",
  "checked": 1787200000}]

indicator is Statuspage's own vocabulary: maintenance, minor, major or critical. none never appears — an operational service is not news. A reading older than an hour is dropped rather than shown, so a box that has lost DNS says nothing instead of leaving yesterday's outage on the screen.

The feed is a status block in clis.toml next to the launch command:

[cli.claude]
status = { url = "https://status.claude.com/api/v2/status.json",
           page = "https://status.claude.com" }

A second optional key sits beside it: own_input = true marks a CLI that draws its own input box at the bottom of the pane, so the panel does not stack a second one underneath. Purely about what is on screen — a shell prints > and is not doubled by anything, and there the panel's box is the only place Run, the repeat counter and a saved draft live.

url must be an Atlassian Statuspage v2 endpoint — that one format covers Anthropic, OpenAI, GitHub and Cursor, and a second parser here would be the first step towards a directory of per-vendor scrapers. page is what the panel links to. Adding a feed for a CLI we have never heard of is those two lines and a reload; a CLI with no block is never asked about.

Read every five minutes, and only for CLIs with a session open right now. An idle panel makes no requests. It sends no identifier, no session name and no query string. service_status: false stops the thread immediately.

Settings

PATCH /api/settings

Send only the keys you are changing; the merged object comes back. Unknown keys are ignored rather than stored. Values are clamped, not rejected, where a bad one could otherwise make the UI unusable and unfixable.

Everything a person chose lives here, on the server, so it survives a reload and follows them to another device. If you add a preference to CLIque, it goes in this object — localStorage is only for what is about the screen in front of you (sidebar width, sidebar shown or hidden).

Key Type
marker_default "both" | "icon" | "dot" | "none" Which mark a session gets by default
marker_by_cli object Per-CLI override; merges one level deep, so sending one CLI does not reset the rest. null clears one
markers_in_tabs bool Marks on tabs
markers_in_sidebar bool Marks in the sidebar
status_on_icon bool The CLI logo carries the status colour, instead of a second dot
theme string Preset id from web/themes.js; "" is the built-in
theme_rotate bool Wear a different theme every so often, picked at random from theme_rotate_pool. Off by default: a panel that changes its own colours unasked is a fault, not a feature
theme_rotate_hours int How often, in hours. 24 is a different theme every morning. Clamped to 1–720
theme_rotate_at string What time of day the change lands, "HH:MM" on the server's own clock. With an interval under a day it is the anchor the rest are counted from, so "07:00" every 6 hours is 07:00, 13:00, 19:00, 01:00. Anything that is not a real time is refused and the old value kept
theme_rotate_pool list The theme ids the rotation may choose from, preset and stored ids mixed freely. "" is the built-in dark theme and belongs here like any other id, not a missing value. Never checked against the themes that exist — the server has never read web/themes.js. Capped at 64
theme_rotate_last int Bookkeeping, not a preference: the slot last acted on, in unix seconds. It is what stops a panel shut all week applying six changes when it opens
appearance "dark" | "light" | "system" Base used when no preset is chosen
font_panel 9–28 Sidebar and chrome
font_terminal 9–28 The pane, read at a different distance. Also the +/ stepper in the bottom-right
font_family "system" | "menlo" | "consolas" | "ubuntu" | "courier" Monospace stack for the pane. Each id is a fallback chain that exists on Windows, Mac and Linux, so a missing font still lines up. Unknown ids are dropped
palette_hotkey bool Whether Ctrl+K opens the palette or is handed to the pane
history_in_sidebar bool Past conversations listed under live sessions. Off by default — a month of work is several hundred of them, and at that ratio the sidebar stops showing what is running. The palette still searches all of it
history_days int How far back the sidebar goes when the above is on. Default 14. Does not limit the palette
reap_idle_hours int Stop an idle session's process after this many hours to free its memory, greying its tab; clicking it resumes exactly where it was. Only a resumable session no browser is attached to and that is not busy is reaped. Default 6; 0 turns it off; clamped to 720
drop_cleanup_days int Auto-delete dropped/pasted files older than this many days from the scratch folders (.clique-drops, .claude-images); nothing else on disk is touched. Off by default (0) — a share is your file. Clamped to 365. The manual purge and the storage readout work whether this is on or off
input_mode "auto" | "panel" | "terminal" Whether the panel draws a prompt box. auto (default) asks the CLI — one that draws its own box gets no second one under it — except on a touch device, which always gets the box, because typing into the terminal goes through the phone keyboard's input method and Android duplicates the line. terminal still overrides it. The mode pill is never hidden by this
css_both, css_panel, css_terminal string Custom CSS, applied in that order
snippets list {"trigger", "label", "text"}; malformed entries are dropped here rather than becoming a render error later
notify_flash bool Flash a tab whose session finished
notify_sound bool Off by default: a room with twenty agents would be unbearable
notify_idle_seconds 2–120 Quiet before a session counts as finished
confirm_destructive bool Ask for a one-click confirm before a command matching destructive_patterns is sent from the prompt or a broadcast. A guard, not a block. On by default
destructive_patterns list of strings Plain, case-insensitive match substrings (never regex — nothing here is compiled or executed); trimmed, deduplicated, capped at 100 × 120 chars. Defaults to the catastrophic-and-rarely-meant commands (rm -rf /, mkfs, force-push, drop database, …); everyday rm -rf ./build is deliberately excluded
open_tabs list of session ids The workspace: which sessions have a tab, in order. Deduplicated, order preserved
active_tab session id Which one was in front
views_collapsed list Shut view-groups: __running, __unfiled, __archived
cli_tint bool Colour the pane edge, active tab and prompt box with the active CLI's colour
project_roots list of paths Where GET /api/projects looks when the new-session dialog is asked for a project by name. Empty means the home directory. Name directories here when your work lives elsewhere, or when home is big enough that the walk is worth narrowing
cli_watermark bool Draw the active CLI's logo faintly in the top-right of the pane, opposite the theme character. A single-colour glyph is masked and tinted with the CLI's own colour; a logo carrying its own colours is drawn as the image. A CLI with no icon draws nothing. Hidden on a pane under 720px or a window under 460px tall. On by default
theme_art bool Draw the theme's hand-drawn character in the bottom-right of the pane, behind the text. Only the seven character themes (plumber, triforce, fellowship, drizzt, chompy, bricks, aincrad) carry one; elsewhere it does nothing. Composited with lighten/darken so a glyph over it stays exactly as readable, and hidden on a pane under 720px or a window under 460px tall. On by default
cli_colors map Per-CLI colour overrides, {"claude": "#d97757"}. Merged one level deep like marker_by_cli; a null value restores the shipped colour. Must be a 3- or 6-digit hex, anything else is dropped
changelog_seen version Newest release whose notes have been read. Seeded on first load so a fresh install does not badge itself
service_status bool Ask the provider behind a running CLI whether it is having a bad day. The only outbound requests CLIque makes without being told to — see Service status below. On by default
clock_24h bool 24-hour clock. Not derived from the locale — people read one format at work and another at home
clock_zone IANA zone Clock on the empty pane, e.g. Europe/Lisbon. Validated against the system zone database; a name that is not real is dropped rather than stored, because Intl throws on one. Blank means the browser's own
webhook_url url Where to POST session events. http/https only; anything else is stored as "". Withheld from API tokens/api/state returns it as "" plus a webhook_url_set boolean for a token caller (a Discord/Slack URL path is itself a credential); the cookie operator who set it still sees the value
webhook_secret string Signs each request as X-CLIque-Signature. Write-only/api/state returns it as "" plus a webhook_secret_set boolean, so a read-only token cannot lift it and forge a signature. Send "" to remove one
panel_url url This panel's public address, included so a notification can link back
artifacts_show bool List the images a session makes
artifact_dirs list Where to look, relative to each session's cwd; . is the cwd itself. Absolute entries and .. are dropped, max 12

Terminals

GET /ws?id=<id>&cols=<n>&rows=<n> upgrades to a WebSocket carrying the pane. Text frames are keystrokes; JSON control frames handle resize, refresh and running a command. The handshake enforces Origin, because a WebSocket is not subject to CORS and SameSite=Lax does not cover it.

{"type": "refresh"} asks tmux to repaint this socket's own view at once, and takes no arguments. It is what a client sends after a layout change that left the grid the same size, because tmux has nothing to redraw for and the pane would otherwise keep the frame it already had until a keystroke. It touches one client, so a read-only token may send it: asking for your own screen back is not writing to the session.

{"type": "resize", "cols": n, "rows": n, "handheld": bool} sets this client's PTY and asks for the shared tmux window. handheld decides who wins when two panels disagree, and the server settles it, because neither client can. A tmux window has one size that every attached client sees, so a desktop panel and a phone cannot both be right; before this each asserted its own size on every poll, three seconds apart, forever, and the CLI reflowed between 162 and 42 columns for as long as both were open.

A handheld wins. A phone is picked up to do the thing that could not wait; a desktop panel is often merely open. While a phone holds the window, a desktop's resize still sizes its own PTY, so it keeps drawing at its own shape, but it does not move the window underneath the phone. document.hasFocus() cannot decide this: it is per browser window, and a desktop on one machine and a phone in a hand both report true, because both are true.

{"type": "hold"} and {"type": "release"} are how a phone says it is still awake and that it has stopped. hold is cheap by design, no tmux call and no resize, and exists because a phone that already owns the window has nothing left to resize and would otherwise let its claim lapse under itself. release is sent the moment the screen goes dark or the tab is hidden, so a desktop never waits out a timer after you put the phone down. A claim with neither goes stale after 90 seconds, which is the backstop for a phone that vanishes rather than the ordinary path. Both are ignored from a read-only viewer.

passive=1 attaches a viewer without resizing the shared tmux window, and sizes its PTY to the window that is already there. Each window is locked to manual size — attaching a client cannot move it, only an explicit resize from a focused pane. Used when a tab is warming in the background, or reconnecting while hidden. A resize below 20x8 is ignored; that is a collapsed tab measuring itself, not a real window.

The PTY is created on connect and destroyed on disconnect — no viewer, no process. Closing the socket does not stop the session; that is the whole point of tmux underneath. Use DELETE to actually end one.

Errors

400 Bad input — error says what
401 No credential, or one that is not valid
403 Read-only token, cross-origin write, or an unrecognised Host
404 No such route, session or folder
500 A bug; the traceback is in the journal