CLI reference
Lever ships two binaries with user-facing commands: lever, the host control plane you run
from an instance root, and lever-manager, the in-jail orchestration CLI the manager agent
runs inside its container. (The other binaries have no operator-facing commands: lever-agent,
baked into agent images and run by the container pre-start hook, and the capability-aware MCP
tools the broker launches and supervises when a broker.tools: entry names them in its command:
lever-tool-db (the reference tool), lever-tool-github,
lever-tool-whisper and lever-tool-tts. Those tools take their settings as flags in that command.)
All lever commands read ./lever.yaml from the current directory when the config argument is
omitted — there is no walk-up discovery, so run them from the instance root.
lever — host control plane
Everyday lifecycle
| Command | What it does |
|---|---|
lever up [config] |
Converge the application on lever.yaml as it is now, as lever apply does (create or start the jail, provision scion, start or restart the hub, the broker and the remote proxy where they do not match the config, start or resume the manager), and attach the manager’s TTY. A config change therefore takes effect at the next up, with no lever apply; before it starts, up says when the config or the lever binary changed since the last bring-up. Steps whose own check finds them done are skipped (a broker of this binary and config, a hub started with these settings, an image already loaded and warmed), so a warm up changes nothing; the security fences run on every up. On Lima, when this bring-up started the VM (it was stopped, or new) and the guest’s grub.cfg already carries lever’s kernel parameters (such as no-kvmapf) that the running kernel lacks, up reboots the guest once before it provisions anything (limactl stop, then start; that start takes longer) and prints jail: rebooted the guest once to apply …; a VM that was already running is never rebooted and reports the reboot pending. Idempotent: re-running resumes a suspended manager (or one the hub reads running over a container podman shows exited or gone) and re-attaches — same conversation, even across a lever stop or host reboot. --fresh starts a new manager thread: the bring-up deletes any existing record once the hub is up and creates the manager anew — also after a lever stop, when the hub is down at the start of up and the record cannot be seen yet (the discard is announced). Before it deletes the record it backs up the manager’s conversation (~/.claude/projects) to .lever-state/conversations/ (a running manager is suspended for the copy); if the backup fails nothing is deleted. --no-backup skips the backup. --keep-conversation then resumes the new manager on the old conversation (its own boot turn is cut short): use it when only the image, model, mounts or devices change, never to evict a session you no longer trust. Because a record keeps the image it was created with, --fresh is also how a changed manager.image reaches the manager. Under --fresh the new manager’s enrolment ticket is staged only once podman shows no container by the manager’s name: when it still lists the old container after 30 s, or cannot say, up fails closed with no ticket staged and no manager created (the record is already deleted and the conversation backed up); run lever up --fresh again once podman no longer lists it (troubleshooting). See the manager’s conversation across --fresh. Without --fresh, a resume in the bring-up whose claude --continue finds no conversation (a manager never sent a message) is followed by a fresh start, as --fresh would make it, but only when the guest shows the manager’s home holds no transcript; up says so, and otherwise names the cause and keeps the record. That fresh start records the new session for the contact gate only when the manager’s lever skill (read before the delete and again right before the create) is the one its last recorded session started with; otherwise contacts cannot post to the manager until it next starts fresh other than on its own (lever up --fresh), and up says so. --reset-hub-identity as for apply. --no-attach brings up without taking your terminal. When up attaches, other terminals attached to the manager stay attached; --detach-others detaches them first, as for lever attach (it does nothing with --no-attach). On a terminal up attaches to a manager it has just created as soon as claude runs in it (its tmux pane is alive), and to a resumed manager after the wait below, so that a claude --continue with no conversation is caught before the attach; after you detach it checks the manager again: a manager that died is reported then, with a non-zero exit. --no-attach, a stdin that is not a terminal, and every resume wait until claude reports that its session started (lever’s claude-ready hook; after 2 min with no report over a pane that stays alive, up falls back to the 3 s settle window and, if claude is still alive after it, reports up with a warning that the readiness signal is missing) and return as soon as it has; a manager image without that hook (built by an older lever) gets a 3 s settle window instead. --progress auto\|tty\|plain\|json\|quiet (default from LEVER_PROGRESS, else auto) chooses how the steps are shown (Watching a bring-up); LEVER_TRACE=1 adds each external command. The everyday entry point. Refused while manager.image is unset, and, without --fresh, before it plans any change, while the manager’s record was created on an unqualified image name or with none (scion rewrites that name to the hub’s image registry at each start): run lever up --fresh --keep-conversation. Refused with exit 6, changing nothing, while another lever command changes the instance (see the lifecycle lock); --wait <duration> waits up to that long for it instead. |
lever attach [name] |
Attach your TTY to the manager (default) or a named worker. Strictly passive: fails fast with “run lever up first” if the jail isn’t up — it never provisions. Detach with Ctrl-b d. The attach runs as a child of lever: when it ends, also when it dies (a dropped connection, a stopped container), lever restores the terminal’s line settings and turns off the modes the agent’s tmux left on (mouse reporting, focus events, bracketed paste, extended keys; after an attach that did not exit 0 also the alternate screen; lever up does the same), writing nothing when stdout is not a terminal, and exits with the attach’s own status. Other terminals attached to the same agent stay attached, so a second terminal can watch alongside the first. --detach-others detaches them first (never the container’s own tmux client) and prints the count: use it when an earlier attach whose terminal went away left a stale tmux client that froze the view (a frozen attach view). Staging the attach token and detaching other terminals change the jail, so they wait for the lifecycle lock, up to 2 minutes by default (--wait <duration>); the attach itself holds no lock. |
lever msg send "…" --to NAME |
Host-side fire-and-forget note to the manager (use the app name) or a declared worker — no attach needed. The note goes through the running broker’s operator socket, which records it (the agent verifies it as an operator-note) before it sends it; with no broker running the command refuses. The note lands as the agent’s next user turn; it acts on it unattended and the exchange waits in the scrollback for your next attach. --interrupt injects it ahead of the agent’s next turn. A recipient (worker or manager) that is not running, or has no record, is refused with exit 9 and nothing is sent; for a worker, resume it with lever worker resume NAME and send again (driving workers from a host program). |
lever reload [config] |
Apply config changes (new worker, tool, or grant) to a running instance without a VM power cycle: stops the broker, re-runs the idempotent apply on the current config, spawns a fresh broker. The manager container keeps running, so its conversation is preserved and your TTY isn’t taken. Needed because the broker reads lever.yaml only at startup — a plain re-apply keeps the old broker. Refused with exit 6, changing nothing, while another lever command changes the instance (see the lifecycle lock); --wait <duration> waits up to that long for it instead. |
lever stop |
Power the jail off but keep its disk — the daily “done for the day”. Suspends the manager (conversation preserved) and every running worker, stops the host broker; a later lever up powers it back on and resumes the same session. Installed runtimes and scion state persist. --machine/--backend target another jail (the broker is then not stopped). --keep-vm suspends the agents and stops the jail’s hub, the broker and the remote proxy but leaves the VM running, so the next lever up skips the VM boot; the VM keeps its memory on the host meanwhile (stopping without powering off). It acts on the current instance only and refuses --machine. Refused with exit 6, changing nothing, while another lever command changes the instance (see the lifecycle lock); --wait <duration> waits up to that long for it instead. While the broker holds the lock (its certificate heal or token reset of the manager), stop stops the broker first, which ends the hold, and goes on. |
lever destroy |
Full teardown: delete the isolated machine and everything in it. Targets lever-<name> from config; override with --machine. lever down is a deprecated alias. From the instance root, or with --machine naming this instance’s own machine, it holds the instance’s lifecycle lock for the teardown: while another lever command changes the instance it exits 6, names that command and its pid, and destroys nothing (--wait <duration> waits up to that long first); --force destroys the jail anyway, after a warning that names the holder, for a command that is stuck. |
lever conversation new [config] |
Start the manager on a new conversation without recreating anything: it backs the conversation up (as backup; no backup, no change), stops the record so the old session ends, and starts the same record again in a new Claude session, with the boot prompt (manager.prompt_file as it is now, read before anything changes; scion stores it on the record), through the same checks as any resume of the manager. A manager in the middle of a turn is left alone (exit 5) unless --force; the manager must be running or suspended. A failure before the stop resumes a manager it suspended on its own conversation; one after it leaves the manager down and names the backup (lever up starts it in a new session, then lever conversation restore <id> puts the old one back). It takes the lifecycle lock (exit 6; --wait <duration> waits up to that long first; the chat page’s and the daily reset’s new conversations wait 3 and 10 minutes). --progress as for up. See start a new conversation. |
lever conversation list\|backup\|restore [ID] |
The manager’s conversation backups in .lever-state/conversations/ (0700, files 0600; the last 10 are kept; none when the state directory is inside the tree). list shows them, newest first. backup copies the manager’s ~/.claude/projects from the jail (which must be up). restore puts a backup (default: the newest) back into a running or suspended manager: it backs up the conversation it replaces (--no-backup skips that), suspends the manager, swaps the transcripts (the replaced ones stay in the manager’s home as ~/.claude/projects.lever-replaced-<stamp>), records the session with the lever skill the restored conversation started with, and resumes and gates the manager. Only the transcripts travel; ~/.claude.json stays the record’s own. backup and restore take the lifecycle lock (exit 6, or --wait <duration>). |
lever worker purge NAME |
Discard a declared worker’s scion record and staged bootstrap ticket so it can be re-dispatched fresh with a new task — a worker’s task is fixed at creation, so lever-manager agent start against an existing worker returns 409. Never touches the worker’s workspace (its work product); requires --force. Host-side (operator) command. The in-jail manager cannot purge, but it can recycle a worker the config marks recyclable: true (lever-manager agent recycle, which runs the same teardown). |
lever worker resume NAME [--json] |
Resume a declared worker that is not running (suspended, stopped, completed, or in phase error, which gets a forced resume) and wait until it is live, as the manager’s lever-manager agent resume does: the same checks (role, workspace, shared folders, the running-worker cap), the same per-worker lock, a fresh bootstrap ticket. A running worker is left alone and the command succeeds. While another start, resume, stop, wake or heal of the same worker is under way, the command waits for it to finish, then acts on what it left. It asks the running broker over its operator socket (owner-only), never scion directly, and it can’t resume the manager (lever up does) or start a worker that has no record (lever worker start does). --json prints one line on stdout: {"worker","ok","phase","reason","error","exit"} (not after a flag or argument error, which prints the usage instead). Exit codes and reasons: lever worker exit codes. See driving workers from a host program. |
lever worker list [--json] |
List the declared workers as the broker sees them on the hub, read-only: name, phase (none for a worker with no record, running-container-down for a record the hub reports running whose container has died), activity, last activity, container status and whether the worker is idle at its prompt. Every field but the name is the worker’s own word: the activity comes from scion’s hooks inside the worker’s container, and a worker can write its phase and container status on the hub too, so it can show what it likes; lever’s own checks (recycle, liveness) never trust them. The broker passes only known labels (anything else reads unrecognised) and the CLI bounds and sanitizes every string again. idle_at_prompt is derived from that activity: true for waiting_for_input or completed on a running worker, false for a worker that is busy (working, thinking, executing) or not running (no prompt to type at), and null when the record doesn’t settle it (stalled, which the hub sets both for a worker idle at its prompt and for one that can’t reach the model, blocked, or no activity yet). --json prints one line on stdout: {"ok","workers":[{"name","phase","activity","activity_source":"worker-reported","last_activity","container","idle_at_prompt"}],"reason","error","exit"}, with last_activity an RFC 3339 time or null. Exits 0, 11 when no broker answers (exit codes), or 1. |
lever worker start NAME [--task-file F] [--json] |
Start a declared worker and wait until it is live, as the manager’s lever-manager agent start does: the same checks, the same per-worker lock, the running-worker cap, a fresh bootstrap ticket. A worker with no record is created with the task in --task-file, else its config’s task_file, else no task. An existing record is resumed as by lever worker resume (its task is fixed at creation, so a --task-file then is refused with task-fixed: recycle or purge it to start fresh). A running worker is left alone. --task-file is read on the host: a regular file of at most 64 KiB, trimmed, non-empty, within the 15 KiB task budget, and never through a symbolic link inside the mounted tree (an agent could aim one at any file you can read); a refused file exits 10 with reason task-file, bad-task or task-too-long, and the broker is not asked. A revoked worker is refused. --json as for resume. |
lever worker recycle NAME [--task-file F] [--json] |
Discard a worker’s record and start it fresh, as the manager’s lever-manager agent recycle does, for any declared worker (recyclable: true is the manager’s authority to recycle, not yours): only one that is suspended, stopped or in phase error with its container down (not-stopped, exit 12, otherwise: lever worker stop it first), and at most once a minute per worker, a count the manager’s recycles share (rate-limited, exit 75). The scion record and the staged ticket go, so the conversation is lost; the workspace is kept. The task is --task-file’s (read as for start), else the config’s task_file, else none. Nothing is deleted when a check refuses. --json as for resume. |
lever worker new-session NAME [--task-file F] [--json] |
Start a worker on a new conversation and keep its record and workspace, as lever conversation new does for the manager: the broker stops the worker (whatever phase the record shows), which ends its session, waits until the record reads stopped and its container is down (on the hub and in podman), reads the record once more, and resumes the stopped record in a new Claude session, then waits until it is live. Once the stop is under way the broker finishes the verb (within 10 minutes) even when the command is interrupted. The task is --task-file’s (read as for start), else the config’s task_file, else the one the hub stored on the record when it was created. The same per-worker lock as the other verbs (it waits for another start, resume, stop or heal to finish) and the checks of resume (role, workspace, shared folders, the running-worker cap), all run before the stop, so a refusal leaves the session as it was. A worker with no record exits 8 (lever worker start it); a stop that fails or is not confirmed in time, or a record that no longer reads stopped just before the resume, exits 3 and starts nothing (the worker may then be stopped); a revoke that lands during the stop exits 10 (revoked) and leaves the worker stopped. The old conversation is not backed up. The new session counts as a fresh start for contacts, as a fresh start does. There is no way to do this by message: a message is never turned into a slash command such as /clear. --json as for resume. |
lever worker watch NAME [--json] [--idle-timeout D] |
Print a declared worker’s hub events from now on, as they come: its state changes (WAITING_FOR_INPUT, LIMITS_EXCEEDED, …) and the COMPLETED summary it reports with sciontool status. It long-polls the broker’s operator socket (POST /operator/worker/events), which reads the hub’s notification feed without acknowledging anything (the manager’s inbox is unchanged) and passes on only the events the hub attributes to that worker’s record. Every event is the worker’s own report: its text (one line, at most 1 KiB, marked worker-reported: ) and which status it reports; a status the hub doesn’t produce reads UNRECOGNISED. A completion ("kind":"completion") is a trigger to go and check the worker’s work, never the result. --json prints one event per line, {"id","worker","kind","status","message","at","source":"worker-reported"}, and when the watch ends for any reason but Ctrl-C a last line {"worker","ok","reason","error","exit"}. It ends on Ctrl-C (exit 0, no last line), after --idle-timeout without an event (exit 0, reason idle-timeout), or when the worker’s record is purged or replaced (a recycle makes a new one; exit 8, no-record, also when it has no record at the start). While the broker restarts (lever reload) or the hub doesn’t answer, it retries and goes on from where it was. Events with no hub time are not shown, and an event the hub makes visible only after a later one, with an earlier time, can be missed. Its text has runs of blanks collapsed to one space. Each poll reads the whole notification feed of the hub’s controller user, so many watches at once load the hub. |
lever worker suspend NAME [--json] / lever worker stop NAME [--json] |
Suspend or stop a declared worker through the broker, under the worker’s lock, as the manager’s agent suspend and agent stop do. scion is asked whatever phase the record shows (a worker can write its own phase, so one that claims to be stopped is still stopped; a suspend of a worker that is already suspended can then fail with runtime-error while the hub cannot reach its runtime broker); a worker with no record exits 8. A revoked worker may still be stopped. A stop is the operator’s decision: the remote chat page’s wake does not undo it for a contact, while lever worker resume and start do (and so does autostart at the next lever up). --json as for resume. |
Tree backups
Snapshots of the instance tree in .lever-state/tree-backups/, when tree_backup.enabled is set
(see tree backups). A snapshot is named by its id, latest or
latest~N; a path is given as raw bytes or as q: plus the quoted form lever prints. Every
command refuses when the store, the state directory or its parent is inside the tree, or the tree
inside the store, compared by file identity.
| Command | What it does |
|---|---|
lever tree snapshot [--full] [--pin] [--time D] [--no-wait] [-q] |
Take a snapshot now. --full re-reads every cached file (within the read budget), --pin keeps it until lever tree delete, --time sets a time budget (none by default), --no-wait exits 75 when another snapshot runs, -q prints nothing for a complete one. Exit 0 complete, 3 incomplete, 1 failed, 75 busy. Refused while tree_backup.enabled is off. |
lever tree list |
The snapshots, oldest first: id, sequence number, time, trigger, label (vm-off, suspended, none), complete or not, files, size, pin. |
lever tree ls <id> [<path>] [-r] |
What a snapshot holds: type, mode, size, short hash, state, path. |
lever tree log [-n N] [--all] [-- <path>] |
One line per snapshot with its change counts and the first 20 changed paths (--all: every one), or, with a path, the snapshots in which it changed. |
lever tree diff <a> [<b>] [--stat] [--meta] [--no-limit] [--full-diff] [-- <path>...] |
What changed between two snapshots (<b> defaults to latest): A added, D deleted, M content, T type, L link target, P mode, ? unknown (with the reason), ~ modification time alone (with --meta). Summary first, then line diffs of text files. |
lever tree cat <id> <path> |
One file’s content to stdout, verified against its hash. Refuses a terminal. |
lever tree restore <id> --to <dir> [--path <p>]... [--links=inside\|all\|none] [--keep-modes] [--keep-vcs] |
Restore into a new directory outside the tree, through <dir>.lever-partial, and print a report. Exit 0 only when everything selected was restored. |
lever tree verify [--fix] |
Validate every snapshot and re-hash every stored file; --fix moves damaged ones to blobs/corrupt/. |
lever tree delete <id>... [--yes] |
Delete snapshots, pinned or invalid ones too, then collect garbage. Asks first unless --yes. |
lever tree gc |
Apply retention and remove content no snapshot references. Deletes nothing while a snapshot is invalid. |
lever tree lock [--wait D] -- <cmd> [args...] |
Run an off-host copy tool while GC cannot change the store, with LEVER_TREE_BACKUP_STORE, LEVER_TREE_BACKUP_LATEST and a PATH without in-tree entries. Exits with the command’s status, or 75 when the lock is not free within --wait (10 min). |
lever tree path |
Print the store’s path. |
lever stop and lever destroy take a snapshot after the VM is off when tree backups are on and
tree_backup.at_stop is not false; --no-tree-backup skips it. Inside lever tree lock, the
commands that write the store (snapshot, gc, delete, verify --fix, lock) refuse.
Setup and diagnosis
| Command | What it does |
|---|---|
lever init |
Scaffold/refresh the framework operator skills (SKILL.md) into the instance tree — lever-operator at the tree root, lever-agent in each declared worker dir — plus a marked reference block in the tree-root CLAUDE.md. Hash-guarded: files you’ve edited are left alone with a warning (--force overwrites); --check reports staleness without writing (non-zero exit); --adopt records your customizations as an accepted baseline so doctor and --check treat them as OK — later drift past that baseline still fails doctor (tamper detection: agents can write these files, the baseline lives host-side). Re-run after upgrading lever or adding a worker. |
lever doctor |
Run real health checks — broker alive and serving, every declared tool backend reachable/resolvable (external servers dialed, supervised commands resolved on the supervisor PATH; a host: tool is left to its service host’s row), one service host <name> row per broker.service_hosts entry (mutual TLS with the pinned keys, the tools it serves against the host: tools, operations outside its ceiling; see service hosts), the agent image’s baked Claude Code version (read from the host Docker store, or from the image_tar archive when the image ships as one), the image’s in-jail code matching the host lever (the image’s lever_jail_abi label against the host’s jail ABI: equal passes and shows a release difference as information, different fails; an image without that label falls back to comparing the release in its lever_version label, and an image with neither is not checked), the manager record’s image matching manager.image (a resume keeps the record’s image; a drift points at lever up --fresh; it fails for any agent record, the manager’s or a worker’s, created on an unqualified image name or with none, which scion rewrites to the hub’s image registry at each start: recreate the manager with lever up --fresh --keep-conversation and a worker with lever worker recycle NAME, or lever worker purge NAME --force then lever worker start NAME; and it fails while manager.image is unset: set it, e.g. to lever.local/lever-claude), the hub’s container runtime (hub container runtime: podman, which every apply sets as lever-admin; it fails on any other), agent telemetry in the jail’s scion settings matching scion.telemetry and the manager’s SCION_TELEMETRY_ENABLED agreeing with it (a manager started before the change points at lever stop && lever up), manager credential file presence/size/mode, no stray .mcp.json in the tree, usable Go toolchain (not required when lever downloads its release’s prebuilt scion), scion project-registration consistency, operator-skills scaffold current, operator directives configured (signer key count, admin socket present), remote-access proxy when enabled (pid/listen, PAT presence + mode, an end-to-end GET /healthz through the proxy, which signs in with a lever assertion), the hub tokens’ records (scope set current, more than 30 days of life left — a token without a record fails), hub authentication (by behaviour: an assertion signed by another key and one for another hub’s issuer and audience both get 401, one for a random uninvited address gets 403 (the hub is invite_only), lever-admin’s assertion is a hub admin, the hub offers no login provider, the running hub carries lever’s auth settings and no dev auth, no scion dev token exists in the jail, and the jail’s public key is the host key’s, in a root-owned file and directory nobody else can write), hub identities (lever-admin is the only admin by role and by binding, the controller holds only the tokens lever recorded, scion’s dev user is the member controller on a migrated hub and absent on a fresh one, the key fingerprint matches the record), remote web role (read live from the hub: every configured login’s hub user is the one recorded, bound to its tier’s role and capped, and every removed login suspended), no shared directory on the project, every agent record carrying a stored role that scion’s migration did not grandfather, the size of the hub’s agent list (hub agent list: the largest page against the 8 MiB lever reads per page; it warns past half of that and fails past all of it, since lever up then cannot check or resume its agents), the project’s agent-role ceiling set to the role lever stamps, and scion’s hub.conduit and hub.artifacts experiments off at the hub (scion conduit and artifacts: the experiments lever pins off (hub.conduit, hub.artifacts, hub.chat_thread_groups, federation.project_visitors), probed with the controller PAT; it fails while one is on, warns while they are off only by scion’s default because lever has not pinned them off on this hub, or when an older lever’s pin never looked for one, and says not applicable on a scion without hub experiments). Further rows cover the manager’s liveness and activity, each agent’s hub token, certificate and network mode, worker ticket mounts and tree bootstraps, the workers the manager may recycle (worker recycle, never a failure), the last bring-up (last bring-up: its total time and slowest steps, from .lever-state/logs/; never a failure), the guest clock and guest DNS, the jail’s podman store (podman storage: layers no image or container uses, layers flagged incomplete, and podman’s own corruption warnings, read as the jail user without changing anything; a corruption warning fails, orphan layers warn, and the fix points at the recovery runbook), the node toolchain (not required when lever downloads its release’s prebuilt web UI), manager.read_only mounts (manager read-only paths), each agent’s shared_folders mounts (shared folders), nested_virt (nested virt), the Lima guest’s crash recovery (guest crash recovery: kernel.panic_on_oops=1, kernel.panic=10, kernel.softlockup_panic=1 and kernel.hung_task_panic=0 read from /proc/sys/kernel, efi_pstore.pstore_disable=1 on /proc/cmdline (and no-kvmapf on an x86_64 guest), and no EFI pstore backend registered, read from /sys/module; it warns with the fix “lever apply; reboot the VM for the kernel command line”), the Lima guest’s kernel (guest kernel: the guest’s Ubuntu release and the running kernel from uname -r; it warns when grub boots another kernel next (the first entry of /boot/grub/grub.cfg under grub’s default entry 0, otherwise the newest /boot/vmlinuz-*: a reboot is pending), when guest_kernel is hwe and the release’s HWE package (linux-generic-hwe-26.04, or -24.04 on a VM created on 24.04) is not installed or the release has none lever knows, and when nested_virt runs on the 6.8 GA kernel; both rows say not applicable on OrbStack and are warnings, never failures), how the previous Lima guest boot ended (last guest boot: read from the guest journal through sudo -n; it warns when that boot did not end with a clean shutdown, with the time of its last journal entry, and when the newest console log set lever saved in .lever-state/guest-logs/ holds a kernel panic, oops or BUG: line, which it prints sanitized and cut to 200 characters (see saved guest console logs); a warning, never a failure, and not applicable on OrbStack), the Lima VM’s host swap (VM host swap, Linux hosts: the QEMU process’s VmSwap and its cgroup’s memory.swap.max; it warns above 1 GiB in host swap and while the host may swap the VM out without host_swap: true, see keeping the VM out of host swap), guest tasks stuck on KVM async page faults (guest async page faults: tasks in D state in kvm_async_pf_task_wait_schedule whose context-switch count did not move over 5 s, read as root through sudo -n, and the kernel’s hung-task traces in that function this boot; it warns with the fix lever stop (or limactl stop -f <vm>), then lever up; both rows are warnings, never failures, and not applicable on OrbStack), the remote proxy’s exposure, the ledgers and settings behind the chat page (sent ledger, verified chat, agent messages, files, push, chat labels), and dictation: voice (off, or on with the socket, the clip limit and the logins with no dictation; it fails when the jail could reach the whisper tool’s -whisper-port), voice tool (asks the tool’s dictation socket for its health: not running, running with whisper-server not ready, or ready with the model) and voice model (the tool’s -model in its -models directory: present, of the pinned size and sha256, and pinned in lever’s table). The voice tool and voice model rows run for any configured lever-tool-whisper entry, also with remote.voice off (agents only: the first whisper tool and its own -dictate-socket); with voice on they check the tool that serves remote.voice.socket. The generic tool row covers the lever-tool-whisper process itself. tts tool (asks lever-tool-tts’s speech socket for its health: not running, not ready, or ready with the model, voice and longest text) and tts model (every file of the tool’s -model in its -models directory: present, of the pinned size and sha256, and pinned in lever’s table) run with remote.voice.read_aloud: server (for the tool that serves speak_socket) or for the first configured tts tool. Each failure prints a specific fix hint. Exits non-zero on any failure. --machine/--backend run the profile away from an instance root. |
lever apply [config] |
Headless bring-up: runs the full ordered plan (jail → broker → images → init-machine → config-registry → scion-server (proxy auth with lever’s assertion key, dev-auth off; restarted only when its auth settings, key or web-assets argv differ, at most once per run, with scion’s experiments pinned first; a hub it starts must have its runtime broker online within 5 minutes, one it restarts within 2, as lever-admin sees it) → hub-identity (lever-admin, scion’s conduit and artifacts experiments pinned off, lever-controller, the project, the controller PAT, and every remote login invited, capped and bound before its first sign-in; runs while agents run) → credential (only when manager.credential_file is set) → register-project (one registration for the tree, linked to the recorded hub project; lever-admin links the jail’s runtime broker to it when it has none) → agent-template → mint-manager-bootstrap → start-manager → remote-proxy (only when remote.enabled: true)) with no attach. --dry-run prints the plan and exits with no side effects (a load-image step names the archive it streams from when image_tar is set). Images come from the host Docker store, or from a docker save archive named by image_tar (no host Docker needed). --progress and LEVER_TRACE as for up. --reset-hub-identity forgets lever’s recorded hub identities and tokens and sets the hub up as new, after a deliberate hub database reset (see hub identities). The load-image step skips the image warm-up when a finished warm-up of the same image archive and lever binary is recorded in .lever-state/lifecycle.json and the jail still holds the image. The non-interactive half of up, for scripts and scheduled runs. Refused with exit 6 while another lever command changes the instance; --wait <duration> waits up to that long instead. |
lever provision |
Low-level: provision the jail only (create the isolated machine, install runtimes + scion, apply egress rules). --machine (default lever-jail), --tree (required), --backend, --allow-port (repeatable). Idempotent; rarely needed directly — up/apply call it for you. |
lever backends |
List the containment backends (orbstack, lima) and the guarantees each declares — the matrix config validation checks your backend: choice against. |
Broker operations
| Command | What it does |
|---|---|
lever broker serve [config] |
Run the capability broker + first-party tools in the foreground (normally up/apply daemonize it for you — this is for debugging and supervised setups). |
lever broker revoke <agent> [config] / lever revoke <agent> |
Revoke one agent on the running broker: its capability tokens stop verifying immediately. |
lever broker bump-epoch [config] |
Revoke all outstanding tokens at once by raising the epoch floor. |
lever service-host pair <name> [config] [--rotate] |
On the lever host: make the broker’s client key for the declared service host <name> in .lever-state/service-hosts/<name>/ (client.key 0600) and print its fingerprint for the service host’s client_fingerprint. An existing key is kept and its fingerprint printed again; --rotate replaces it, and the broker uses the new key from its next call. Refused while the state directory is inside tree. |
lever service-host init <service-host config> |
On the service host: make its server key and self-signed certificate (the config’s cert and key, default server.crt and server.key beside it; the config need not exist or be complete yet) unless both exist, and print the key’s fingerprint for broker.service_hosts[].fingerprint. |
lever service-host serve <service-host config> |
On the service host: serve its tools to the paired broker over mutual TLS, in the foreground, logging decisions to standard error. Refuses a wildcard listen without allow_wildcard_listen, and a config, certificate or key that others can write (or, for the key, read). |
lever acceptance [config] |
Bring up a real jail and drive the six live capability/egress acceptance checks — delegated-read, three scope-envelope denials (a disallowed table, a dropped narrowing filter, a worker self-minting an un-granted cap), egress-refused (allowlisted broker port reachable, admin port blocked, the metadata address 169.254.169.254 rejected and a neighbouring link-local address dropped), and revocation (a token stops working after bump-epoch) — the merge gate for capability-layer changes. Before the checks it waits up to 30 s for each supervised tool to listen (not one held back by manager.read_only), and fails naming a tool that never does, with its log. It takes the lifecycle lock (exit 6, or --wait <duration>). |
lever version / lever --version |
Print the version: the release (X.Y.Z), plus the build’s provenance in parentheses when there is one: the commit (first 12 hex digits, -dirty for an uncommitted tree) for a local build, the module version for a go install …@vX build. make install and the release build stamp the commit themselves, so a build in a git worktree reports its own commit too. A build with no commit (a plain go build in a git worktree, where Go records none) and a dirty build add bin <hash>, the first 12 hex digits of the binary’s SHA-256, e.g. X.Y.Z (bin 3f2a91c04e7b). apply restarts the broker and the remote proxy when this string changes. |
Exit codes
lever exits 0 on success and 1 on an error, with these exceptions from lever up,
lever apply and lever conversation that a script can tell apart by the code (never by the error text, which can carry
text from the jail):
| Code | Meaning |
|---|---|
3 |
lever could not resume the manager. The record and its conversation are kept. Run lever up again (a transient failure clears; a hub stop claim, which the message names, does not: lever up removes the stopped manager container that holds one, which loses its writable layer as any scion resume does, files outside the agent home and the tree such as packages installed by hand, and keeps the conversation; see a stop claim the hub never releases) and lever doctor for the cause; lever up --fresh starts a new conversation (it backs up the old one first). |
4 |
The hub refused the resume (a token without the lifecycle scope, or a phase that cannot be resumed now). The manager is kept. Do not answer this with --fresh. |
5 |
lever conversation new: the manager is in the middle of a turn. Nothing was changed; try again later, or pass --force. |
6 |
lever conversation new, restore or backup, lever up, lever apply, lever stop, lever reload, lever destroy, lever acceptance, lever attach: another command holds this instance’s lifecycle lock; the message names it. Nothing was changed. With --wait, after waiting that long. |
7 |
lever conversation new as the broker’s daily reset runs it: the manager is not running. Nothing was changed. |
A command prints its usage text only after a flag or argument error (an unknown or missing flag, a wrong combination of flags, the wrong number of arguments), not after a failure at run time.
lever worker exit codes
lever worker start, resume, recycle, new-session, suspend, stop and watch (and lever
msg send, for code 9) exit with a code a script can branch on, and --json names the broker’s
reason as well. Never branch on the error text: it can quote a phase or a path.
| Code | Reason (--json reason) |
Meaning |
|---|---|---|
0 |
Done: the worker is running (started or resumed now, or it already was), or suspended or stopped. | |
3 |
not-live, runtime-error |
The start or resume ran but the worker did not come live, or a runtime or staging error stopped the verb. The record is kept (a recycle’s purge that failed deleted nothing); try again, then lever doctor. |
4 |
hub-refused |
The hub refused the resume (a token without the lifecycle scope, or a phase that cannot be resumed now). The record is kept; try again shortly. |
8 |
no-record |
resume, new-session, suspend, stop: the hub has no record of the worker (never started, purged, or a new jail). Start it: lever worker start NAME. watch: the record is gone or was replaced, so the watch is over. |
9 |
not-running, no-record |
lever msg send only: the recipient (a worker, or the manager) is not running or has no record, so nothing was sent. |
10 |
not-a-worker, revoked, role-mismatch, image-unqualified, workspace-unsafe, workspace-missing, shared-access, task-file, bad-task, task-too-long, task-fixed, config-file |
Refused for a reason that won’t pass by itself: an undeclared name (the manager’s included), a revoked identity, a record whose stored role isn’t the configured one, a record on an image name the hub rewrites to its image registry (image-unqualified: an unqualified name, or none; lever worker recycle NAME, or lever worker purge NAME --force then lever worker start NAME, recreates it), a workspace or shared folder that is missing or reached through a link, shared access the config has withdrawn, a --task-file the CLI refused (task-file: missing, not a regular file, over 64 KiB, empty, or reached through a link in the tree), a flag-shaped task or one over the 15 KiB budget, a task given for a worker whose record exists (task-fixed), or an instructions_file or task_file the broker can’t read or that is invalid (config-file). Fix the cause. |
11 |
no-operator-socket, runtime-unavailable |
No broker answers on the operator socket (run lever up), the state directory is inside the tree (no socket is served there), the running broker is older than this lever and has no route for the verb (run lever reload), or the broker has no scion runtime. |
12 |
not-stopped |
recycle: the worker is not suspended, stopped or in phase error, or its container is still live. Nothing was deleted. lever worker stop NAME, then recycle. |
75 |
worker-cap, busy, rate-limited |
Too many workers are running (max_running_workers), so this one may not start now; another lifecycle action of the worker is under way (recycle never waits for one; the other verbs wait for it to finish instead of returning busy); or the worker was recycled less than a minute ago (by you or the manager). Try again later. |
1 |
anything else | Any other error. |
The broker sends the reason in an X-Lever-Reason response header (and the worker’s phase in
X-Lever-Phase, when the refusal depends on it) on these operator-socket routes
(/operator/worker/{list,start,resume,recycle,new-session,suspend,stop,events}; the remote chat
page’s resume for a chat login, on /operator/worker/resume, adds not-asleep and
stopped-by-operator) and lever msg send’s /operator/note. The names are stable: new ones may
be added, none is renamed. X-Lever-Phase (and --json’s phase) is one of scion’s phase names
(suspended, stopped, completed, error, running, and the interim phases on the way up) or
running-container-down, a record the hub reports running whose container has died.
lever doctor and lever init --check exit 1 when a check fails. lever tree snapshot exits
3 for an incomplete snapshot and 75 when another snapshot holds the store, and
lever tree lock exits with its command’s status (75 when the lock is busy).
Operator directives
Authenticated delivery of an operator-signed action to a target agent — see the operator: block
in the config reference for the signer trust anchor and expiry defaults, and
security-model.md for the verification mechanism. Like most lever
commands, directive subcommands resolve [CONFIG] from an explicit argument or ./lever.yaml
in the current directory.
| Command | What it does |
|---|---|
lever directive send <agent> (--instruction TEXT \| --action JSON) [--expires DUR] [--key PATH] [--not-before RFC3339] [CONFIG] |
Sign and submit a directive to <agent> (the manager’s app name, or a declared worker) over the broker’s host-only admin socket. --instruction/--action are mutually exclusive, exactly one required. --expires defaults to operator.directive_expiry, capped by operator.directive_expiry_max. --key defaults to operator.signing_key. Prints the exact statement bytes it signs, for operator review, before sending. <agent> may also be the literal manager. A --not-before more than 2 minutes in the future is refused before the broker is contacted (the broker enforces the same limit). Exits non-zero when the target is not enrolled or not running, or when the notice does not reach the agent (the broker then revokes the directive). See operator directives. |
lever directive list [--state active\|consumed\|revoked\|invalidated\|expired] [--key PATH] [CONFIG] |
List directives and their state. |
lever directive revoke <id> [--key PATH] [CONFIG] |
Revoke a directive by id. |
lever directive selftest [--key PATH] [CONFIG] |
Round-trip a self-signed test directive (sign → verify) against the configured allowed_signers, to catch misconfiguration before it’s needed for real. |
Remote access
Exposes the Scion hub web UI to a Tailscale tailnet through a host-side, credential-injecting
reverse proxy — see the remote: block in the config reference for the
options, and the remote access guide for the accepted security posture and full
setup (including the one tailscale serve command lever asks you to run). Like most lever
commands, remote subcommands resolve [CONFIG] from an explicit argument or the current
directory.
| Command | What it does |
|---|---|
lever remote serve [CONFIG] |
Run the proxy in the foreground. Normally up/apply daemonize this for you when remote.enabled: true (lever stop stops it alongside the rest of the instance); this is for debugging. Refuses to start with remote access disabled. Prints the remote-settings warnings (a non-loopback bind, trust_forwarded_host) to stderr, which is .lever-state/remote.log when daemonized. |
lever remote status [CONFIG] |
Proxy liveness (pid + listening on its bind address), the identity header, the tailscale serve command to run (loopback bind only), any remote-settings warnings, the serve URL from base_url, and whether the remote PAT is present — never its value. |
lever voice fetch [MODEL] [CONFIG] |
Download a Whisper model for the whisper tool (the chat page’s dictation), or a speech model for the tts tool (its server voices). It goes into the -models directory of the lever-tool-whisper entry in broker.tools (an absolute path outside the tree; config load refuses one inside it); with no such entry, into .lever-state/voice-models/, and then point the tool’s -models at that absolute path. The directory is 0700, the file 0600. MODEL is a name from lever’s pinned table (default: the tool’s -model, else large-v3-turbo). The URL names a fixed commit of the model repository on Hugging Face; the download goes to a temporary file, and only a file of the pinned size and sha256 is renamed into place. A redirect to a host that is not Hugging Face’s own is refused. Refuses a table entry that is not verified yet, and a state directory inside the tree. A file already there that verifies is kept. Never run by up, apply or the tool; the running tool picks up a fetched model within a minute. A speech model (kokoro-en-v0_19, piper-en_GB-alba-medium) is one .tar.bz2 archive from sherpa-onnx’s tts-models GitHub release, pinned by size and sha256 (a redirect to a host other than github.com and GitHub’s release-asset hosts is refused). Only a verified archive is unpacked, and only its directories (0700) and regular files (0600) below its top directory; a link, a device or a path outside it refuses the archive. It is unpacked into <models>/<model>/ of the lever-tool-tts entry’s -models, or of .lever-state/voice-models/ with none, with a manifest of every file’s size and sha256, replacing an old copy, and the archive is deleted. Without MODEL it fetches the whisper tool’s model (or large-v3-turbo) and, when a tts tool is configured, that tool’s -model too; with only a tts tool, only its model. |
SSH
Off unless ssh.enabled is set and the jail’s scion has the hub’s conduit controls; see the
SSH and VS Code guide and the ssh: block in the config reference.
Both resolve the instance from --config PATH or lever.yaml in the current directory, and refuse
while the state directory is inside the tree.
| Command | What it does |
|---|---|
lever ssh [--config PATH] NAME [-- COMMAND...] |
An SSH session into the manager (its name, the app name) or a named worker, with your ssh client and none of your ssh config (-F none; agent and X11 forwarding, port forwards, local commands and connection sharing off). Everything after NAME is the remote command. Exits with the remote command’s status. --proxy carries the session on stdin and stdout instead, as an ssh ProxyCommand (what ssh-config writes): it checks that lever turned conduit on and that the running hub carries lever’s conduit policy, mints a 12-hour tunnel token as lever’s controller (agent:attach, agent:port_access, agent:read, project:read, the instance project only), pins the agent’s reported host key in .lever-state/ssh/known_hosts, runs scion tunnel --stdio --ssh in the jail with the token on its stdin, and revokes the token when the connection ends. |
lever ssh-config [--config PATH] [NAME...] |
Print one ssh Host lever.<name>.<agent> block per agent (default: the manager and every worker) with lever ssh --proxy as its ProxyCommand, then the VS Code settings Remote-SSH needs, as comments (remote.SSH.localServerDownload: always, remote.autoForwardPorts: false, and agent and X11 forwarding off). Writes nothing. |
lever-manager — in-jail orchestration
Run by the manager agent inside its container (baked into the agent image, on PATH). Every call
is authenticated by the broker over mTLS and validated against the instance config — the manager
can only reach workers the operator declared.
| Command | What it does |
|---|---|
lever-manager agent start NAME --task "…" |
Dispatch a declared worker that has no existing record. The broker resolves the worker’s image and workspace from the config host-side; --task is the only routine flag and fixes the worker’s task at creation; it defaults to "Read your context, then begin.", so agent start never sends an empty task. Against an existing worker agent start returns HTTP 409 (its task can’t be changed in place) — use agent resume to re-run its original task, msg send to give a running worker new work, or, to start it fresh with a new task, agent recycle it (a recyclable worker) or ask the operator to lever worker purge it. Confirms the worker is live before reporting success. |
lever-manager agent recycle NAME --task "…" |
Discard a worker’s record and start it fresh with a new task, in one call: the same teardown as lever worker purge (the scion record and the staged ticket; never the workspace), then the same start as agent start. The worker’s old conversation is lost. Only for a worker with recyclable: true in the config (403 otherwise), and only for one that is suspended, stopped or in phase error (409 otherwise, nothing deleted: agent stop it first); a worker with no record is simply started. Refused with 503 while another start, resume, stop, wake or heal of the worker is under way, and with 429 (and Retry-After) within a minute of the worker’s last recycle. Every recycle is in the broker audit log (op=worker, recycle NAME, the task’s size and its first 60 bytes only). --task defaults as for agent start. |
lever-manager agent list |
List worker agents and their phases. |
lever-manager agent stop NAME / suspend NAME / resume NAME |
Worker lifecycle, broker-routed. Suspend keeps the container (cheap resume); stop removes it but keeps the record. Resume answers when the worker is live (it recovers an error-phase record, and is a no-op for a running one); a worker must be running before msg send reaches it (409 otherwise). |
lever-manager msg send "…" --to NAME |
Message a running agent. NAME is <name> or agent:<name> for a worker, or user:manager to reach the manager (the form taught to workers). --interrupt injects the message ahead of the recipient’s next turn. Routing is identity-derived and default-deny; a refused send names the addresses the caller may use. |
lever-manager msg recipients |
List the addresses the calling agent may pass to msg send --to, one per line, as the broker’s policy has them: the manager sees itself and every declared worker; a worker sees user:manager, itself, and the other workers only when messaging.worker_to_worker allows them. Read-only. |
lever-manager msg list |
Read the typed agent-event inbox; --worker <name> reads a worker’s inbox (manager only). --all includes already-read events. Each event’s message starts with worker-reported: (the worker’s own text, sanitized and cut to 1 KiB); a status the hub does not produce shows as UNRECOGNISED. |
lever-manager watch --events-file PATH |
Bridge scion agent events (state changes, input-needed) into a file, appending as they arrive (--interval seconds between polls, default 5). The manager tails this to get live worker notifications. A poll the broker does not answer, or answers with 5xx, 408 or 429, is logged to stderr and retried with backoff up to a minute; any other error ends the command. |
lever-manager version / lever-manager --version |
--version prints this binary’s build, as lever version does, and the Jail ABI it was built with: lever-manager X.Y.Z (<commit>), jail ABI N. version prints that line, then asks the broker (GET /version) for the host lever’s and prints host lever X.Y.Z (<commit>), jail ABI N and a verdict: match (the same build), compatible (the same Jail ABI, so no rebuild is needed) or mismatch with the fix, e.g. mismatch: image 0.50.0 (<commit>) (ABI 6), host 0.51.0 (<commit>) (ABI 7): rebuild the agent image, then lever up --fresh --keep-conversation (a worker is told to ask the operator for it). A host lever that predates the route is a mismatch. Fails when the broker does not answer. Workers run it too. |
Inside agent containers
Two more surfaces exist inside every agent container, wired up automatically at boot:
lever-capability— an MCP tool (not a shell command) the agent’s harness calls to mint capability tokens:request {tool, op}returns a token to pass as the_capabilityargument on gated tool calls;delegatemints a token bound to another agent. The scaffolded operator skills (lever init) teach agents this flow.lever-agent— the boot/enrolment binary the pre-start hook runs (key generation, broker enrolment, MCP registration, token renewal). Not for interactive use, exceptlever-agent --versionandlever-agent version, which print the same report as thelever-managerforms for lever-agent’s own build (devwhen built without the Makefile’s stamp). Unlikelever-manager, it needs a readable bootstrap file and does not fall back to the agent gateway.