The daemons command-line client is distributed as self-contained macOS and Linux binaries. The first beta is unsigned and not notarized; it is not described as signed anywhere in these instructions.
This is the supported installation path. It needs neither Go, Node, a package manager, nor a checkout of this repository.
-
Open the latest release and choose the asset matching your system:
System Asset macOS on Intel daemons_vX.Y.Z_darwin_amd64.zipmacOS on Apple silicon daemons_vX.Y.Z_darwin_arm64.zipLinux on x86-64 daemons_vX.Y.Z_linux_amd64.tar.gzLinux on ARM64 daemons_vX.Y.Z_linux_arm64.tar.gzDownload that archive and the
SHA256SUMSfile from the same release. Release archive names always usedaemons_vVERSION_OS_ARCH, with ZIP for macOS and tar.gz for Linux. Every archive containsdaemons,LICENSE, andNOTICE. -
Verify the archive before extracting it. Replace
ARCHIVEwith the downloaded filename.On macOS:
grep -F " ARCHIVE" SHA256SUMS | shasum -a 256 -c -
On Linux:
grep -F " ARCHIVE" SHA256SUMS | sha256sum -c -
The command must report
OK. Stop if it does not. -
Extract the archive and install the executable in a directory on your
PATH(the example uses~/.local/bin).On macOS:
unzip ARCHIVE mkdir -p "$HOME/.local/bin" install -m 0755 daemons "$HOME/.local/bin/daemons"
On Linux:
tar -xzf ARCHIVE mkdir -p "$HOME/.local/bin" install -m 0755 daemons "$HOME/.local/bin/daemons"
If needed, add the directory to your shell startup file and start a new shell:
export PATH="$HOME/.local/bin:$PATH"
-
On macOS, Gatekeeper can block this temporary unsigned, unnotarized beta when the downloaded file has the
com.apple.quarantineattribute. After the checksum succeeds, remove the attribute only when it is present:if xattr -p com.apple.quarantine "$HOME/.local/bin/daemons" >/dev/null 2>&1; then xattr -d com.apple.quarantine "$HOME/.local/bin/daemons" fi
This is a temporary beta fallback while Developer ID signing and Apple notarization credentials are unavailable. It is not needed for a future signed and notarized release.
-
Confirm the installation:
daemons --version
Run daemons help for the full list. Every command accepts --help. Global options: --json (canonical API document on stdout), --quiet, --host URL, --no-color, --request-id ID.
| Command | API route | Scope |
|---|---|---|
daemons whoami |
GET /api/v1/me |
control-plane:discover |
daemons capabilities |
GET /api/v1/capabilities |
control-plane:discover |
daemons list (alias ls) / show ID |
GET /api/v1/daemons[/{daemon}] |
daemons:read |
daemons operations list [--limit N] |
GET /api/v1/operations?limit=N (1 to 200) |
operations:read |
daemons operations show ID |
GET /api/v1/operations/{operation} |
operations:read |
daemons show ID prints the daemon's current ETag; daemons destroy uses that value for its conditional delete.
Tasks, workspace listings, and logs are always addressed through their daemon. DAEMON is the daemon UUID or its exact name.
| Command | API route | Scope |
|---|---|---|
daemons task run DAEMON (PROMPT | -) [--agent AGENT] [--model MODEL] [--permission-mode yolo|approval-auto-deny] [--working-directory /workspace/DIR] [--timeout SECONDS] |
POST /api/v1/daemons/{daemon}/tasks |
tasks:write |
daemons task show DAEMON TASK |
GET /api/v1/daemons/{daemon}/tasks/{task} |
tasks:read |
daemons task list DAEMON [--limit N] |
GET /api/v1/daemons/{daemon}/tasks |
tasks:read |
daemons task cancel DAEMON TASK |
POST /api/v1/daemons/{daemon}/tasks/{task}/cancel |
tasks:cancel |
daemons files list DAEMON [PATH] [--cursor CURSOR] [--limit N] [--all] |
GET /api/v1/daemons/{daemon}/files |
files:read |
daemons logs DAEMON --source agent|app|daemon|provisioning [--level LEVEL] [--cursor CURSOR] [--limit N] |
GET /api/v1/daemons/{daemon}/logs |
logs:read |
Pass - as the prompt to read it from stdin (cat brief.md | daemons task run research -), which keeps it out of shell history and process listings. task run and task cancel are mutations and follow the idempotency-key rules below; a Codex task in YOLO mode may answer confirmation_required. A task that ended failed, cancelled, or timed_out makes task show exit 1 under the task's error_code. There is no --wait or --follow for tasks yet: poll with task show.
files list is an inventory only (name, type, size, mtime); it never downloads content. One page is printed per call with the next cursor on stderr; --all follows cursors for up to 50 pages and then stops with a resumable cursor. In --json mode every page is written as its own canonical document.
logs prints one bounded, server-redacted snapshot. --source is required and validated locally against the closed set; --follow is refused because the Control Plane has no log event route yet. Rerun with the printed --cursor to read newer lines.
Every mutation takes --idempotency-key KEY (8 to 128 characters of letters, digits, ., _, :, -). Interactively the CLI generates one, prints it on stderr before submitting, and reuses it for the whole command. With --json or without a terminal an explicit key is required, so a script can never retry under a fresh key by accident. Every mutation also performs the API version preflight (GET /api/v1, scope control-plane:discover).
| Command | API route | Scope |
|---|---|---|
daemons create NAME --size small|medium|large --variant burstable|reserved --agent AGENT --assigned-user UUID --creation-team UUID |
POST /api/v1/daemons |
daemons:write |
daemons start|stop|pause|resume ID [--etag ETAG] |
POST /api/v1/daemons/{daemon}/{action} with If-Match |
daemons:write |
daemons restart ID [--force] [--etag ETAG] |
POST /api/v1/daemons/{daemon}/restart, {force:false|true} |
daemons:write |
daemons resize ID --size SIZE --accepted-offer UUID [--etag ETAG] |
POST /api/v1/daemons/{daemon}/resize, {size,accepted_offer_id} |
daemons:write |
daemons delete ID [--etag ETAG] |
DELETE /api/v1/daemons/{daemon} with If-Match and browser approval |
daemons:destroy |
daemons rename ID NAME [--etag ETAG] |
PATCH /api/v1/daemons/{daemon}, {name} with If-Match |
daemons:write |
daemons stop UUID... |
POST /api/v1/daemons/bulk/stop, {daemon_ids:[...]} |
daemons:write |
daemons operations cancel|retry ID |
POST /api/v1/operations/{operation}/{action} |
daemons:write plus workspace capability |
daemons operations wait ID [--wait-timeout DURATION] |
GET /api/v1/operations/{operation} |
operations:read |
spawn aliases create, destroy aliases delete, status aliases show, and force-restart aliases restart --force. retry ID targets an operation, as does operations retry ID. Customer server commands, server selection and disk quota flags are removed. Workspace displays have no server column. Use exact workspace UUIDs for lifecycle commands.
Create sends only metadata. Optional flags are --source empty|payload, --team UUID and --accepted-offer UUID; source defaults to empty. The assignee and creation team are required, and the optional grouping team does not replace the creation team. The API currently returns only meta.operation.uuid on create; --wait polls that ID. Accepted offers are references to approved prices, never client-supplied monetary amounts. Resize is upward only while Running (or supported disk-full Unhealthy), with no variant switch. Stop retains compute at the active price; Pause releases compute at the paused price.
The published API does not yet expose E4's authorized local payload receipt and streaming endpoints. create --repo URL [--branch BRANCH], create --payload-file PATH, and operations continue ID --payload-file PATH fail before any create, file read or upload. Metadata-only --source payload remains available for an assignee using a separate authorized uploader; the CLI warns that its own uploader is unavailable. Content stays on the device; the payload deadline is 15 minutes after target readiness, never during a capacity wait. The CLI does not upload repository input to metadata or ordinary file endpoints. Additional agents, labels and descriptions are also not accepted by the landed create API and have no CLI flags yet.
Force restart sends force:true and preserves the API's confirmation denial; the current API always denies force and supplies no approval URL. Bulk delete is refused locally because the landed /daemons/bulk/destroy endpoint does not enforce confirmation bound to the complete UUID/revision set. Single delete retains browser approval. These are upstream integration gaps, not completed lifecycle capabilities.
Bulk stop normalizes and deduplicates exact UUIDs in one request so the API can reject a denied selection before any mutation. Every returned outcome is printed, including failures. A partial failure exits 1; --wait polls all accepted child operations within one total timeout. Missing outcomes produce exit 8. The bulk API does not currently accept per-item ETags.
Single-workspace lifecycle commands, rename and delete read the daemon to capture its ETag and send it as If-Match. Pass --etag to pin a value and skip the read. A stale precondition exits 1 without resubmitting. Delete also re-reads to explain the changed state. Replay guidance preserves the original command options, ETag and idempotency key. Browser approval never creates a replacement key.
- E4 local payload receipt lookup and streaming routes are not published. Repository and payload-file commands fail before creating a workspace, reading a file or uploading content.
- Bulk delete lacks confirmation bound to the complete UUID/revision set on
/daemons/bulk/destroy. The CLI refuses bulk deletion before sending any request. - Force restart currently returns the API's confirmation denial without an approval URL. Additional agents, labels and descriptions are not accepted by the create API, and bulk stop does not accept per-item ETags.
Add --wait to any mutation to poll its operation until it reaches a terminal state (succeeded, failed, partially_succeeded, cancelled, timed_out, or outcome_unknown). Polling honours Retry-After, otherwise backs off from 2s to 15s with jitter. --wait-timeout bounds the wait (default 10m); use a duration such as 1s or 10m, or a bare number of seconds such as 90. Ctrl-C stops waiting locally; it never cancels the operation on the server.
In --json mode with --wait, stdout carries the mutation's document first and, once polling ends, the final operation document on its own line. Progress lines go to stderr and are suppressed by --quiet.
Outcomes: partially_succeeded is reported as a failure (exit 1) with the operation's result printed so you can see what landed. A wait that hits its timeout exits 8 with the last known state and the daemons operations show ID command to check it.
When the API answers 409 confirmation_required (for example on daemons destroy), nothing has changed. The CLI prints the safe summary, the approval URL, the expiry and the confirmation ID, and exits 6. In an interactive terminal it offers to open the approval URL in your browser; opening it is never treated as consent, and the CLI never polls or approves on your behalf. Approve in the browser, then run the same command again. With --json the canonical problem document is written to stdout and no browser is opened.
When a mutation's outcome cannot be determined (a transport failure after dispatch, an invalid response, or an operation ending in outcome_unknown), the CLI exits 8 and prints the reconciliation step to run first (for example daemons list after a spawn, daemons show ID after a lifecycle action) and the exact replay command with the original idempotency key. It never retries a possibly destructive or billable mutation on its own, and never under a new key.
daemons login runs the device flow. daemons login --token-stdin reads an existing Control Plane token from stdin instead (one line), verifies it with GET /api/v1/me, and stores it; a token is never accepted as an argument and never echoed. DAEMONS_TOKEN in the environment overrides the store for CI and is never written to disk.
Credentials live in an owner-only file (~/.config/daemons/credentials.json, or DAEMONS_CREDENTIALS_FILE) keyed by normalized host, so logging in to a second --host never overwrites the first. Without --host or DAEMONS_HOST the CLI uses the production host when it has a credential, otherwise the only stored host; two or more non-production hosts require an explicit --host. daemons logout revokes and removes only the current host's credential. A credential file from an older release is migrated on the next login.
A protected workspace refuses every access kind until the guest itself verifies a PIN. That verification is not a Control Plane check: daemons unlock DAEMON opens a finite encrypted exchange straight to the guest, and no PIN, verifier, recovery phrase or device proof reaches daemons.run at any point.
daemons lock pair DAEMON # compare and pin the guest identity
daemons unlock DAEMON # hidden PIN prompt, installs an 8 hour device grant
daemons lock status DAEMON
daemons lock DAEMON # give up this device's grant
daemons lock setup|change|recover DAEMONSecrets are typed at a hidden prompt and nowhere else. There is deliberately no flag, environment variable or piped stdin that carries a PIN or a recovery phrase, naming one (--pin, --secret, ...) is a usage error, and a non-interactive run refuses with exit 2 rather than falling back.
lock pair shows the guest's public identity pin so it can be compared with an already trusted device before anything is committed to it; the probe sends no request frame. unlock --trust-on-first-use accepts the offered identity without that comparison and says so: it is a disclosed bootstrap, not a verified pairing. A changed identity always fails closed with lock_identity_changed and needs an explicit lock pair.
A grant lasts eight hours from verification with no sliding refresh, and it never survives a guest reboot, an explicit lock, a PIN change, a recovery or a reassignment. It lives in an owner-only 0600 file (~/.config/daemons/workspace-lock.json, or DAEMONS_WORKSPACE_LOCK_FILE) holding the public identity pin and, while the grant is live, the device signing key; daemons logout and daemons lock pair --forget remove it. The PIN itself is never written there.
lock status reports what this device knows. Guest state is shown as unknown unless the guest itself reported it: it is never inferred from Control Plane metadata.
What the grant currently reaches: daemons attach, daemons files, daemons upload, daemons download and daemons ssh-proxy answer the guest's device challenge with the stored grant, and refuse with lock_device_required (exit 5) when this device has none. Unlock first on a protected workspace. Owner organization actions stay in the browser.
daemons lock exchanges DAEMON # what is waiting for you on this workspace
daemons lock handoff DAEMON # approve the reassignment with your own factor
daemons lock authorize-replacement DAEMON # authorize the Owner's organization key replacementThe engineer's two lock-channel decisions are driven here. lock exchanges reads what the platform is waiting on, and each command binds the exchange the platform actually issued: the handoff scope comes from the reassignment operation that exists and is checked against the guest's own verified challenge, so a substituted workspace or a stale generation is refused rather than approved. authorize-replacement additionally asks for the confirmation code the Owner reads out: the Control Plane never carries it, which is what makes the authorization an agreement with the Owner rather than with the platform.
The Owner organization actions (enroll, recover, rotate, and staging a replacement) stay in the browser in v1: they need a password entered by the Owner, and this CLI does not offer one.
daemons lock DAEMON gives up this device's grant. When the Control Plane does not admit the guest-wide lock action yet, the request never reaches the guest, so the grant is kept and the refusal says so; every other outcome, including an uncertain one, drops the local grant.
daemons attach DAEMON [--session NAME] requires the ticket to advertise the takeover_v1 terminal feature; if the Control Plane does not, attach refuses (exit 2) before connecting instead of guessing at the gateway's behaviour. Raw terminal mode is restored on every exit path, including a panic.
Enable SSH with a locally held identity (only its adjacent .pub file is read):
daemons ssh enable DAEMON --identity ~/.ssh/id_ed25519 --wait
daemons ssh-config DAEMON --identity ~/.ssh/id_ed25519
daemons ide DAEMON --editor codessh-config writes private, managed OpenSSH files below ~/.ssh/daemons-run/ and adds one marked Include to ~/.ssh/config; it never copies a private key or stores tickets. daemons ssh keys list DAEMON lists fingerprints, ssh keys remove DAEMON FINGERPRINT removes one key, and ssh disable DAEMON removes SSH access. ide --cached uses the saved config but every new SSH transport still mints a fresh ticket and needs a current login. The proxy sends only SSH bytes on stdout; diagnostics stay on stderr.
0 success; 1 API or operation failure; 2 usage or local validation; 3 authentication; 4 not found; 5 scope or capability denied; 6 web confirmation required; 7 rate or quota; 8 outcome unknown.