

Canopy
CLI Reference
Last updated
canopy [command] [options]
Running canopy with no command opens the TUI (running setup
first if needed, and checking for updates (notice only — run canopy update
to install)).
Daemon
| Command | Description |
|---|---|
canopy daemon start |
Start the daemon (MCP server, scheduler, watchers) |
canopy daemon stop |
Stop the daemon |
canopy daemon status |
Show daemon status and version; warns when the running daemon is not this CLI’s version |
canopy daemon restart |
Restart the daemon |
canopy daemon logs |
Show the daemon log |
canopy daemon install-service |
Register as a system service |
canopy daemon uninstall-service |
Remove the system service |
canopy daemon health-check |
Run the daily database integrity check and backup now, instead of waiting for the daemon to find an idle moment (see Installation) |
Updates
| Command | Description |
|---|---|
canopy update |
Check the latest stable release, ask Update to <tag>? [y/N] (default no), and install it atomically; prints canopy <current> → <latest> or canopy <current> is up to date |
canopy update --check |
Print the update status without changing anything; exits 0 when up to date, 1 when an update exists, and 2 when the check could not complete (network, DNS, TLS, HTTP error, or unparsable response — one stderr line names the cause) |
canopy update --yes |
Install without the confirmation prompt |
A cargo-installed binary is never overwritten: canopy update prints
installed with cargo — run: cargo install harness-canopy --force.
After a successful replacement, a running systemd-managed daemon is restarted
with systemctl --user restart canopy.service; an unmanaged daemon uses the
normal canopy daemon stop/start path. If a graph is running, update asks
before restarting and otherwise leaves the daemon untouched and prints the
command to run later.
Plain canopy update exits 2 on the same lookup errors.
Setup & diagnostics
| Command | Description |
|---|---|
canopy setup |
Interactive setup wizard — detects AI CLIs, generates ~/.canopy/config.toml. An uninstalled harness (binary no longer on PATH) is dropped from the config on its own; one still referenced by a crew, graph node, graph hook or scheduled agent is kept and canopy doctor warns about it once |
canopy setup --local-registry <path> |
Use a local registry directory (for registry development) |
canopy setup --force-skills |
Overwrite local skill files that diverge from the sync source, even when they were edited locally (default: skip them with a warning) |
canopy mcp |
MCP wizard — sync/add/remove canopy’s MCP entry across platforms |
canopy mcp --local-registry <path> |
Run the MCP wizard against a local registry directory instead of GitHub (same flag as canopy setup --local-registry) |
canopy mcp → Add |
Add a server via pasted README JSON, local stdio command (with args + env), or remote URL (with headers); previews entries, marks (replaces existing) overwrites, then asks Install on N platform(s)? |
canopy uninstall --dry-run |
Preview removal: stop daemon, remove service unit + canopy.service.d/ drop-ins, revert platform configs, remove skill symlinks (changes nothing) |
canopy uninstall --keep-shared-servers |
Only remove canopy’s own canopy server entry; leave shared fetch/filesystem entries in place |
canopy doctor |
Full health diagnostics (installed harnesses list with versions; an uninstalled harness stays silent unless something still references it) |
canopy doctor --verbose |
Same, plus the searched PATH for missing harnesses (hidden otherwise) |
Platform registry
| Command | Description |
|---|---|
canopy registry refresh |
Fetch the platform registry and apply it now, ignoring the 24-hour interval the TUI’s background refresh uses; prints one line per affected platform (<platform>: changed <fields>, <platform>: kept local override <fields>, <platform>: added, <platform>: updated, <platform>: removed), or registry already up to date when nothing changed. Rewrites only config.toml and the baseline file — never starts the TUI, wizard or daemon, safe while graphs run |
canopy registry refresh --json |
Print the same report as JSON (changed, kept, added, updated, deleted, up_to_date) |
canopy registry refresh --local-registry <path> |
Apply a local registry directory instead of fetching from GitHub (same flag as canopy setup --local-registry) |
Registry-owned CLI fields — binary path, model flag, headless mode,
environment variables — are rewritten from the registry. A field you edited
yourself is kept as it is; when the registry changed that same field, the
refresh reports it as a local override, so a refresh never overwrites your
choice. The command needs an existing ~/.canopy/config.toml — on a machine
that has not run setup it fails with run `canopy setup` first. The refresh
time is recorded where the TUI’s background refresh records it, so a manual
refresh also restarts that 24-hour window. A harness whose binary is gone is
pruned from the config on the same terms as setup, except on a grace window:
the binary must be absent at two checks at least ten minutes apart, so a
transient PATH problem never drops anything. The daemon runs the same
grace-gated check on every startup (and reinstalls re-appear on their own).
Graph inspection
| Command | Description |
|---|---|
canopy graph list |
List all graphs with status and spec progress |
canopy graph list --workdir <path> |
Filter graphs by workdir |
canopy graph list --json |
Print one JSON array on stdout (keys: id, name, status, workdir, current_spec, current_node, the last two null when absent) instead of the table |
canopy graph list --running |
Only graphs whose status is running (combinable with --workdir and --json); prints a compact one-line JSON array unless combined with --json |
canopy graph info <id-or-name> |
Detailed status for a single graph (specs, runs, hooks) |
canopy graph crew [id-or-name] |
List harness/model assignments — agent nodes outside ensembles, ensemble members and agent hooks, each with platform, model, timeout, crew and availability. Omit the graph for every graph |
canopy graph crew --platform <p> --model <m> |
Filter by exact platform and case-insensitive model substring |
canopy graph crew --all |
Include archived graphs |
canopy graph crew --group-by-pair |
Summarise per platform/model pair: graph, ensemble, node and hook counts plus the graph names |
canopy graph crew --json |
Print the MCP crew assignments JSON shape instead of tables |
canopy graph validate <path> |
Check a shared graph or node JSON document without importing it: every structural check import runs plus the sharing rules. Prints each problem with its JSON path; exits 0 when the document is valid, 1 when it is not |
canopy graph validate <path> --json |
Print the machine-readable report instead: ok with the kind, name and counts, or problems |
canopy graph install <ref> |
Install a graph from the catalogue in one command: <slug> or <owner>/<repo>/<slug>, each with an optional @<ref> suffix, or draft:<id> for an unreviewed editor draft (graphs.univerlab.org/editor, 30 minutes, installs once). Prints a review of every shell command and agent prompt the document runs (with attention marks), asks Install <name> from <source>? [y/N], then — after confirmation — validates the fetched document, installs its bundled presets, and imports it as a new graph |
canopy graph install <ref> [--workdir <dir>] [--name <name>] [--as <prefix>] [--replace] [--overwrite-presets] [--yes] [--show-prompts] [--json] |
Install into another workdir (default: the current directory), under another name, renaming colliding bundled presets under a prefix (--as alice installs review as alice-review), overwriting differing local preset files instead of refusing (--replace, like --overwrite-presets), skipping the confirmation question (--yes, after printing the review), printing every prompt in full (--show-prompts), or printing the review and report as machine-readable JSON |
A bare slug installs the official UniverLab entry; owner/repo/slug
installs a community one. A bundled preset already owned by another
source is refused before anything is written — rename it with
--as <prefix> or overwrite it with --replace.
canopy graph validate runs entirely locally — no daemon, no database,
no workdir — so it works before anything is installed. The rules it
enforces are in Graphs.
Graph ids can be specified by exact id, exact name, or unambiguous id prefix. Ambiguous references list the candidates.
With --json or --running the command goes through the daemon instead of
reading the database directly, so scripts can ask whether a graph is running
without opening background_agents.db. An empty result prints []. If the
daemon is unreachable it prints daemon not reachable on stderr (nothing on
stdout) and exits 3; it never starts the daemon.
Nodes
| Command | Description |
|---|---|
canopy node list [--json] |
List installed node documents and the builtins embedded in the binary, marking each installed or builtin and showing which catalogue entry installed it |
canopy node install <ref> |
Install a node document from the catalogue: <slug> or <owner>/<repo>/<slug>, each with an optional @<ref> suffix, or draft:<id> for an unreviewed editor draft. Prints the same review as graph install and asks for confirmation first; after confirmation it validates the document, installs its bundled preset, and writes it to ~/.canopy/nodes |
canopy node install <ref> [--as <name>] [--replace] [--overwrite-presets] [--yes] [--show-prompts] [--json] |
Install under another local name (--as, renaming the bundled preset with it), overwrite an installed copy from another source (--replace), overwrite differing local preset files instead of refusing, skip the confirmation question (--yes, after printing the review), print every prompt in full (--show-prompts), or print the review and report as machine-readable JSON |
canopy node remove <name> [--json] |
Remove an installed node document. A builtin with no installed copy cannot be removed; an installed override can |
A bare slug is the official UniverLab entry and owner/repo/slug a
community one. Installing a name already installed from a different
source is refused, naming both sources — --as <local-name> installs
it under another local name, --replace overwrites. Reinstalling from
the same source updates in place, and an installed copy still overrides
a builtin of the same name.
canopy node runs entirely locally — no daemon needed. See
Graphs.
Graph control
Mirrors the graph_control actions run/pause/continue/reset/
schedule_autorun — a second way to reach the same daemon
operations when an MCP client can’t. Every command resolves <id-or-name>
locally (same rule as graph inspection above) and then delegates the actual
state change to the daemon over its existing MCP endpoint; it never writes
to the database directly. If the daemon isn’t reachable on the resolved
port, the error says so explicitly instead of looking like the graph is
missing.
| Command | Description |
|---|---|
canopy graph run <id-or-name> |
Run a graph in the background, spec by spec |
canopy graph run <id-or-name> --queue <queue-id> |
Run the queue’s pending specs through the graph instead |
canopy graph run <id-or-name> --workdir <path> |
Override the graph’s workdir for this run only (the stored workdir is unchanged; every continuation of the run keeps using the override) |
canopy graph run <id-or-name> --from <node> |
Start the first spec this run executes at a chosen node (id, id prefix, node name, or ensemble name), reusing the recorded outputs of upstream nodes; refused, naming the node, if an upstream node’s latest run failed, errored or was interrupted |
canopy graph run <id-or-name> --idea <text> |
Run a spec-less idea graph: the text is fed to nodes as {{spec_content}} (mutually exclusive with --queue; the graph must have no bound specs and no queue) |
canopy graph pause <id-or-name> |
Pause a running graph after the current node finishes |
canopy graph continue <id-or-name> --retry-current-node |
Resume a paused or failed graph by retrying the current node (also resumes a paused graph whose current spec is pending with a recorded node; clears that spec’s execution count and the reply says so). On an idea-driven graph (one run with --idea), it resumes that idea run; an idea graph that has never run is refused, naming graph run --idea as the way to start one |
canopy graph continue <id-or-name> --skip-next-spec |
Resume a paused or failed graph by skipping to the next spec |
canopy graph continue <id-or-name> --from <node> [--spec <spec>] |
Start a spec at a chosen node, reusing the recorded outputs of upstream nodes (refused, naming the node, if one failed, errored or was interrupted; defaults to the graph’s current spec: a running/interrupted member, else a failed one, else the next pending one). On an idea-driven graph it resumes that idea run with the stored idea as {{spec_content}} and no --spec to give |
canopy graph reset <id-or-name> |
Reset a completed/failed graph back to pending (prompts for confirmation) |
canopy graph reset <id-or-name> --specs <id>... |
Reset specific spec ids, even if already completed |
canopy graph reset <id-or-name> --yes |
Skip the confirmation prompt |
canopy graph reset <id-or-name> --clear-notes |
Also delete the reset specs’ spec notes (default keeps them) |
canopy graph remove-spec <id-or-name> <spec-id> |
Unbind a spec from the graph, making it a standalone backlog spec; its execution history is kept |
canopy graph autorun <id-or-name> --at <iso8601> |
Schedule a one-shot resume at a future time |
canopy graph autorun <id-or-name> --quota-reset-message <text> |
Schedule a resume from a raw CLI quota-limit message |
canopy graph autorun <id-or-name> --cancel |
Cancel a pending autorun schedule |
canopy graph log <id-or-name> |
Print the graph’s durable event log, one line per event |
canopy graph log <id-or-name> --since <id|RFC3339> |
Start from an event id or a timestamp |
canopy graph log <id-or-name> --kind <k> |
Only events of this kind (repeatable) |
canopy graph log <id-or-name> --spec <id> |
Only this spec’s events |
canopy graph log <id-or-name> --follow |
Poll every 2 s and print new events until Ctrl+C |
canopy graph log prints <created_at> <KIND> spec=<name> node=<name> <message>
and goes through the daemon like the other graph subcommands. See the
Event log section.
A pause requested while a node runs is recorded at once and the graph
pauses at the next node boundary; meanwhile a second pause is a no-op
answering pause already pending, and continue/run cancel the pending
pause instead — see Pending pauses.
A continue on a sandboxed graph resumes inside that run’s sandbox worktree;
when the worktree is gone the continue is refused, naming the sandbox id,
rather than running the graph against the original workdir. A failed
sandboxed graph always keeps its worktree — see
Sandboxes.
Agents
Read-only inspection of registered agents, served from the local database
(like canopy graph info — no daemon required).
| Command | Description |
|---|---|
canopy agent show <id> |
Show one agent’s full stored definition, prompt last and untruncated |
canopy agent show <id> --json |
Print the same definition as JSON (identical to the agent get action’s output) |
Missions
Read-only listing of the 28 gamification missions (see TUI), served from the local database — no daemon required.
| Command | Description |
|---|---|
canopy missions |
List every mission: icon, title, category, unlocked <date> or locked: <challenge>, plus N / 28 unlocked |
canopy missions --json |
Print the same report as one JSON object (unlocked, total, missions with id, title, icon, category, challenge, unlocked_at as RFC 3339 UTC or null) |
Spec backlog
| Command | Description |
|---|---|
canopy spec create --name <name> --description <text> |
Create a standalone spec (a backlog item) |
canopy spec create ... --workdir <path> |
Tag the new spec with a workdir, for backlog filtering |
canopy spec create ... --spec-type <type> |
Set the Conventional Commits type (feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert). Optional — when omitted and the name is a conventional title, the type and scope are parsed from the name |
canopy spec create ... --spec-scope <scope> |
Set the optional scope (e.g. tui). Part of the deterministic commit message |
canopy spec create ... --queue <queue-id> |
Also append the new spec to an existing queue |
canopy spec create ... --queue <queue-id> --group <group> |
Add it to the queue within a context group |
canopy spec complete <spec-id> --reason <text> |
Mark a standalone spec as completed |
canopy spec skip <spec-id> --reason <text> |
Mark a standalone spec as skipped |
canopy spec reopen <spec-id> --reason <text> |
Reopen a completed/skipped spec back to pending |
canopy spec notes <spec-id> |
Print every spec note of a spec, newest first, with its owner, last update and body (see Spec notes) |
Availability roster
| Command | Description |
|---|---|
canopy roster list [--platform <platform>] |
List roster entries with their effective state now |
canopy roster set <platform> [<model_pattern>] --unavailable [--reason <text>] [--until <rfc3339>] |
Declare a platform/model pair unavailable; until must be a future RFC 3339 UTC instant |
canopy roster set <platform> [<model_pattern>] --available |
Declare it available |
canopy roster clear <platform> [<model_pattern>] |
Delete one entry (idempotent) |
canopy roster check <platform> [<model>] |
Show the effective availability of one pair |
Omitting <model_pattern> addresses every model of the platform; a pattern
ending in * (e.g. opencode-go/*) addresses a prefix. The engine skips
effectively-unavailable members at dispatch and blocks a single unavailable
agent node or hook — it never edits a graph.
Named mutexes
| Command | Description |
|---|---|
canopy mutex list [--json] |
List granted mutex leases and queued waiters (FIFO); --json prints the raw MCP shape |
Crews
| Command | Description |
|---|---|
canopy crew list |
List every crew with its version, members and binding counts |
canopy crew show <name> |
Show one crew and every binding of it |
canopy crew create <name> --from-ensemble <id> [--description <text>] |
Create a crew seeded from an ensemble’s current members |
canopy crew delete <name> |
Delete a crew (refused while any ensemble, node or hook binds it) |
A crew is a named, ordered member list ensembles and agent nodes/hooks bind to; member editing is MCP/TUI only (see MCP tools).
Shelf
| Command | Description |
|---|---|
canopy shelf list [--json] |
List every shelf file (the host shelf plus every session’s), newest first, through the daemon; --json prints the items with their file ids |
canopy shelf open <file_id> |
Open one shelf file with the system viewer, via the daemon |
Both subcommands go through the running daemon over the session-protocol
WebSocket — the CLI never writes the shelf itself. file_id is the
16-lowercase-hex id printed by canopy shelf list --json.
Remote
| Command | Description |
|---|---|
canopy remote login |
Sign in to GitHub with the OAuth device flow (identity only, no scopes) |
canopy remote logout |
Delete the stored GitHub identity |
canopy remote status [--json] |
Show the sign-in state, whether remote is enabled, the relay URL, the last relay error and reconnect time, and a config invalid, ...; config.toml: <reason> line while config.toml fails to parse (the last valid config stays live, and enabled/relay_url show those effective values); --json prints {signed_in, login, enabled, relay_url, last_error, last_reconnect} plus config_fault when invalid (last_error/last_reconnect are null when unrecorded; config_fault is omitted while the file parses; a successful reconnect clears last_error) |
canopy remote on / off |
Turn the relay connection on or off ([remote] enabled); the daemon picks the change up without a restart |
canopy remote pair |
Create a one-time code, print it with its <relay_url>/pair#<code> link and expiry, and draw the link as a terminal QR code |
canopy remote devices [--json] |
List paired browsers (id, label, paired_at, last_seen) |
canopy remote revoke <device_id> |
Delete a paired browser and close its live connection at once |
canopy remote disconnect <device_id> |
Close a paired browser’s live connection at once, keeping the pairing so it may reconnect |
canopy remote invite <login> [--days N] |
Issue a single-use guest invite code for a GitHub login, printed once with its guest link and expiry (default 7 days; stored as a hash only) |
canopy remote invites [--json] |
List guest invites (login, expiry, live/used/expired) |
canopy remote uninvite <code-or-login> |
Revoke one invite by code, or every live invite by login |
The same actions live in the TUI: F1, then 5 opens the hub’s Remote
tab — GitHub sign-in with the device flow, the relay switch and phase,
pairing with a QR overlay, and disconnecting and revoking devices (TUI).
Pairing a browser
Pairing turns a browser into a revocable device of this host. The code is
ten Crockford base32 characters shown as XXXXX-XXXXX, works once for ten
minutes, and is burned after one success or five failed code checks. One
code is live per host: canopy remote pair replaces the previous one, and
the old code stops working at once. Case, spaces and dashes do not matter
when the code is typed, I/L are read as 1 and O as 0.
canopy remote pair prints the code, the link and the expiry time, then
the link as a terminal QR code:
Pairing code: 7K3QH-M9P2X
Link: https://remote.univerlab.org/pair#7K3QH-M9P2X
Expires: 2026-10-28 15:04:05 UTC
<the link as a QR code, drawn with half blocks>
The browser opens that link and exchanges long-term P-256 public keys with
this host, authenticated by the code, so later connections need no code
and the relay never learns anything that lets it impersonate either side.
The exchange rides the daemon’s relay connection, so pairing completes
only while the daemon is running, remote status shows a login, and
remote is on. A failed exchange tells the browser only no_code,
expired or bad_code: run canopy remote pair again for a fresh code.
A guest browser — one signed in as a GitHub login other than the owner’s —
pairs only while that login holds a trust grant (add one from the TUI
Remote tab with t, or with the remote tool’s trust_add); without it
the exchange fails as bad_code, so trust the profile before handing out
the code.
canopy remote devices [--json] prints id, label, paired_at and
last_seen (never when the registry has no time), one line per device
or as a JSON array; no paired devices when there are none. canopy remote revoke <device_id> deletes the device and closes its live
connection within a second, printing revoked <device_id>, and refuses
an unknown id with no paired device with id <device_id>. canopy remote disconnect <device_id> closes the live connection the same way
but keeps the pairing, printing disconnected <device_id> and refusing
an unknown id the same way. Re-pairing the
same browser updates its label, paired_at and last_seen instead of
adding a row.
The host key lives in ~/.canopy/remote/host_key (PKCS#8 DER, mode 0600)
and host_id is the first 16 hex chars of SHA-256 over its public key;
the live code and its derived key sit in ~/.canopy/remote/pairing.json
(mode 0600, removed once the code is used, refused as expired, or burned —
deleting it cancels the code). Paired browsers are recorded in the
remote_devices table and identified by the first 16 hex chars of SHA-256
over their own key.
Inviting a guest
A guest has no paired device, so it cannot pair: the host hands it an
invite code instead. The code is CNR- plus 16 Crockford base32 characters
(80-bit random), works once, and lives 7 days unless --days N says
otherwise (at least 1 day). canopy remote invite <login> needs a signed-in
host, resolves the login to its GitHub uid, stores only the code’s SHA256
hash, and prints the code once with the guest link and its expiry:
Invite code for octocat: CNR-7K3Q-HM9P-2XAB-CDEF
Guest link: https://remote.univerlab.org/g/1001/76805a63a6980c18#invite=CNR-7K3Q-HM9P-2XAB-CDEF
Expires: 2026-11-11 15:04:05 UTC
Send octocat the link: it works once, after octocat signs in with GitHub.
The link is <relay_url>/g/<owner_uid>/<host_id>#invite=<code>: the
signed-in owner’s GitHub uid names the room, this host’s id names the
host, and the code rides the fragment, which the browser never sends to
the relay or in a Referer. Send it to the guest yourself (v1 has no
delivery). The guest page signs the guest in with GitHub, keeps the code
for that tab only, strips it from the address bar and opens the relay’s
guest door with it; a guest who got only the code opens
<relay_url>/g/<owner_uid>/<host_id> and types it into the form there.
A host with no host key yet (never paired or connected) prints the code
with a hint instead of the link: turn remote on, let the daemon connect,
and invite again. A note follows when remote is off, or when the login
holds no trust grant: the code alone does not admit the guest, the login
must also hold one (add it from the TUI Remote tab with t, or with the
remote tool’s trust_add), or its offer is refused. The host publishes
only hashes to the room, and the first accepted guest offer burns every
live invite for that login, so a shared code opens nothing afterwards —
nor does it reopen the door for the same guest: once that guest’s relay
socket closes (a reload, a dropped connection), it needs a fresh invite.
canopy remote invites [--json] lists each invite’s login, expiry and
live/used/expired state (no invites when there are none), and
canopy remote uninvite <code-or-login> revokes one code or every live
invite for a login, refusing with no live invite for <target> when there
is nothing to revoke. A typed code ignores case, dashes and spaces, reads
I/L as 1 and O as 0, and may drop the CNR- prefix.
Logins resolve to uids through https://api.github.com/users/<login>,
both for canopy remote invite and for the daemon’s guest allowlist. As a
test hook for a local sandbox with a mock GitHub, CANOPY_GITHUB_API
replaces that base, but only when it is a local http:// origin
(http://127.0.0.1[:port] or http://[::1][:port], no path); any other
value is ignored with a warning, so the host’s GitHub token never leaves
the machine through it. Set it for both the CLI and the daemon.
The relay connection
With remote on and a stored identity, the daemon keeps one WebSocket to
<relay_url>/ws/host, carrying the GitHub token and this host’s id and
label, and answers the pairing frames the relay forwards to it. The label
is truncated to 64 characters and percent-encoded for the handshake header
(café-ß travels as caf%C3%A9-%C3%9F), so a non-ASCII or long label can
never wedge the connection; config set remote.host_label refuses labels
with control characters and labels that are blank but non-empty. The host
sends a protocol Ping every 20 seconds and declares the socket dead when
nothing comes back — no Pong, no frame — for 45 seconds, so a dead relay
link is noticed within a minute instead of leaving the relay listing the
host as online. A dropped socket is retried after one second, doubling to
thirty seconds, with jitter.
canopy remote off closes the socket and on reconnects within a second
— neither needs a daemon restart. If the relay answers HTTP 401, the
daemon stops retrying that token and logs github token rejected: run canopy remote login (see canopy daemon logs); a fresh canopy remote login makes it connect again. If the relay answers HTTP 400, the daemon
stops retrying that label, records relay rejected the handshake: HTTP 400 <reason> for remote status (the Remote tab reads relay rejected: see remote status), and waits: fixing the label reconnects without a restart.
Behind a corporate proxy the daemon tunnels the socket with an HTTP CONNECT:
ws:// relays read HTTP_PROXY, wss:// relays read HTTPS_PROXY
(uppercase wins over lowercase, empty counts as unset, ALL_PROXY is the
fallback for both), and destinations matching NO_PROXY/no_proxy dial
direct — an
exact host, a domain suffix with or without a leading dot, an IP literal or
CIDR range, or * for everything. It authenticates with the proxy URL’s
userinfo when present; without any of them the dial is direct. A proxy URL
it cannot parse is a retryable relay error in remote status, never a
silent direct dial.
Remote control is owner-only: the relay admits only hosts signed in with
the same GitHub account. login prints the device URL and the code to
enter (Open https://github.com/login/device and enter <user_code>, plus
the full verification URL when GitHub returns one), then polls GitHub at
the interval it asks for — slow_down adds five seconds, an expired or
denied code stops with a one-line message — and ends with Signed in to GitHub as <login>. It stores {token, login, id} in
~/.canopy/remote/github.json (mode 0600, directory 0700); the token never
appears in logs, errors, or status output. logout deletes that file and
succeeds when it is already gone. status prints login: <login> (or
not signed in), enabled: …, relay_url: …, and — once the daemon has
recorded them — last_reconnect: <RFC 3339> and last_error: <message>,
so a dropped relay link is visible without reading the daemon log; the
next successful reconnect clears last_error again.
A paired browser’s connection
The relay only carries signaling. A paired browser sends a signed WebRTC
offer through it; the host verifies the signature against
remote_devices and refuses unknown_device, stale (over 120 seconds
off), bad_signature or forbidden (paired, but its login holds no
grant), then answers with a signature of its own. Both
signatures cover the SDP, so the DTLS fingerprints are pinned end to end
and neither the relay nor a TURN server can read or impersonate the
session. Nothing but those signaling bodies reaches the relay.
After the answer, each side trickles ICE candidates through the relay in the object form, and the host sends the minimal object because its local candidates carry no mids:
{"t": "ice", "candidate": {"candidate": "candidate:1 1 UDP 1694498815 203.0.113.7 5000 typ host", "sdpMid": "0", "sdpMLineIndex": 0}}
sdpMid and sdpMLineIndex are present only when the sender knows them.
The host still accepts the legacy bare string
{"t": "ice", "candidate": "<candidate-attribute>"} from older browsers,
and reads the mids into the peer connection when an object names them, so
a browser behind NAT, TURN or a mobile network connects like one on the
LAN. A body with no candidate — the key missing, null, or another
shape — is logged and ignored, and the connection stays up.
Once the data channel is open, the browser speaks the same protocol v2 as
/sessions/ws, with one restriction: only the host owner’s own paired
device may resize a session (clamped to 20–500 columns and 5–200 rows,
at most one resize per session every 100 ms, the latest winning); every
guest’s resize answers forbidden, and the browser otherwise renders
the host’s size scaled, following resized. Terminal
sessions are first-class, as on the local socket: they list, attach, and
spawn — a terminal spawn may omit cli (or send null) to open the
host user’s default shell. A browser paired as a trusted guest login
sees only its granted sessions — every other session answers not_found,
as if it did not exist — while the owner keeps the full view. A session
whose process ends leaves the list for owner and guest alike, as if
closed; attaching it afterwards answers not_found. A spawn names a directory (workdir)
rather than a project. workdir may be absolute or start with ~/ (~ and
~/ alone mean the host user’s home), and the expanded path must be an
existing directory; the browser’s New dialog types that directory. A
relative path is refused with spawn: field 'workdir' must be an absolute path, and a path that does not exist or is not a directory with
spawn: workdir '<expanded>' does not exist or is not a directory; both are
error{code:"bad_request"}. The yolo field (default false) launches the
harness with its approval prompts off — the switch the TUI’s New dialog
offers — by appending that harness’s registry yolo_flag after whatever
interactive args the frame sent; a harness with no yolo_flag refuses it
with spawn: harness '<cli>' has no yolo mode, and a terminal spawn with
spawn: field 'yolo' is not allowed for kind 'terminal'. Each catalog CLI
row carries yolo: true|false, so the browser only offers the switch where
the host can honor it. Output and snapshot frames are chunked to stay under
16 KiB; a snapshot’s first part carries cols/rows, the
rest travels as snapshot_more, and the final snapshot_more carries
last: true (an empty one when the snapshot fit the first part). A
snapshot taken while a full-screen app runs switches the client to its
alternate screen (?1049h) after the main buffer, and when any client
resizes a session every connection attached to it receives
resized{id,cols,rows} (only when the size actually changes). The host
coalesces terminal output into larger out frames: the first output
after a quiet spell is sent at once (a keystroke echo never waits), and
what follows within 25 ms of a sent frame is batched and sent once 12 KiB
has accumulated or the 25 ms have passed. The data channel sends that
coalesced output at most 20 times per second per attached session, and
the first frame after a 50 ms lull still leaves immediately. A
client that falls more than 512 KiB or half a second behind gets a fresh
snapshot in place of the backlog, so it always catches up to the current
screen; /sessions/ws clients (the TUI) obey the same 512 KiB and
half-second rule with a 4 ms batching window and without the 20-a-second
cap, and control frames (hello,
list, ok, errors, shelf, metrics) are never dropped for a slow
client. The shelf rides the same channel: shelf_list and shelf_get
answer shelf and file frames, and shelf_added / shelf_removed push
arrivals and removals. Files from
terminal sessions stay hidden from lists and pushes and answer
error{code:"forbidden"} on fetch. Host-shelf files (tied to no session)
are visible to the owner and to guests with an all-sessions grant only. A
guest fetching or deleting a file from a session outside its grant, or a
host-shelf file while its grant names specific sessions, hears
error{code:"not_found"} instead, as if the file did not exist, and such
files never appear in its lists or pushes. file
frames stay under 16 KiB, at most two downloads run at once — a third
answers error{code:"busy"} — and their parts interleave with terminal
output so a large image never stalls it. A host serves at most four device
connections at once — a fifth is told busy — each success refreshes the
device’s last_seen, and canopy remote revoke (or disconnect, keeping
the pairing) closes a live connection within a second. A revoked device hears error{code:"revoked"} before its
channel closes, so its app can say the device was removed; answers larger
than the peer’s negotiated frame cap are refused with
error{code:"frame_too_large"} instead of being sent halfway.
Uploads travel the other way, and only for a trusted guest with the
upload grant: shelf_put_begin{req,id?,name,size,mime?,sha256} answers
shelf_put_ready{req,file_id}, then shelf_put_part{file_id,part,data}
carries base64 chunks of at most 12 KiB numbered from 0 and the host
replies shelf_put_ack{file_id,part} on every eighth part (the client
keeps at most eight unacknowledged parts in flight), and
shelf_put_end{file_id} commits it. id names the target session; absent
or empty means the host shelf, which only the owner or a guest with
every-session trust may write. Every refusal happens at begin, before
any file data: size over [remote] shelf_max_file_mb (default 20)
answers error{code:"too_large"}, a name with directories or control
characters, an empty name, or a sha256 that is not 64 lowercase hex
characters answers bad_request, and a guest without the grant or a
session outside its grant answers forbidden. Parts must arrive in order
(bad_request otherwise) and may not exceed the declared size.
shelf_put_end verifies the SHA-256 over the received bytes and sends no
success frame at all — the shelf’s shelf_added event is the completion
signal; a mismatch answers error{code:"checksum_mismatch"} and drops the
temp. shelf_put_abort{file_id} deletes the temp silently, a part or end
whose grant vanished mid-flight answers forbidden and drops the temp,
and at most three uploads run per device — a fourth begin answers
error{code:"busy"}. Uploads are stored with origin user, and a live
target session receives one automatic 📎 the user attached NAME (N.N MB) → SHELF_PATH line. hello carries proto: 2 and
features: ["shelf", "shelf_upload"]; a client offers upload only when
shelf_upload is listed, so an older host without it stays usable.
Graphs ride the same channel, read-only. graphs{req} answers
graphs{req,graphs} with the non-archived running, paused and failed
graphs plus completed graphs active within the last 24 hours — running
first, then paused, failed, completed, most recent activity first, at
most 50 — each row carrying id, name, status, workdir as a
basename (never the host’s absolute path), current_spec (the current
spec’s name), current_node (the node’s name, never its id),
done/total over the graph’s queue or specs, blocked and
updated_at:
{"t": "graphs", "req": "g1"}
A graph whose stored row cannot be read is skipped — the host logs the skip — instead of failing the whole list, so one corrupt row never blanks the answer.
graph_watch{req,id} first answers graph{req,id,document,state} — the
graph’s export document, exactly what canopy graph export writes, plus
its live state — and then pushes graph_state{id,state} only when
that state differs from the last one sent on the connection, re-read
every 2 seconds. state carries status, blocked/blocker,
current_spec_id, the current_node name, the nodes map of the
current spec’s latest iteration ("running", "pass" or "fail", a
node with no run absent), the queue-ordered specs (each id, code
such as FEAT-12 or null, name, status, attempts) and
updated_at:
{"t": "graph_watch", "req": "w1", "id": "7c1e4b2a-9d3f-4a6e-8b5c-2f0d1e3a4b6c"}
graph_unwatch{id} stops that watch and answers nothing:
{"t": "graph_unwatch", "id": "7c1e4b2a-9d3f-4a6e-8b5c-2f0d1e3a4b6c"}
A connection holds at most four watches — a fifth answers
error{code:"busy"} and starts nothing — an unknown or archived graph
id answers error{code:"not_found"} and starts no watch either, and
every watch ends with the connection — or as soon as its graph is
deleted, which stops the pushes and frees the slot for another watch.
None of these frames ever changes a graph, a spec or a queue: pausing
and continuing stay local.
Host metrics ride the same channel, read-only. host.metrics{req}
answers host.metrics{req,metrics} with one sample — cpu_usage,
cpu_temperature_c, cpu_temp_source, memory_used_mb,
memory_total_mb, gpu (name, usage, temperature_c,
vram_used_mb, vram_total_mb, or null), power (watts,
limit_watts, source — "battery" or "gpu" — or null) and
sampled_at (RFC 3339). Absent values are null, never omitted.
host.metrics.watch{interval_s?} pushes the same frame without req
every interval_s seconds (interval_s clamps to 5..60, default 10)
until host.metrics.unwatch or the channel closes; one watcher per
channel, so a second watch replaces the first:
{"t": "host.metrics.watch", "interval_s": 10}
The [remote] table in ~/.canopy/config.toml carries enabled (default
false), relay_url, github_client_id (the public Client ID of
UniverLab’s “Canopy Remote” OAuth App — no configuration needed),
host_label (empty means the hostname), and shelf_max_file_mb (default
20, clamped to at least 1) — the largest file a remote upload may send
onto a session shelf; the TUI- and MCP-side session_send_file cap stays
at 20 MiB. The value is read when the daemon starts, so changing it takes
a restart. Overriding github_client_id
with an empty string makes login stop before any request:
remote.github_client_id is not set in ~/.canopy/config.toml.
Settings
Every setting in ~/.canopy/config.toml is declared once, in a typed
registry, and read and written through one surface the CLI, MCP and the TUI
all use.
| Command | Description |
|---|---|
canopy config list [--group <group>] |
List settings with their group, kind, default, effective value, where it came from (default, file, env), when it applies, and status (ok, invalid: <reason>, pending_restart, unknown) |
canopy config get <key> |
Show one setting by its dotted key (e.g. clean.retention_days) |
canopy config set <key> <value> |
Change one setting; validated first, and nothing is written if it is refused |
canopy config reset <key> |
Write the setting’s default back |
canopy config path |
Print the path of config.toml |
Only the daemon writes config.toml, and it edits the file with toml_edit,
so hand-written comments, key order and unknown keys survive every change.
canopy config set reports what the user must do for the value to take
effect: applied, applies on next run, restart the daemon, or restart the TUI. A hand edit is picked up by the daemon’s file watcher without a
restart, and an invalid value keeps the previous effective value — it is
reported by canopy config list and as a canopy doctor warning rather than
being applied. pending_restart marks a restart-only setting whose value on
disk differs from the one the running daemon loaded, including a key the file
no longer holds, and a config.toml that will not parse at all is reported
as a single config.toml row (and doctor warning) instead of being silently
ignored. While the file stays broken the daemon logs the parse error once and
keeps serving the last config that parsed (remote included) until a valid
file appears — canopy remote status shows the fault meanwhile.
skills.sources (a list of tables) must still be edited by
hand.
Spec queues
| Command | Description |
|---|---|
canopy queue group <queue> <spec> <group> |
Set the context group of an existing queue member; a spec joining a group with pending members moves next to them |
canopy queue group <queue> <spec> --clear |
Clear the member’s group (it stays in place unless that splits its old group) |
RAG
| Command | Description |
|---|---|
canopy rag auto-index start |
Resume automatic indexing (default state) |
canopy rag auto-index stop |
Pause indexing without losing the queue |
canopy rag report |
Detailed per-file indexing report, store footprint and health |
canopy rag compact |
Merge store fragments and prune old versions (daemon does it, or this process when the daemon is down) |
canopy rag backfill |
Index every configured file that is not in the vector store yet |
canopy rag purge [--yes] |
Delete the entire vector store and reset all RAG state; asks for confirmation unless --yes (-y) is given |
canopy rag model retry |
Clear a failed local embedding-model download so the daemon retries it in the background, without re-running setup |
Sandboxes
Sandboxed graph runs (graph_control run with sandbox: true) leave their worktree
behind whenever it holds work; see Sandboxes. These
commands — or the sandbox MCP tool, which shares their
implementation and refusals — are how you deal with them, and none of them
guesses.
| Command | Description |
|---|---|
canopy sandbox list |
Read-only: every sandbox worktree canopy created — id, path, graph/run that made it, branch, whether the branch holds commits that exist nowhere else, how far behind its base it is, and its status (active, kept, cleaned, cleanup_failed, merged, discarded). Directories under canopy’s worktrees root with no sandbox row are listed as untracked and never touched |
canopy sandbox list --json |
The same list as JSON |
canopy sandbox land <id-or-prefix> |
Merge one sandbox’s branch onto its base branch; refuses rather than forcing when the workdir is on another branch, has uncommitted changes, or the merge conflicts (naming the conflicting paths), and leaves the sandbox in place. On success it removes the worktree, deletes the branch and marks the sandbox merged |
canopy sandbox discard <id-or-prefix> |
Remove one sandbox’s worktree and delete its branch; refuses when the branch holds commits that exist nowhere else (or when that is uncertain) |
canopy sandbox discard <id-or-prefix> --discard-unique-commits |
Explicit consent to delete commits that exist only in this sandbox, in that same call |
canopy clean also reclaims sandboxes it considers finished once their branch
holds no commits that exist nowhere else — it does not look at uncommitted
files. Land or discard a kept sandbox you care about before running it.
Clean
canopy clean removes stale data. The default run is a soft cleanup:
| Command | Description |
|---|---|
canopy clean --dry-run |
Preview what would be removed; deletes and modifies nothing |
canopy clean |
Soft cleanup: sessions and files older than clean.retention_days, and graph event-log rows older than graph_event_retention_days for graphs that are not running |
canopy clean --older-than <days> |
Override the retention window for this run only |
canopy clean --hard |
Also delete orphaned projects (workdir missing) and their dependent rows; asks for confirmation unless --yes is given. Soft cleanup runs first |
canopy clean --yes |
Skip the confirmation prompt that --hard requires. A typo here can delete a real cascade |
canopy clean --no-reclaim |
Skip reclaiming freed database space (VACUUM and WAL checkpoint) even when this run deleted enough rows to warrant it |
canopy clean --stop-daemon |
Consent to stop a running daemon for a warranted reclaim, then restart it exactly as it was. Without it, an interactive terminal is prompted; a non-interactive run skips reclamation while the daemon is up |
Reclaim runs only when a run deletes at least clean.reclaim_min_deleted_rows
rows (default 50). It takes an exclusive lock and rewrites the whole database
file, which is why it can be skipped or deferred.
Settings reference
Every key the registry declares, with its default and range. canopy config list prints the same keys with the value this machine actually uses and where
it came from. Apply says when a change takes effect: applied (at once),
applies on next run, restart the daemon or restart the TUI.
| Key | Default | Range | Apply | Meaning |
|---|---|---|---|---|
mcp_filesystem_root |
home directory | — | next run | Root directory the MCP filesystem server exposes |
temperature_unit |
celsius |
celsius, fahrenheit |
restart the TUI | Temperature unit for the TUI’s system widgets |
theme |
classic |
classic, modern |
applied | TUI colour theme: bordered or borderless |
pinned_panel_face |
auto |
auto, activity, knowledge, graph, crews |
applied | Pinned right-panel face; auto lets the panel switch on its own |
tui.datetime_format |
%Y-%m-%d %H:%M |
chrono format | applied | Dates and times in the TUI (validated by formatting a fixed instant) |
tui.time_format |
%H:%M |
chrono format | applied | Time-only labels in the TUI |
tui.panel_dwell_seconds |
10 | 0–300 | applied | Seconds a panel event holds the right panel; 0 disables the dwell |
tui.brians_brain |
true |
bool | applied | Home-banner animation |
tui.atmosphere |
true |
bool | applied | Fireflies and dandelions |
tui.scroll_step |
3 | 1–20 | applied | Lines per scroll event |
tui.page_step |
15 | 5–100 | applied | Lines per page-up/down |
tui.animations |
reduced |
full, reduced, off |
applied | Animation budget: 20 fps, 8 fps with focus/idle pause, or static |
notifications.enabled |
true |
bool | applied | Desktop notifications |
update.check_enabled |
true |
bool | applied | Automatic update checks |
update.check_interval_hours |
24 | 1–720 | applied | Hours between automatic update checks |
announcements_enabled |
true |
bool | next run | Upgrade and tip announcements |
embeddings_model |
empty | — | restart the daemon | Embedding model used by the knowledge layer |
embeddings_idle_unload_secs |
600 | 1–86400 s | restart the daemon | Idle time before the embedding model is unloaded |
similarity_threshold |
0.25 | 0.0–1.0 | next run | Lexical-cohesion threshold for semantic chunk merging |
rag_personal_dirs |
[] |
list of paths | next run | Personal directories indexed recursively |
rag_max_file_mb |
10 | 1–ceiling | next run | Per-file indexing size cap, in MB |
rag_vector_cache_entries |
64 | 1–4096 | restart the daemon | LanceDB index cache ceiling, in entries |
skills.sources |
the UniverLab skills repo | list of tables | restart the daemon | Git sources for the skill store, checked in order (edit by hand) |
skills.ttl_minutes |
15 | 0–10080 | next run | Minutes a fetched skill is served before its source is re-checked |
models.catalog_ttl_minutes |
1440 | 1–43200 | next run | Minutes the models.dev catalog cache is served |
models.native_ttl_minutes |
60 | 1–43200 | next run | Minutes a platform’s native model list is served |
agents.probe_timeout_seconds |
30 | 5–300 s | applied | Timeout for a probe before the harness gives up |
scheduler.retry_enabled |
true |
bool | next run | Retry a failed scheduled run after a delay |
scheduler.retry_delay_minutes |
60 | 1–1440 | next run | Wait before a scheduled run is retried |
scheduler.retry_max |
3 | 0–20 | next run | Retries of a failed scheduled run |
spec_attempt_limit |
3 | 1–20 | restart the daemon | Separate graph executions a spec may fail with a verdict before it is blocked |
ensemble_concurrency_cap |
4 | 1–64 | restart the daemon | Ensemble members that run at once across every graph |
graphs.* |
see Tuning the engine | see there | see there | Graph engine retry budgets, ensemble member caps, check output, and the graph catalogue URL |
clean.retention_days |
7 | 1–3650 days | next run | Days kept before canopy clean removes sessions and files |
graph_event_retention_days |
90 | 1–3650 days | next run | Days a graph’s event log is kept before canopy clean prunes it |
clean.reclaim_min_deleted_rows |
50 | 0–100000 | applied | Deleted rows that trigger VACUUM during canopy clean |
activity.retention_per_workdir |
500 | 50–100000 | applied | Activity-log entries kept per workdir |
remote.enabled |
false |
bool | applied | Whether this host accepts owner-only remote control via the relay |
remote.relay_url |
https://remote.univerlab.org |
— | next run | Relay URL for owner-only remote control |
remote.github_client_id |
Ov23liVCfE58kUb3ssDy |
— | next run | Public Client ID of UniverLab’s “Canopy Remote” OAuth App (identity only; empty disables remote login) |
remote.host_label |
empty | — | next run | Label shown for this host; empty means the hostname |
daemon.cpu_quota_percent |
half the host’s logical CPUs | 0–25600 | applied | CPU cap for every agent and gate the daemon spawns, in percent of one core (600 = 6 cores); 0 disables the cap (CPU quota cap) |
scheduler.retry_* and spec_attempt_limit are not in graphs.*, which is
why they are listed here.
Spec queues
Plumbing
| Command | Description |
|---|---|
canopy stdio |
Run the MCP server over stdio |
canopy bridge --id <session> |
Stdio sidecar proxy that injects canopy identity headers |
Global flags
| Flag | Description |
|---|---|
-p, --port <port> |
Override the daemon port (default 7755) |
--help |
Show help for any command |
--version |
Show canopy version |