API.md
Rama main · réplica de lectura del repositorio original.
# 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.
```bash
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
```
```bash
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`. The same reads are
also an MCP server, `python3 -m clique mcp`, read-only for now. See
`skills/drive-clique/mcp.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.
### Pairing a device
A token is forty-odd characters. Nobody types that into a phone, so a native
client had no reasonable way in. Pairing trades a short code for a real token.
The rule above still holds: **the authorisation happens on the box.** Somebody
already inside the panel asks for a code and the box displays it. What crosses
the network is only the redemption of a short secret the box chose, single use,
expiring in two minutes, with never more than one outstanding.
#### `POST /api/pair`
Authenticated. Mints a code, replacing any code already waiting.
```json
{ "code": "K7PM-3XQF", "expires_in": 120 }
```
The alphabet has no `0`/`O`, `1`/`I`/`L` or `U`. The dash is cosmetic and is
ignored coming back in, as is case.
#### `GET /api/pair`
Authenticated. `{ "expires_in": 87 }`, or `0` when nothing is outstanding, so
the panel can show a countdown.
#### `DELETE /api/pair`
Authenticated. Takes the code back off the screen without waiting it out.
#### `POST /api/pair/claim`
**The one unauthenticated write**, because the device making it has no
credential yet. `{ "code": "k7pm-3xqf", "name": "Justin's Pixel" }` returns
`201` and a token, named after the device so it can be recognised and revoked
later:
```json
{ "token": "mxp_...", "id": "tk_1a2b3c4d", "name": "Justin's Pixel" }
```
Anything else is `403 pairing code not accepted`, and deliberately so for every
reason: wrong, expired, already used, none outstanding, too many attempts.
Saying which would tell a guesser where they are. Five wrong guesses burn the
code, and twelve attempts a minute across all codes is the ceiling.
The same code also signs a browser in, so a phone can scan a QR instead of
typing the panel password. This is not new authority: the code already buys a
full bearer token through `/api/pair/claim`, and a session cookie is nothing
it did not already grant.
#### `GET /?pair=<code>`
Unauthenticated. Serves the sign-in page with the code filled in. The dash is
cosmetic and case does not matter, same as claiming. An already signed-in
request is unchanged: it gets the panel, not a login page.
#### `POST /`
The browser login form. `password` is the original field. `pair` is the
alternative, accepted when `password` is absent: a correct outstanding code
issues the same session cookie a correct password does. Anything else is
`401` and `That code is no longer valid.`, one message for every kind of no.
## 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`, `quick_commands`, `models`, `model_cmd`, `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`, `quick_commands`), `models` and `model_cmd` (one-tap model switch: send `model_cmd` with `{model}` filled in, or as typed when `models` is empty), `adopted`, `archived`, `pinned`, `draft`, `state`,
`saying` — the last line a session actually printed, captured for anything
carrying a `signal` (waiting, errored, or compacting). 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), `cpu` (CPU percent of that same process tree since the last poll; `0.0` on the first sample, because a rate needs two readings; can pass 100 when the session is using more than one core), `sub_clis` (CLI ids currently running as descendant processes of this session, e.g. `["grok", "codex"]`; empty when none), 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/briefing`
Which sessions need a person, computed rather than generated: no LLM involved,
just the same `state` the sidebar already shows, filtered to `waiting` and
`error`. One call instead of opening every tab to find out.
```json
[{"id": "abc123", "name": "checkout-fix", "cli": "claude", "cwd": "/srv/app",
"folder": "clients", "branch": "main", "dirty": true, "state": "waiting",
"activity": 1787200000, "rss": 483183820, "cpu": 12.5, "draft": "",
"lines": ["Allow Codex to run `npm test`?"]}]
```
Each row: `id`, `name`, `cli`, `cwd`, `folder`, `branch`, `dirty`, `state`,
`activity`, `rss`, `cpu` — straight from `/api/state`'s own fields, nothing recomputed
a second way. `draft` is whatever is half-typed in that session's prompt box,
unsent — the closest thing this codebase keeps to "what was I about to tell
it", not a log of prompts actually sent. `lines` is the same frame-filtered
tail a `/peek` returns, fixed at 20 lines since there is no hover to fall back
on.
### `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,
`rss` bytes and `cpu` percent (the same process-tree reading a session
carries, `0.0` on the first sample), heaviest by memory 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/sessions` → `201`
```json
{"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.
With no `folder` the session lands in Ungrouped; a folder's directory `match`
only auto-files adopted sessions, by whole directory (`/srv/work` files
`/srv/work/app`, never `/srv/workshop`), longest match first.
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.
If the repo has a `.clique-setup` file, tracked and executable, it runs once in
the new worktree before the CLI starts, output visible in the pane. A
`.clique-copy` file, one relative path per line, names files to carry over from
the primary checkout first — the untracked ones a fresh worktree would
otherwise lack, `.env` foremost. Never a glob, and a path leaving the checkout
is skipped rather than followed. Both are ordinary files in the repo, so a
worktree made on any machine gets them without CLIque being told anything.
A setup script that fails is reported in the pane; the session starts anyway.
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.
`draft_rev` is optional and only matters with `draft`: `[writer, n]`, a string
naming the page and a number it increases on every draft write. A draft whose
`n` is not above the last one applied from the same writer is ignored, so a
save that lands late cannot overwrite a newer one. Without it, `draft` applies
as sent.
### `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`.
```json
{"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`
```json
{"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/spawn` → `201`
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`
```json
{"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`
```json
{"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`
```json
{"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:
```json
{"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.
```json
{"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:
```bash
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"`, `"compacting"`
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. `"compacting"` is pattern-only: it is not stuck, so it is not
something this endpoint asserts — a pane whose visible text matches a
`compacting` pattern (`"(?i)compacting"` by default, every CLI) is the only
way to get it.
### `GET /api/workspace?cwd=/srv/app`
What is already going on in a directory, asked before starting something in it.
```json
{"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.
```json
{"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.
```json
{"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/workspace` → `201` with the same shape
```json
{"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.
```json
{"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.
```json
{"asked": "docs/foo.md", "path": "/srv/app/docs/foo.md", "name": "foo.md",
"kind": "text", "size": 1204, "text": "# Foo\n", "truncated": false}
```
Reads are fenced to the session folder (`CLIQUE_FENCE_READS`), with one
exception: anything under the system temp folder is readable, because CLIs
write drafts and scratch files there and print the path. Never writable. A
refused path comes back as `missing` with an `error` saying why.
`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:
```json
[{"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.
```json
{"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": [...], "routes": {...}, "features":
[...], "encryption": <bool>}`. Each provider is `{id, label, kind, base_url,
model, key_set}`. `routes` and `features` are the same ones `POST
/api/llm/routes` reads and writes. Read scope.
- `POST /api/llm/providers` → `201` 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", "error"?}]}`. 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.
By default, only CLIs that declare a probe **and** have a session open are
asked, so a panel with nothing running makes no outbound call. `?all=1` widens
that to every *installed* CLI with a probe, session or not — an explicit ask
(the usage sidebar panel makes it), not something the routine poll does on its
own. `?refresh=1` bypasses the cache below. 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) still returns an entry for that CLI,
with empty `windows` and a short `error` string saying which ("no token (not
signed in?)", "HTTP 401", "reply is not JSON", ...). The route itself never
fails over it, and a CLI with no probe declared gets no entry at all. 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/themes` → `201` 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/generate` → `201` 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/groups` → `201`
```json
{"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:
```json
{"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/folders` → `201` with the whole folder — body `name`, `color`
- `PATCH /api/folders/<id>` → the folder — `name`, `color`, `emoji`, `collapsed`, `auto_resume` (true: once a usage limit resets, type the CLI's `after_limit`, default "continue", into this folder's sessions that stopped on it; needs a CLI with a `usage` probe and `limited` patterns in `clis.toml`)
- `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.
```json
[{"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:
```toml
[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"` \| `"nerd"` | 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. A chain only protects you from a font being *absent*: one that is installed with no regular face makes every line italic, which is what `"ubuntu"` did on a Windows machine carrying an italic-only Ubuntu Mono |
| `term_line_height` | 100-160 | Row height as a percent of the font's own. Default 100. Fewer rows fit as it rises, and the pane refits to match |
| `term_letter_spacing` | 0-4 | Extra pixels between letters. Default 0. Fewer columns fit as it rises, and the pane refits to match |
| `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", "bar", "color"}`; malformed entries are dropped here rather than becoming a render error later. `bar: true` also shows it as a one-tap button on the shortcuts bar, on every CLI, and then `trigger` may be empty — a bar-only snippet is never typed. `color` is a hex string tinting that pill, or `""` for the default accent tint; anything not a plain hex triple is dropped to `""` |
| `session_templates` | list | `{"name", "cli", "cwd", "prompt", "folder", "worktree"}`. A saved new-session form. `cli` and `cwd` are required — a template with either empty is dropped. `name` (80 chars), `cwd` (1024), `prompt` (same cap as a snippet), `cli` and `folder` (64 each) are length-capped. `name`, `prompt` and `folder` may be blank. `worktree` is a bool, default false |
| `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 |
| `local_echo` | bool | Draw each typed letter in the pane at once, above the terminal, and remove it when the real echo arrives. Printable ASCII on the main screen only; never on a line asking for a password, on a full-screen program, or with the cursor hidden. A guess nothing confirms within a second turns it off until the next Enter. Only a display; what is sent is unchanged. 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. JSON text frames are control, handling `resize`, `refresh` and running
a command; binary frames are keystrokes. 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 |