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.
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_xxxxxxxxcurl -H "Authorization: Bearer $CLIQUE_TOKEN" http://127.0.0.1:3200/api/stateDriving 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.
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.
CPU, memory, swap, disk, load and connected terminals; the history form returns a series, clamped to 180 minutes.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
{"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.
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.
Stops the process. The record stays in its folder, stopped. Start it again
with /start. Closing a tab does not come here.
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.
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.
{"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.
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.
{"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.
{"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.
{"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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
{"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.
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.
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.
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.
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.
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.
Marks it looked-at. Returns the new last_seen, which is what the unread dot
compares tmux's activity clock against.
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.
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": [...]}.
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"]}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/providers→201the redacted provider. Body{label, kind, base_url, model, key}.kindisopenai(any OpenAI-compatible endpoint — OpenRouter, Groq, Together, local Ollama) oranthropic;base_urlmust be http(s). Write scope.POST /api/llm/providers/<id>— update. Same fields; an absent or emptykeykeeps the stored one. Returns the redacted provider, or404.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.404if unknown.POST /api/llm/providers/<id>/delete→{"ok": true}, or404.POST /api/llm/routes— point a feature at a provider. Body{feature, provider_id}; an absent/emptyprovider_idclears the route.featureis one of the names inGET /api/llm/providers'sfeatureslist (currentlyinboxandtheme). 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.
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.
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_generateis whether a model provider is routed to thethemefeature. Read scope.POST /api/themes→201the stored theme. Body is a seed:label,base(light/dark),bg,fg,accent, and the six huesred,green,yellow,blue,magenta,cyan, each#rgbor#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 abasethat is neither light nor dark is a400naming the problem. Write scope.POST /api/themes/generate→201the stored theme. Body{prompt}, a description in words. Asks the provider routed to thethemefeature for a seed and then treats it exactly asPOST /api/themeswould.400when 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}, or404. 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.400when the pool is empty. Write scope.
Forty are kept; the oldest goes when a new one would exceed that.
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.
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.
{"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.
Fields: name, color, members, order. Returns the record, so a colour
that failed validation is visible rather than silently kept.
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.
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.
Removes the group. The sessions are untouched.
POST /api/folders→201with the whole folder — bodyname,colorPATCH /api/folders/<id>→ the folder —name,color,collapsedDELETE /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.
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.
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 |
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.
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 |