Config reference, lever.yaml
A lever application is described by a single config file. It declares the manager agent and the workers (project agents) it orchestrates, plus how the jail is built around them.
The canonical filename is lever.yaml, placed at the instance root. The root holds the
config and the boot prompt and is NOT mounted; only a tree: subdirectory is bind-mounted
into the jail. This keeps the config out of the agent-writable mount, a compromised agent can’t
rewrite the config the host trusts on the next bring-up.
Config is resolved from the current directory only, there is deliberately no walk-up
discovery. Run lever from the instance root (where lever.yaml lives), or pass an explicit
config path. A lever.yaml planted in a parent directory can therefore never be picked up. See
security-model §5.
Layout
my-instance/ <- instance root: run `lever` here; NOT mounted
lever.yaml <- the config
prompt.md <- boot prompt (host-only)
workspace/ <- tree: the bind-mounted subdir (agents edit this)
workers/...
lever-manager, the in-jail orchestration binary, isn’t staged in the tree, it’s baked into the
agent image (make lever-image-bins + your Dockerfile’s COPY … /usr/local/bin/lever-manager), so
it’s already on PATH when the manager container boots.
Minimal config
tree: is required and must be a confined subdirectory. api-key is the default LLM-auth mode,
so a real Console key (broker.api_key_file, 0600) is required unless you opt into subscription:
name: myapp
backend: orbstack
tree: workspace
manager:
image: lever.local/lever-claude
broker:
api_key_file: ~/.secrets/anthropic-key # required by the default api-key mode
To use your Claude OAuth token instead (the real token is projected into the agents), opt into
subscription and drop api_key_file:
name: myapp
backend: orbstack
tree: workspace
broker:
llm_auth: subscription
manager:
image: lever.local/lever-claude
credential_file: ~/.scion/oauth-token # 0600; projected to the agents
Providing the Console key
api_key_file must hold a real Anthropic Console key (sk-ant-...), created at
console.anthropic.com → API Keys. That’s a different credential
from the Claude subscription OAuth token used in subscription mode (manager.credential_file,
above) — a Console key is billed per-token via the API, not tied to your Claude.ai session.
Local host. Write it straight to the api_key_file path at 0600, without it ever touching
your shell history or argv:
umask 077 && mkdir -p ~/.secrets
read -rs key && printf '%s' "$key" > ~/.secrets/anthropic-key && unset key
read -rs reads the key from stdin without echoing it or putting it in a command line;
umask 077 means the file > creates lands 0600 immediately, satisfying the permission check
below. (Any editor works too, as long as you set the mode afterwards: chmod 600
~/.secrets/anthropic-key.)
Remote host. Pipe the key over ssh so it never touches the remote shell history or a
world-readable temp file:
pbpaste | ssh user@remote-host 'umask 077; mkdir -p ~/.secrets; cat > ~/.secrets/anthropic-key'
The key travels over the ssh channel only, never as an argument or a remote shell variable;
umask 077 on the remote side is what makes the file cat writes land 0600 before lever
apply’s permission check sees it. Swap pbpaste for however you get the key into your local
pipe (cat ~/.secrets/anthropic-key, a password manager’s CLI, etc).
Rotation. Overwrite the file (keep it 0600) and re-run lever apply/lever up — the key is
read once when the broker process starts, not per request, so a broker already running keeps using
the key it started with until it’s restarted.
Full example
name: assistant # instance identity -> jail machine "lever-assistant"
backend: orbstack # containment backend
tree: workspace # bind-mounted SUBDIR (the root is not mounted)
egress: closed # seal the jail to the broker only (api-key instances only)
scion:
version: 65531d7f # pin a scion commit; a lever release ships this one prebuilt
manager:
image: lever.local/lever-claude
# image_tar: images/lever-claude.tar # optional; ship the image as a docker archive, no host docker needed
model: claude-opus-5 # optional; alias or model ID for `scion start --model`
prompt_file: prompt.md # boot TASK (first user turn, keep it short); resolved at the ROOT (host-only, outside the mount)
instructions_file: manual.md # standing instructions -> the agent's CLAUDE.md, via a file, never argv; same resolution
read_only: [tools] # tree dirs the MANAGER sees read-only (e.g. code the host also runs); create-time only
claude_settings:
auto_compact_window: 400000 # optional; tokens at which Claude Code compacts; applied at every start
after_compact_note: Re-read NOTES.md and the open cards before you go on. # optional; one line, given to the agent after each compaction
daily_reset: "06:30" # optional; a new conversation once a day at this host-local time
# resources: { memory: 3GiB, cpu: "2" } # optional; container limits, create-time only
# Host ports the jail may reach DIRECTLY (an MCP server you run yourself).
# Never a first-party tool's backend port (3201 below): the jail would dial
# the tool past the broker, and config load rejects it.
allow_ports: [3901]
broker:
llm_auth: api-key # the default; no real key in any container
api_key_file: ~/.secrets/anthropic-key # 0600; injected host-side by the /llm proxy
# max_running_workers: 2 # optional; refuse a worker start past 2 running workers
tools:
- name: db
command: [lever-tool-db] # supervised subprocess the broker proxies to
backend: 127.0.0.1:3201 # loopback address it listens on (injected as -backend); reachable from the jail only via the broker
operations:
- { name: read, params: [query, table] } # optional: declared arg names — mint rejects constraints on any other key
allowed_values:
table: [users, orders] # a db capability may only be pinned to these tables
workers:
- name: scratch
dir: workers/scratch # relative to tree (i.e. workspace/workers/scratch)
# image: <ref> # optional; defaults to manager.image
# image_tar: <path> # optional; the archive shipping THIS worker's image (needs image:)
# model: <alias|id> # optional; defaults to manager.model
# resources: { memory: 1536MiB, cpu: "1" } # optional; this worker's container limits
# task_file: tasks/scratch.md # optional; the task of an operator's fresh start; at the ROOT, outside the tree
# autostart: true # optional; `lever up` resumes or starts this worker after the manager
obtain:
- { tool: db, op: read } # this worker may obtain db/read capabilities
shared_folders: # optional; off unless listed
- path: shared/reference # a tree folder, outside every worker dir
writers: [scratch] # mounts it read-write at /shared/reference
readers: ["*"] # every other worker mounts it read-only; the manager always does
security: # optional image policy (both default off)
allowed_image_registries: [lever.local] # only run images from these registries/namespaces
require_image_digest: false # true -> every image must be @sha256:-pinned
For subscription mode instead: drop egress, set broker.llm_auth: subscription, remove
api_key_file, and add manager.credential_file (your OAuth token, projected to the agents).
Keys
Top level
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
name |
string | yes | - | Instance identity. The jail machine is named lever-<name> and the manager’s Scion agent slug is <name>. (Its capability identity at the broker is separate: broker.manager_identity, default manager.) Must match ^[a-z0-9][a-z0-9-]{0,62}$ (it becomes a machine name and a shell token). |
backend |
string | yes | - | Containment backend (the jail substrate). orbstack and lima are the implemented values today; any other name is rejected at load rather than silently substituted. Run lever backends for the guarantee matrix. See containment backends. |
tree |
path | yes | - | A confined relative subdirectory of the instance root, bind-mounted in place into the jail (agents edit these real files live). Must not be . (the root itself is never mounted), absolute, or contain ... Must not itself be a git repository (a .git entry directly in the tree is rejected at load); a plain subdirectory inside a larger git repo is fine, ancestors are not checked. |
scion |
object | no | - | Where the Scion engine comes from (see below). |
manager |
object | yes | - | The manager agent (see below). |
workers |
list | no | [] |
Project agents the manager orchestrates (see below). |
shared_folders |
list | no | [] |
Opt-in tree folders that named agents mount at /shared/<name>, read-write for writers and read-only for readers (see below). Unset: a worker mounts only its own dir. |
egress |
enum | no | open |
Jail outbound network posture (open | closed), applied jail-wide and independent of llm_auth. open: LAN and non-allowlisted host ports dropped, public internet reachable. closed: catch-all DROP so the jail reaches only the broker port; requires a uniformly api-key instance. See security-model §2.2. |
broker |
object | no | - | The host-side capability broker: LLM-auth mode, API-key file, registered tools (see below). |
security |
object | no | - | Optional image policy: registry allowlist and digest pinning (see below). |
operator |
object | no | - | Optional operator-directives config: signer trust anchor, signing key, expiry policy (see below). Unset ⇒ directives disabled. |
remote |
object | no | - | Optional remote-access proxy config: expose the hub web UI to a Tailscale tailnet (see below). Unset/enabled: false ⇒ remote access disabled. |
tree_backup |
object | no | - | Optional host-side snapshots of the tree (see below). Unset/enabled: false ⇒ no snapshots. |
ssh |
object | no | - | Optional lever ssh and VS Code Remote-SSH into agents over scion’s hub relay (see below). Unset/enabled: false ⇒ conduit stays pinned off. |
disk |
string | no | 24GiB |
Lima only — guest disk size (e.g. 24GiB, 40GiB). Lima’s own omitted-disk default is 100GiB, a grow-only qcow2 that can wedge a smaller host; lever caps it at a conservative 24GiB by default. Applied only at jail creation; resizing an existing jail needs limactl disk resize or a recreate. Ignored by the OrbStack backend, which manages its own disk. |
nested_virt |
bool | no | false |
Lima on an x86_64 (Intel/AMD) Linux host only. The manager container gets /dev/kvm, so the manager can run KVM guests (e.g. Lima, to test lever itself) inside its container. Workers and the hub do not get the device. Apply refuses on a host that is not x86_64, or when the host’s KVM nested module parameter (kvm_intel/kvm_amd) is off, and installs a udev rule (/dev/kvm mode 0666 in the guest). The manager gets the device as a /dev/kvm bind-mount volume on its scion record. The jail VM must be created with it (create-time vmOpts.qemu.cpuType: host); an existing VM without /dev/kvm needs a recreate (lever conversation backup, lever destroy, lever up, lever conversation restore). The volume is create-time only: an existing manager gets or drops the device only on a fresh create (lever up --fresh --keep-conversation); lever stop + lever up keeps the old volumes, and apply warns. Turning it off removes the udev rule and sets the device back to mode 0660. Apply also removes the podman drop-in 20-lever-kvm.conf that earlier builds wrote (it gave every container the device; workers created while it was there keep the device until they are recreated). Doctor row nested virt. Security: exposes the host kernel’s nested-virtualization code to the jail guest, and the manager’s /dev/kvm ioctl surface to the guest kernel; use it only for dev instances and keep the host kernel patched. Rejected on OrbStack and on darwin. |
cpus |
int | no | Lima default | Lima only — guest vCPUs (1-256). Create-time only: apply warns when an existing VM has a different value. |
memory |
string | no | Lima default | Lima only — guest memory, e.g. 24GiB (minimum 2GiB). Create-time only: apply warns when an existing VM has a different value. |
host_swap |
bool | no | false |
Lima on a Linux host. false starts a stopped VM in a systemd user scope with MemorySwapMax=0, so the host cannot swap the VM’s memory out, when your user lingers (loginctl enable-linger) and the user manager can set memory.swap.max; otherwise the VM starts the old way and apply warns why. A running VM keeps its cgroup until it restarts (lever stop, then lever up); until then each bring-up warns. lever provision does not read this key. true lets the host swap it, as before, without the warning. The trade-off: memory the host may not swap stays resident, so a host short of memory can OOM-kill the VM’s QEMU instead. No effect on macOS. Doctor row VM host swap. See keeping the VM out of host swap. Rejected on OrbStack. |
guest_kernel |
enum | no | hwe with nested_virt, else ga |
Lima only — the guest’s Ubuntu kernel. hwe: every apply and up installs the guest release’s hardware-enablement kernel, linux-generic-hwe-26.04 on a 26.04 guest (lever’s image) and linux-generic-hwe-24.04 on a VM created on 24.04, when dpkg does not have it, and warns that a reboot is pending until the guest runs it. On 26.04 the HWE package follows the release kernel (7.0) until Ubuntu moves it to a newer series. On another release lever installs nothing and warns. ga: lever installs nothing and the guest keeps the release kernel (7.0 on 26.04, 6.8 on 24.04); switching from hwe back to ga does not remove an installed HWE kernel, and grub keeps booting the newest one. A new kernel alone never makes lever reboot the guest: lever stop, then lever up, reboots it and keeps the manager conversation. It does boot in the one reboot a bring-up that started the VM makes for the kernel command line (what needs a reboot). A failed install is a warning, not a failure. apt waits up to 2 minutes for the apt and dpkg locks (cloud-init may hold them on a fresh VM). While the guest’s egress is closed lever does not run apt, and says so; a failed install is stamped in .lever-state/hwe-install-failed.json (0600, keyed by the VM’s machine id), and while that stamp names the VM under egress: closed, apply and up warn without running apt. An apply under the open posture, or deleting the stamp, tries again. The default follows nested_virt because the 24.04 GA kernel (6.8) is the one a nested VM crashed; an instance without nested VMs keeps its kernel. Doctor row guest kernel. See nested virtualization. Rejected on OrbStack. |
guest_profile |
enum | no | standard |
Lima only — small trims the guest for a small host; standard is the stock Ubuntu cloud guest. Converged on every apply and up, before provisioning. small disables (systemctl disable --now) the units a jail never uses: snapd.service, snapd.socket, snapd.seeded.service, unattended-upgrades.service, apt-daily.timer, apt-daily-upgrade.timer, multipathd.service, multipathd.socket and ModemManager.service, but only those that exist and are enabled; it records them in the guest (/var/lib/lever/guest-profile-disabled-units). It also adds zram swap: lever’s own unit lever-zram-swap.service (no apt package), a compressed swap device in RAM of half the guest’s memory, zstd, priority 100, so a short memory spike swaps instead of the OOM killer taking an agent. A kernel without the zram module only warns. Back to standard (or unset) enables exactly the units lever recorded again and removes the swap; a unit you disabled yourself is never touched. Security trade-off: with the apt timers off the guest no longer installs security updates by itself; update it yourself (limactl shell lever-<name> sudo apt-get upgrade). A recreated VM boots the cloud image this lever release pins, not the newest one, so a recreate alone does not bring it up to date. Why it is opt-in: the guest is the containment boundary, and turning its patching off is the operator’s call. See system requirements & sizing. Rejected on OrbStack. |
scion
Provide at most one of version, source or binary (they are mutually exclusive). Omit all three to rely on a scion already present in the jail.
lever requires a recent scion; the examples pin a supported commit. An unsupported scion fails at
lever apply with an error that names the minimum. Do not work around a value must be
base64-encoded error by encoding the credential yourself; upgrade the pin.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
version |
string | no | - | A scion module version/commit (a commit hash like 63d5d65d, or a vX.Y.Z tag) fetched via the Go module system and cross-compiled into the jail at provision time. No vendored source tree, this is the recommended way to pin Scion. Requires a Go toolchain on the host — plus node >= 20 + npm when remote.enabled, which builds the hub’s web UI from the same fetched module (see below). The pin of your lever release needs no Go toolchain (see prebuilt scion below). |
source |
path | no | - | Path to a local scion source checkout, cross-compiled into the jail at provision time (for local Scion development). Relative paths resolve against the config file’s directory. Requires a Go toolchain on the host, but no module fetch. Also needs node >= 20 + npm when remote.enabled; the web UI is rebuilt whenever you edit web/. |
binary |
path | no | - | Path to an already-built linux scion binary, installed into the jail as-is. Unlike the two above it needs no Go toolchain, no module cache and no egress on the machine hosting the jail — and no node, since lever builds no web UI for it: with remote.enabled the hub serves whatever assets that binary embeds (upstream’s make all embeds them), or its “Web UI Not Available” page if it embeds none — build it on a workstation and ship it. Relative paths resolve against the config file’s directory. lever checks the ELF header against the guest’s architecture before installing, so a wrong-arch build fails immediately instead of as exec format error at manager start. Its integrity is yours to guarantee: unlike version, no module-proxy checksum stands behind it. |
agent_role |
string | no | - | Overrides the role lever stamps on every scion start: none, readonly, baseline or full. You do not need to set it. Empty means lever picks baseline whenever the installed scion supports --role (lever probes the binary for the flag); scion’s own default for an unspecified role is full (agent create, lifecycle and project-secret-read). readonly cannot heartbeat, so a live agent cannot run on it. full grants exactly the hub authority the jail model exists to withhold. Naming a role on a scion that has no --role flag is a hard error, never a silent downgrade. The same role is written as the project’s maximum and default agent role on the hub, so the hub refuses a create above it whoever asks (see security model §4.4). |
telemetry |
enum | no | off |
Agent telemetry posture (off | scion-default), written into the jail’s ~/.scion/settings.yaml. off writes telemetry.enabled: false, so every agent starts with SCION_TELEMETRY_ENABLED=false. scion-default leaves the key to scion and to you. See agent telemetry below. |
Whichever mode you use, lever records the installed binary’s sha256 in the jail and skips the copy when it already matches, so an unchanged scion is not re-streamed on every lever up.
Prebuilt scion
Each lever release ships scion prebuilt for linux/amd64 and linux/arm64, built from the commit the
examples pin (65531d7f). When scion.version names that commit (as written in the examples, as
its full hash or a prefix of at least 7 hex digits, or as its module version), a release build of
lever downloads the binary for the guest’s architecture from its own GitHub release instead of
building it. The host then needs no Go toolchain and skips the scion compile (about 4 CPU-minutes).
The release also ships scion’s web UI built from that commit (a scion-web_<pin>.tar.gz asset of
a few MB), so with remote.enabled lever downloads it instead of building it, and the
host needs neither Go nor node for remote access either.
- The download is https only, from
github.comand GitHub’s release asset hosts. The sha256 and size of every asset are embedded in the lever binary when the release is built. lever checks the download against them, and checks the ELF architecture, before it installs anything. It does not usechecksums.txtor any other file from the server. - A download whose bytes do not match is refused, and
lever applystops with an error that names both digests. lever does not fall back to a build then; setLEVER_SCION_DOWNLOAD=offto build from source once you have looked. - When the download cannot happen (no network, the asset or release missing, a redirect to a host lever does not allow), lever builds scion from source as it does for any other pin, and the step notes prebuilt scion not downloaded; using a source build. That build needs Go; the web UI build needs Go and node.
- The web UI archive is checked the same way (https, the same hosts, the embedded sha256 and size)
before lever unpacks it, and it is unpacked only when it holds nothing but directories and
regular files under plain relative names (no links, devices, absolute paths or
..), within limits on the file count and sizes. An archive that breaks those rules stops the apply like a digest mismatch. The unpacked UI is cached in~/.cache/lever/scion-web/release-<sha256>/(~/Library/Caches/lever/scion-web/on macOS) and reused on the next apply. - The verified binary stays in lever’s cache directory (
~/.cache/lever/scion-bin/on Linux,~/Library/Caches/lever/scion-bin/on macOS). lever hashes it on each apply and downloads it again if it changed. - Set
LEVER_SCION_DOWNLOAD=offin lever’s environment to always build from source (scion and its web UI). - A lever built from a checkout (
make install,go build,go install) embeds no digests and always builds scion.
Agent telemetry (scion.telemetry)
Since scion#1792, sciontool telemetry is on by default, and cloud export is on by default too.
lever configures no cloud destination, so the OTLP receiver that sciontool init starts in each
agent container refuses to start. Every sciontool hook call still tries to export to
127.0.0.1:4317 in the container. The call then waits out a 10 s export timeout and a 5 s
shutdown timeout. Claude Code fires 8 hook events, and Pre/PostToolUse fire on every tool call.
A trivial turn took more than two minutes.
-
off(the default).lever applywrites this top-level block into the jail’s~/.scion/settings.yaml, and keeps all other keys and comments:telemetry: enabled: falseIf the block has other keys, lever keeps them and sets only
enabled: false. An explicitenabled: trueis overridden, because this key is the switch. (scion config setrefuses this key, so lever edits the file.) scion turns the block intoSCION_TELEMETRY_ENABLED=falsefor every agent that it starts. -
scion-default. lever does not manage the key. It removes only the exact block thatoffwrote, and leaves a block with more keys unchanged. Use this mode only when you configure scion telemetry yourself. A cloud destination adds egress and a credential in the jail, so lever does not manage it.
The setting applies when an agent starts. The runtime broker reads the file at each agent
start. A running container keeps the env that it started with, and the hub reads the block only at
its own startup. When apply changes the file, it logs the change and restarts nothing. Run
lever stop && lever up to apply the change to the hub and the manager. The manager
conversation is kept. The scion telemetry row of lever doctor shows the setting in the jail. It
also shows the SCION_TELEMETRY_ENABLED value that the manager container started with, and it
fails when the two do not agree.
lever has no local mode. With cloud export off (SCION_TELEMETRY_CLOUD_ENABLED=false),
the in-container receiver starts on 127.0.0.1:4317/4318 with no new egress. But the receiver
drops every span and log that it accepts, because sciontool has no local file or console sink. The
telemetry.local settings produce env vars that no sciontool code reads. Also, scion’s Claude
provisioner refuses an enabled telemetry block that names no cloud provider or endpoint. A local
mode would thus add work to every hook and give no data.
With remote.enabled, version: and source: additionally need node >= 20 + npm on
the host. Neither carries built web assets, so lever builds the hub’s web UI host-side from the
same source tree and stages it into the guest; binary: is exempt, and so is the scion pin of
your lever release, whose web UI lever downloads (prebuilt scion). Cache location, sizes, and
failure modes are in the remote access guide.
manager
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
image |
string | in practice | - | Container image for the manager agent. Also the default image for workers that don’t set their own (see workers[].image). lever apply loads this image into the jail’s container runtime. Validated for safe ref characters. Without it, agents can’t start: lever up and a fresh worker start refuse, since a record created with no image runs a default name scion rewrites to the hub’s image registry. lever.local/lever-claude (make lever-image) is the full image; lever.local/lever-claude-slim (make lever-image-slim) is the opt-in slim one, about 1 GB instead of 5 (see building the image). Agents run the image under a name with a registry host, which scion never rewrites to the hub’s image registry: a name that has one (lever.local/x, ghcr.io/org/x) runs as written, and any other name x as lever.local/x; two images may not end up with one name, however they are spelled (x:1, docker.io/library/x:1 and lever.local/x:1 are one name). A name under scionlocal/ is refused: write lever.local/x. |
image_tar |
path | no | - | A docker archive (docker save output) that ships image. When set, lever apply streams this file straight into the jail’s container runtime and never touches the host docker store — a deploy host needs no docker at all, and the “already loaded” skip compares the archive’s config digest with the jail’s image ID. The archive must carry image’s tag (a mismatch is a named error; a multi-image archive is fine); image cannot be digest-pinned. Same resolution and confinement as prompt_file: instance root, host-only, outside the mounted tree (enforced), since the archive is the code the agent runs. lever doctor reads the baked Claude Code version from the archive instead of docker. Add it to .gitignore when the root is a repository. |
model |
string | no | - | The LLM the manager runs on, passed to scion start --model: a scion alias (small, medium, large, extra-large/xl) or an explicit model ID (e.g. claude-opus-5). Also the default model for workers that don’t set their own (see workers[].model). Omit to let the pinned scion resolve the model itself. Create-time only: scion resume has no --model, so changing this does not re-point an agent that already exists — only a freshly created one. Editing only this key also leaves a running broker untouched, so workers that inherit it keep the old model until the broker restarts (see Worker model inheritance below). |
prompt_file |
path | no | - | A file whose contents become the manager’s boot task — the agent’s first user turn. Resolved at the instance root (host-only, outside the mount, so an agent can’t rewrite its own next boot prompt). Must be a confined relative path (no .., not absolute). Omit to start with scion’s default task. Keep it short: the task travels on scion’s command line, where tmux caps the whole start command at 16 KiB, so lever apply refuses a prompt over a 15 KiB budget (bytes plus 3 per apostrophe) by name. Must resolve outside the mounted tree (enforced). Standing instructions belong in instructions_file. |
instructions_file |
path | no | - | A file whose contents become the manager’s standing instructions — who it is and how it must operate — delivered through scion’s agent_instructions channel into the agent’s user-level ~/.claude/CLAUDE.md (a managed block, beside any project CLAUDE.md in the tree), never on the command line, so it has no size ceiling of the kind prompt_file has (it is capped at 512 KiB, half of scion’s 1 MiB hub-request limit). Same resolution and confinement as prompt_file: instance root, host-only, and outside the mounted tree (enforced). Must not begin with file://, which scion would read as a file reference. Create-time only: scion stages the text when it provisions the agent and re-projects that staged copy on every later start (an agent that edits its own managed block gets it back at the next start), but an edited file reaches only a freshly created agent, not one being resumed. |
read_only |
list of path | no | [] |
Tree directories the manager container sees read-only, each relative to tree with forward slashes (e.g. assistant/tools). The manager otherwise mounts the whole tree read-write, so keep anything the host also runs (an operator CLI, scripts) under one of these: an agent that could rewrite such code would run it on the host the next time you do. Each entry is bind-mounted read-only over itself, and each directory between the tree root and an entry is bind-mounted read-write over itself: a mount point cannot be renamed or removed, so the agent cannot move the protected directory away and create a writable one at its host path. While it is set, every workers[].dir and its ancestors are pinned the same way in the manager, and must be reached through real directories only (apply creates a missing worker dir; the broker refuses a worker path through a symlink or over an entry), so the manager cannot swap a worker dir for a link to a protected one. It protects the entry directories only: whatever the host code loads from outside an entry (a require path that reaches out, a Gemfile or .bundle/config beside it, the tree’s .git hooks and config when you run git there, mise/asdf files) is still agent-writable; move it inside an entry or do not trust it. Must be clean relative paths (no .., ./, trailing slash or ., and none of $ ~ : ,, which scion or the runtime would interpret), with no duplicates and no entry inside another, compared case-insensitively. No workers[].dir may equal, contain or lie inside an entry (enforced): a worker mounts its own dir read-write and never sees these mounts. lever apply refuses to start the manager unless every entry is a real directory reached through no symbolic link and holds no symlink that points outside it. On a case-insensitive host, a case alias of a protected path inside the container (TOOLS) reaches the same read-only mount (verified on OrbStack, not yet on Lima). Must be ASCII (APFS treats the composed and decomposed spellings of an accented name as one directory); while the list is set, worker dirs must be ASCII too. A protected directory must hold no file with more than one hard link and no symbolic link that does not resolve inside it (apply refuses both). The hard-link rule also catches a pnpm node_modules (hard-linked from its store) and the objects of a git clone --local: keep such things out of protected directories. Edit protected directories in place; never replace the directory itself. The read-only mount covers the directory that existed when the manager was created: replacing it on the host (rm -rf and recreate, a rename-based deploy, a git checkout that removes and re-adds it) leaves the mount on the old directory, and the manager can write the new one until the next fresh create (on Lima on a Linux host (QEMU, 9p) the tree mount was seen to follow the path instead for a rename-based replace and a removed and recreated parent: a container with the manager’s mounts read the new directory, still read-only; not yet run under vz/virtiofs, where you should assume the protection is lost; see security model §5.1.1). lever doctor and lever apply check this live for every entry and pin from the guest side, without running anything inside the manager: the kernel’s mount table of the manager’s process (each directory a mount point, the entries read-only, each coming from the tree directory at its place) and the device and inode behind each mount, which must match the directory the host has there now; that process must also be in the namespaces the container runtime created (read with the guest’s perl, which every Debian and Ubuntu system has). Never recreate worker dirs or the parents of protected directories on the host: a pin replaced that way is reported, but only a fresh create puts it back (on Lima’s 9p tree mount the mount was seen to follow the recreated directory instead, so there is nothing to report). Create-time only: scion keeps a record’s mounts for life, so a new or edited list reaches only a freshly created manager — lever up --fresh for an existing one, which starts a new conversation after backing up the old one (--keep-conversation carries it over). lever apply warns, and lever doctor’s manager read-only paths row fails, when the running manager lacks a mount, holds an entry read-write, or a mount covers another directory than the host has there now. Removing an entry, or a worker while the list is set, also needs --fresh, and so does removing the whole list: the record goes on mounting the old directory, read-only for an entry, and the directory must stay on the host until the fresh create. If it is gone, podman cannot recreate the container and the next resume fails (statfs …: no such file or directory); lever apply and lever up refuse before any resume (a forced resume of an error phase too) and name the directory; they read the mounts off the manager container or, when it has none, off the hub record: recreate it (empty is enough), or run lever up --fresh --keep-conversation. The runtime resolves each mount source again when a resume recreates the container, so right after every resume of the manager (by lever apply, lever up, lever conversation restore or the broker’s re-enrol) lever reads back from the guest where every tree mount of the manager comes from; one that comes from elsewhere or is missing at its place (a directory swapped for a symbolic link after the check moves both) suspends the manager and fails the bring-up. lever doctor’s manager read-only paths row warns about a mount the config dropped and fails on one whose directory is gone, even with read_only unset, reading the hub record too when the manager has no container. See security model §5.1.1. |
claude_settings |
map | no | {} |
Claude Code settings lever delivers to the manager. An allowlist: a key lever does not know is a load error. Today one key: auto_compact_window (int, 100000 to 1000000), Claude Code’s CLAUDE_CODE_AUTO_COMPACT_WINDOW, the context size in tokens at which the session compacts. lever-agent boot writes it as env into Claude Code’s managed-settings file (/etc/claude-code/managed-settings.json in the container; the agent can change it during a session, and boot rewrites it at the next start), the highest-precedence settings scope, and the variable ranks above the autoCompactWindow setting and /autocompact. Applied at every start (a resume included), not only at create: a change reaches a running manager at its next start (lever stop && lever up), and lever apply warns while the running manager differs. Needs an agent image built from this lever or later, and the manager on a new container (lever up --fresh, after a conversation backup; lever stop && lever up resumes the old image). See Claude Code settings. |
after_compact_note |
string | no | - | One line of operator text (at most 300 bytes; no control or format characters) that the manager receives as hook context right after each compaction, marked [after compaction: note from this instance's lever.yaml]. Use it to say what to re-read (a state file, the open cards). Delivered as a SessionStart hook (matcher compact) in the same managed-settings file, beside scion’s own hooks, which stay in ~/.claude/settings.json. Applied at every start, as claude_settings. |
daily_reset |
string | no | - (off) | HH:MM, 24-hour, host local time (quote it: "06:30"). The broker starts the manager on a new conversation once a day at that time, as lever conversation new does (backup first, boot prompt), only while the manager is running and never interrupting a turn: a manager that is busy, not running, or under another change is tried again every 10 minutes for 2 hours, then that day is skipped; a failure is not retried. Read when the broker starts, so a change takes effect at the next lever apply (which restarts the broker) or lever reload. Off with the state directory inside the tree. Record: .lever-state/daily-reset.json; lever doctor’s daily reset row. See a new conversation every day. |
resources |
map | no | scion’s defaults | The manager container’s limits: memory (e.g. 1536MiB, 3GiB; Mi/Gi also accepted; at least 512MiB, and no more than top-level memory when that is set) and cpu (cores such as 1 or 1.5, or millicores such as 500m; no more than top-level cpus when that is set). lever passes them to scion as the agent’s resources.limits, which become podman’s --memory and --cpus. Past the memory limit the kernel kills a process inside that container only, so one agent cannot take the VM down. scion sets no separate swap limit, so where the guest has swap (guest_profile: small’s zram) podman lets a container use up to as much again in swap before that happens. Unset keeps scion’s defaults: a CPU limit of 2 and no memory limit. Create-time only, like model: scion stores them in the agent record and a resume reuses them, so a change reaches the manager at its next fresh create (lever up --fresh --keep-conversation, which backs the conversation up and resumes the new manager on it). Set memory (and cpu) for a manager that runs nested_virt or heavy builds, so an out-of-memory kill stays in its container instead of pressing on the whole guest; see capping a manager that runs heavy work and system requirements & sizing. |
credential_file |
path | no | - | A file whose contents are set as the CLAUDE_CODE_OAUTH_TOKEN Hub secret and projected into agent containers. Relative paths resolve against the config file’s directory; ~/ is expanded. Read at apply time with a permission check (rejected unless the mode is 0600-tight — no group or other bits) and size cap. Its contents reach every agent, point it only at a real, least-privilege, 0600 credential. Rejected if it resolves inside the mounted tree (an agent could read it there, even under read_only). See security-model.md. |
allow_ports |
list of int | no | [] |
Host tool ports the jail may reach over the host alias (host.orb.internal). This opens a host-loopback port to the jailed agent — the egress allowlist is the only thing standing between the guest and whatever is listening there, so list only ports you intend the agent to reach (host-side MCP servers, etc). The broker’s admin port (broker.admin_port, default 8444) is rejected at config load if listed here — it is unauthenticated and meant to be reachable only from the host loopback, never from the jail. |
llm_auth |
enum | no | inherits broker.llm_auth (or api-key) |
api-key (this agent holds only a capability(llm) token; the broker injects the real key) or subscription (the OAuth token is projected to this agent). The whole instance must be uniform: mixing api-key and subscription across manager/workers is rejected at config load (see security-model §6.1). |
obtain |
list of {tool, op} |
no | [] |
Capabilities this agent may self-obtain from the broker. api-key agents are auto-granted obtain: [{tool: llm, op: generate}]. |
delegate |
list of {tool, op, to: [...]} |
no | [] |
Capabilities this agent may mint bound to another agent (to) to hand off. A delegated token is strictly narrower than what the delegator holds. |
workers[]
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
name |
string | yes | - | Worker identity (its agent slug / hub project name). |
dir |
path | yes | - | Worker directory, relative to tree and inside it. Mounted in place, so files the worker writes appear on the host. Must not be absolute or escape the tree (..). Must not equal, contain or lie inside the tree root’s .lever-files (compared case-insensitively) while remote.files is on or a whisper or github tool is configured: those tools act on the manager’s files there (.lever-files/whisper, .lever-files/github), and a worker mounting it could plant them. |
image |
string | no | manager.image |
Container image for this worker. Set it to give a worker a different toolchain; omit to inherit the manager image (the common single-image case). lever apply loads each distinct image into the jail. |
image_tar |
path | no | inherited with image |
The archive shipping this worker’s own image, as for manager.image_tar. Requires image; a worker with no image runs the manager image and so ships in the manager’s archive. One image ref may come from only one archive. |
model |
string | no | manager.model |
The LLM this worker runs on. Set it to give a worker a different capability/cost point; omit to inherit the manager’s model (and, if that is unset too, scion’s own default). Resolved host-side from config, so the manager cannot choose it when it asks for a worker to be started. Create-time only, as for manager.model. |
instructions_file |
path | no | - | This worker’s standing instructions, as for manager.instructions_file. Not inherited from the manager — the manager’s manual describes orchestration authority a worker must not hold — so a worker without it gets no lever instructions. The path is config-resolved on the host (the manager cannot choose it); the contents are read at each fresh dispatch, so an edit reaches the next newly created worker without a broker restart. The task a manager sends with a dispatch is subject to the same 15 KiB budget as prompt_file; the broker refuses an oversized one with 413 and the reason, whatever the worker’s phase. |
claude_settings / after_compact_note |
map / string | no | - | This worker’s own, as for manager.claude_settings and manager.after_compact_note. Not inherited from the manager. The broker stages them in the worker’s enrolment envelope before every start and resume, so a changed value reaches the worker at its next start (a config change restarts the broker on the next lever apply). |
resources |
map | no | scion’s defaults | This worker’s container limits, memory and cpu, as for manager.resources. Not inherited from the manager. Sent when the broker creates the worker; a resume reuses the record’s limits, so a change reaches a worker at its next fresh start (lever worker purge, or a recycle) after the broker restarted (lever apply restarts it when the config changed). |
llm_auth |
enum | no | inherits broker.llm_auth |
Same as manager.llm_auth, per worker, but the instance-uniform rule still applies. |
obtain / delegate |
list | no | [] |
Same shape and meaning as manager.obtain / manager.delegate. |
recyclable |
bool | no | false |
Lets the manager discard this worker’s record and start it fresh with a new task (lever-manager agent recycle NAME --task "…"), for a worker slot reused for new work that needs a fresh context. The recycle deletes what lever worker purge deletes: the scion record (so the worker’s conversation is lost) and the staged ticket, never the worker’s dir. The broker allows it only for a worker that is suspended, stopped or in phase error and whose container is not running (read from the hub record and from podman in the jail), never the manager, and at most once a minute per worker. Off, only the operator discards a record (lever worker purge, or lever worker recycle, which does not need this setting: it is the manager’s authority only). The broker reads it at start: lever reload after a change. While at least one worker sets it, the manager’s lever-operator skill carries the recycle directions (lever init renders them); with none, the skill is unchanged. On an instance with contact logins, setting it (and lever init) changes the manager’s skill, so contacts cannot post to the manager until it starts fresh, which needs a new conversation (lever up --fresh backs the old one up; --keep-conversation would keep the old skill too): plan the change with the manager’s next fresh start (lever up --fresh), which it also needs to get a manager image whose lever-manager has the recycle verb. See worker slots the manager recycles. lever doctor’s worker recycle row lists the recyclable workers. |
task_file |
path | no | - | A file holding the task this worker starts on when the operator starts it fresh with no task of its own: lever worker start NAME or lever worker recycle NAME without --task-file, and autostart. The manager’s agent start never reads it (the manager names its own task). Same resolution and confinement as prompt_file: instance root, host-only, and outside the mounted tree (enforced at load), so an agent can’t choose a worker’s task through it. It must be a regular file, is trimmed, and is held to the same 15 KiB task budget; the broker reads it at each fresh start, so an edit needs no restart, and a file that is missing, not a regular file or over the budget refuses the start with reason config-file. Omit it to start with no task. A resume never uses it: scion fixes a worker’s task when it creates the record. |
autostart |
bool | no | false |
Have lever up and lever apply bring this worker up once the manager is up: one worker-autostart NAME step per such worker, at the end of the plan (after start-manager and the remote proxy), one at a time. The step asks the running broker, over its operator socket, for what lever worker start NAME does with no --task-file: a running worker is left alone, an existing record (suspended, stopped, completed, error) is resumed, and a worker with no record is started with its task_file. A failure (the running-worker cap, a refused resume, a worker that does not come live within 3 minutes) is a warning that names the worker and lever worker start NAME to retry; the bring-up still succeeds. It resumes a worker you stopped with lever worker stop as well: unset it to keep such a worker down. |
shared_folders[]
A shared folder is a tree directory that the agents you name mount at the same path,
/shared/<name>: read-write for each writer, read-only for each reader. It is the one way to give
workers a common directory; without it a worker mounts only its own dir. Typical uses: reference
material every worker reads, a hand-off folder one worker fills for the others, build outputs, or
reviewed tools that one agent publishes and the rest run. Off unless listed, and every mount is
opt-in per agent.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
path |
path | yes | - | The folder, relative to tree, spelt like a manager.read_only entry: a clean ASCII relative path, no .., ./, trailing slash or ., none of $ ~ : ,. Must not overlap another shared folder, a manager.read_only entry or any worker dir (compared case-insensitively): a worker mounts its own dir read-write, so a folder in or over one would be writable whatever writers says. No component may be .lever, .lever-state or .lever-files (directories lever writes for the host). |
name |
string | no | last component of path |
The mount name: the folder appears at /shared/<name> in every agent that mounts it. Must match ^[a-z0-9][a-z0-9._-]{0,62}$ and be unique. |
writers |
list of agent | no | [] |
Agents that mount the folder read-write: declared worker names, or the instance name for the manager. May be empty when only the host writes the folder. |
readers |
list of agent | no | [] |
Workers that mount it read-only: declared worker names, or "*" alone for every worker that is not a writer. Quote the star (readers: ["*"]): a bare * is YAML alias syntax and the file does not load. The manager may be named but need not be (see below). |
At least one of writers and readers must name an agent, and nobody may be in both.
- The manager always sees the folder. It mounts the whole tree. Unless it is a writer, it gets
the folder read-only at both its tree path and
/shared/<name>, like amanager.read_onlyentry. A manager that writes the folder mounts it read-write at/shared/<name>, and the folder is pinned in its tree (bind-mounted over itself). Either way every directory between the tree root and the folder, and every workerdir, is pinned too, so the manager can neither move the folder away nor swap it, or a worker dir, for a link that a worker would then mount. - A worker that is not listed sees nothing of it.
- The host checks every mount source. Every
lever apply/lever upthat starts or resumes the manager, and the broker before every worker start and resume, refuse a folder that is missing or reached through a symbolic link. Create the folder before you start the agents. Right before it creates the manager,lever applyalso refuses a file in a folder that has a hard link outside it (another name for it elsewhere could edit it), and a directory in it that it cannot read: copy such a file in instead of linking it, and keep the folder readable. Hard links with every name inside the folder are fine. Nothing re-checks hard links later: no new link across the folder’s boundary can be made from a container. - Workers wait for the manager’s pins. While any folder is configured, the broker refuses to
start or resume any worker unless the manager container holds its whole tree plan (checked
as the
manager.read_onlytool guard checks it: the configured mounts, then, for a running manager, its live mounts from the guest side). A manager with no container, or a stopped one whose configured mounts hold the plan and whose planned directories all exist on the host, passes. A manager created before the current plan (a folder or a worker added since) lacks pins: runlever up --fresh --keep-conversation. When lever cannot read the mounts or the worker’s hub record, the start or resume is refused with “retry”. - New contents show at once. It is a bind mount: files a writer adds appear in every reader
without a restart. Change the folder’s contents in place; never replace the folder itself
(
rm -rfand recreate, a rename-based deploy): a mount stays on the directory that existed when the container started, so agents would go on seeing the old one.lever doctorfinds a replaced folder in a running reader (writers’ mounts are checked by configuration only); recreate the agents it names. - Symbolic links inside the folder are the writers’ business. A link resolves in the container that follows it, so it reaches only what that agent can already see.
- No host program may live in a shared folder, and neither may
remote.labels_filewhen a worker writes the folder. Config load refuses a toolcommandwhose program or script resolves inside one: a writer could change it, and no shared folder excuses one the waymanager.read_onlydoes. - Changes restart the broker and need fresh agents. The broker reads
lever.yamlonly when it starts, and a change toshared_foldersormanager.read_onlychanges its config hash, so the nextlever apply(orlever reload) restarts it with the new plan. scion keeps an agent’s mounts for life, so adding a folder, or changing who may read or write it, reaches only an agent created afterwards: a worker afterlever worker purge(or the manager’sagent recyclefor a recyclable worker) and a new dispatch; the manager afterlever up --fresh --keep-conversation, which keeps its conversation (plain--freshstarts a new one). The broker refuses to resume a worker whose record holds a shared mount the config no longer grants (removed, or read-write where it is now read-only), and names the purge. While any folder is configured it also refuses a resume when it cannot read the record (“retry”). With none configured it still reads the record and refuses one that holds a/sharedmount (left from before the folders were removed), but a failed read does not hold the resume back, so an instance without folders never waits on that read. A mount the config adds that an older worker lacks is not refused: that worker goes without it until it is created again. The manager’s own resume is not refused: a manager that lost access keeps its old mounts untillever up --fresh, andlever doctorfails meanwhile.lever doctor’s shared folders row compares every agent’s mounts with the plan (a leftover or wider mount fails, a missing one warns) and checks running readers’ mounts from the guest side; the manager read-only paths row covers the manager’s tree-path protection and pins.
Security: a shared folder is a deliberate channel from its writers to its readers. Whatever a
writer puts there, every reader (and the manager) can read and may run, so a compromised writer can
influence what the other agents do. Grant writers only to agents you would trust with that, and
see security model §4.5.
security
Opt-in image policy applied to manager.image and every worker image. Both default off, so existing
configs are unaffected until you turn them on.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
allowed_image_registries |
list of string | no | [] (off) |
An image is allowed only if it equals, or is prefixed by <entry>/, one of these entries, a registry host and/or namespace prefix (e.g. lever.local, ghcr.io/myorg). Matched on whole path components (lever.local allows lever.local/x but not lever.localevil/x). The check applies to the name as configured. Empty ⇒ any registry. Stops a config from running an image from an untrusted source (the image is the code that runs as the manager/worker, with the projected credential). |
require_image_digest |
bool | no | false |
When true, every image must be pinned by content digest (…@sha256:<hex>) rather than a mutable tag like :latest. Guarantees you run exactly the bytes you vetted (a tag can be re-pointed to different content later). |
Note: these are enforced at config-load (host side), so they bound what
lever applyloads and what the manager/workers declare.lever-manager agent starttakes no image argument; the broker resolves each worker’s image from the validated config.
broker
The host-side capability broker (outside the jail) that holds the real credential and mints CN-bound, short-lived capability tokens. See security-model §6.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
llm_auth |
enum | no | api-key |
Instance-wide default LLM-auth mode (api-key | subscription), inherited by manager/workers that don’t set their own. api-key keeps the real key host-side (the broker injects it); subscription projects the OAuth token into the agents. The effective set must be uniform, a mixed instance is a hard config error. Egress is a separate knob (top-level egress:), not implied by this mode. |
api_key_file |
path | yes for api-key |
- | A file holding the real Anthropic Console key. Read host-side by the broker /llm proxy and injected into the upstream request; never enters a container. Must be 0600 (rejected otherwise), mirroring credential_file, and must resolve outside the mounted tree (enforced). See providing the Console key. |
llm_upstream |
string (URL) | no | https://api.anthropic.com |
Overrides the /llm proxy target, e.g. to route through an LLM proxy that speaks the Anthropic Messages API. Operator-set only, never client-controlled (no SSRF: the broker always streams to this one fixed host); it still injects the real Console key host-side and strips the inbound capability token first. |
jail_port |
int | no | 8443 |
mTLS port the in-jail agents reach the broker on (allowlisted in the egress rules). Defaults to 8443; set an explicit port only to run several instances’ brokers on one host at once (see two instances on one host). |
admin_port |
int | no | 8444 |
Loopback-only unauthenticated admin port (/register, /revoke, /bump-epoch, /bootstrap, /epoch); bind is rejected if non-loopback. Defaults to 8444. A second instance on the host needs its own; lever apply refuses a broker on this port that reports another instance’s id. |
grant_ttl |
duration | no | 24h |
Capability token lifetime. A backstop only: the per-call epoch/revocation check is the real cut, so a session-scale TTL is safe (and must outlive the 12h renew cycle). |
ticket_ttl |
duration | no | 10m |
Lifetime of a one-time enrolment ticket (the manager-bootstrap and agent-enrol tickets minted at apply). Short by design; only needs to outlive container boot. |
manager_identity |
string | no | manager |
The capability CN the manager enrols under (its certificate identity at the broker), distinct from its Scion agent slug (name). |
auto_reenrol |
enum | no | all |
Which agents the broker auto-heals after a natural mTLS-leaf lapse (all | manager | off). When an agent’s short-lived client leaf ages out while renewal couldn’t run (host asleep, instance idle), the broker’s handshake verification proves the presented cert is its own CA’s, valid in every way but time — never a revoked or foreign identity — then re-stages a fresh one-use enrolment ticket and bounces the agent so boot re-enrols (conversation preserved via resume). Attempts are audited (op=reenrol) and bounded (10 min cooldown, 3 per burst). lever revoke always wins: revoked identities are never healed. The same mode governs the broker’s hub-token watch: every 5 min it reads each running agent’s scion hub token in its container and runs scion reset-auth for one that expired (sciontool’s refresh timer stands still while the host sleeps), at most once per agent per 15 min, audited as op=hub-token; a worker busy with a lifecycle verb is skipped until the next pass, and a record created before scion#1089 (grandfathered to the full role, or storing none) is refused, as is any reset when no pre-role guard is wired. The new token carries the agent’s own stored role. |
max_running_workers |
int | no | 0 (no cap) |
The most workers that may run at once (1-1000). The broker refuses, with 409 and a message naming the cap and the workers that hold it, any start, resume (lever-manager agent start/resume), recycle (before it deletes anything) or chat-page wake that would bring up a worker while this many other workers are running or starting; the re-enrol healer does not resume a stopped worker past it either (a bounce of a running worker is not counted). The manager does not count. The count is read from the hub’s records: every agent in the instance except the manager that is not suspended, stopped or in phase error, or whose container reads live; a phase lever does not know counts, and so does a start the broker admitted that the hub does not show yet. A failed read of the records refuses the start. The cheapest way to keep a small machine from thrashing; see system requirements & sizing. It is a resource guard, not a containment boundary: phase and container status are text an agent can report about itself. |
tools |
list of {name, command, backend, operations, allowed_values, external, gate, allow_non_loopback, host} |
no | [] |
First-party / brokered tools registered for capability minting. command launches the supervised subprocess; backend is the loopback address it listens on (injected as -backend); operations are the {name} verbs — each may declare params (its argument names): when set, a capability mint whose constraint keys fall outside params ∪ caveat_param keys is rejected at mint time with an accurate error, instead of minting an over-narrowed token that fails closed only at call time (a typo’d key otherwise surfaces as a baffling constraint not satisfied far from the mistake); caveat_param (map of constraint key → request argument name, for non-identity mappings such as table: schema.table; a renamed key binds only its argument, and the broker answers 400 to a call that carries an argument named like the key itself, under any case; config load refuses an empty name on either side, a name starting with _, a key renamed to itself under a change of case, and a key renamed to another renamed key, in a chain or a cycle) is an optional declared guard: at registration the broker checks the tool-shipped caveat-param map equals it and rejects the tool (403) otherwise; its keys also count as valid constraint keys for the params check. Omit to accept whatever the tool ships; allowed_values restricts a constraint key to a permitted set (e.g. table: [A, B]), enforced at mint. With external: true the broker FRONTS an already-running host MCP server instead of spawning one: no command, backend is the server’s own listen address (host:port[/path], literal loopback IP unless allow_non_loopback: true), and the tool registers third-party — the broker enforces the rules and strips the capability before proxying. With host: <name> instead of command or backend, the tool runs on that service host (a broker.service_hosts entry): it is external and non-loopback by implication, so external may be omitted and allow_non_loopback must be, and config load refuses a host that names no declared service host. lever ships four first-party tools for command: lever-tool-db (the reference tool), lever-tool-github, lever-tool-whisper and lever-tool-tts. Their settings (credential file, repos, state dir, whisper program and model) are flags in command, not lever.yaml keys: see the github tool and whisper tool guides. Config load refuses host paths in command that resolve inside tree: the program (when given as a path) and the code an interpreter runs (ruby, python3.12, sh and similar, also behind env: the script after the leading flags, a code-loading flag’s value such as ruby -I, and the paths in -c/-e inline code), unless it lies under a manager.read_only entry in no worker dir; and always the values of -app-key, -token-file and -state in any tool’s command, and of -server, -models and -dictate-socket in a whisper tool’s (its program, model directory and dictation socket; no read_only entry excuses them). A whisper tool is an entry whose program, after an env prefix, has the base name lever-tool-whisper; its flags are read in no other tool’s command, so a third-party tool’s -server or -models is its own business. A word that starts with - is never read as the previous flag’s value. For a whisper tool, config load also requires -tree (the instance’s tree), -whisper-port, -dictate-socket, -models and -server, checks -max-seconds (1 to 600) and -agent-max-seconds (1 to -max-seconds), and refuses a -whisper-port in manager.allow_ports, on the broker’s jail or admin port, the remote proxy’s port (with remote access on), or any tool’s backend port. No other flag or argument is checked as a path (-tree, -dsn and -csv are deliberately not): lever cannot tell what an unknown flag names, so keep your own tool’s secrets and programs outside the tree yourself — a best-effort guard, not a sandbox. A path with a .. component is refused. A tool whose program relies on read_only starts only once the broker has seen the running manager hold the read-only mounts (it retries every 30 s; the reason is in the tool’s log). Every tool runs with the instance root as its working directory, and relative paths resolve there; a bare command name resolves on the supervisor’s fixed PATH. See security model §5.5. |
service_hosts |
list of {name, address, fingerprint} |
no | [] |
Other machines that run some of the instance’s MCP servers and their credentials, reached over mutual TLS with both keys pinned; see service hosts. name (lowercase letters, digits, -) is what a tool’s host: names and the directory of the broker’s client key (.lever-state/service-hosts/<name>/, made by lever service-host pair <name>). address is host:port (a name or an IP; no scheme or path; the pin, not DNS, authenticates it). fingerprint is the service host’s server key, sha256: and 64 hex digits, as lever service-host init prints it. Names and addresses must be unique. The broker refuses to start while a host a tool uses is not paired or its key is readable by others, and refuses service hosts while the state directory is inside tree. |
messaging |
object | no | worker_to_worker: true |
Routing policy for broker-routed typed messaging (/msg/send, /msg/list; see architecture.md). worker_to_worker (bool) permits worker→worker sends; it’s a pointer under the hood so unset ⇒ allowed, an explicit false denies it for a stricter hub-and-spoke model. Recipients themselves aren’t a config key, they’re resolved from the caller’s mTLS identity: the manager may message any declared worker and read any inbox (msg list --worker <name>); a worker may always message the manager and read only its own inbox. |
External MCP servers (external: true)
An external tool is a host MCP server the broker fronts but does not spawn — it keeps
running as your own user-session process (launchd, a terminal, however you run it), which is
what keeps macOS Automation/TCC grants intact for AppleScript-driven servers. The broker
registers it from config at boot, exposes it at /mcp/<name>/ on its mTLS listener, and
proxies to backend. Jailed agents therefore reach it only through a capability — no
manager.allow_ports hole, no hand-authored .mcp.json. Every request goes to backend’s own
path (/ when backend has none) with the agent’s query string dropped; any other path under
/mcp/<name>/ gets 404 and never reaches the server, so its other routes stay out of reach.
Per-tool capability grain, gate:
fine(default): only the MCP tools listed underoperationsare callable; a token must name the specific operation, andallowed_valuescan pin arguments.coarse: one wildcard grant —{tool: <name>, op: "*"}— admits every MCP call the server exposes (declare nooperations). The wildcard is honored only for agate: coarsetool: the broker chooses which capability to require, so a"*"token can never widen afinetool. The audit log records the real MCP tool called either way.
broker:
tools:
- name: docs-search # fine: only search is callable, database pinnable
external: true
backend: 127.0.0.1:3302
operations:
- {name: search}
allowed_values:
database: [work, personal]
- name: tasks # coarse: whole surface behind one wildcard grant
external: true
backend: 127.0.0.1:3300
gate: coarse
- name: notes # the server mounts its MCP endpoint under a path
external: true
backend: 127.0.0.1:3901/mcp
gate: coarse
workers:
- name: agent-y
dir: workers/agent-y
obtain:
- {tool: docs-search, op: search} # Y: docs-search search ONLY
manager:
obtain:
- {tool: tasks, op: "*"} # manager: full tasks
backend must be a literal loopback IP (127.0.0.1 / [::1]; hostnames are rejected):
the broker proxies host-side, so a non-loopback backend would let a jailed agent reach
another host through the broker, bypassing the jail’s LAN-drop egress. If you truly need
that, set allow_non_loopback: true on the tool — an explicit, per-tool opt-in.
Liveness is yours: the broker does not restart an external server. While it is down, every
call to the tool gets 503 with the plain body tool <name>: backend unavailable; try again
later. The body names only the tool, never the backend address. Claude Code keeps the server
connected on an HTTP error, so only that call fails, and the next call works once your server is
back. If the restarted server no longer knows the client’s session, its own 404 reaches the
client unchanged, and the client opens a new session. The broker writes one audit line per tool
per minute while the backend is down (decision=error detail=gateway.backend_unavailable, with
backend=<host:port>, the error, and suppressed=<n> for the failures it left out since the
last line). The tool backends row of lever doctor dials the server. The gateway answers every
method but POST (an MCP client’s GET and DELETE) with 405 and Allow: POST: it offers no
SSE stream and no session teardown, which the MCP client accepts.
operator
Optional host-side config for operator directives — authenticated delivery of an
operator-signed action to a named agent, verified by the broker against a trust anchor you
control (see the lever directive command group in the CLI reference). The
whole block is optional: with allowed_signers unset, directives are disabled (the channel
is simply not configured, not half-configured).
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
allowed_signers |
path | no | - | An ssh-keygen allowed_signers file listing keys trusted to sign directives, under the fixed principal operator@<instance-name>. A confined path under the instance dir, host-only — keep it out of the mounted tree:, so a compromised jailed agent can neither read it nor alter the trust anchor (enforced: rejected if it resolves inside tree). Live-editable: the broker shells out to ssh-keygen -Y verify per call, so a key added/removed here takes effect with no restart. |
signing_key |
path | no | - | Default private key lever directive send signs with (--key overrides it per call). A host path, not confined to the instance dir — keep it outside the tree entirely (enforced: rejected if it resolves inside tree), and gitignore it. |
directive_expiry |
duration | no | 10m |
Default directive lifetime when lever directive send is called without --expires. |
directive_expiry_max |
duration | no | 24h |
Hard cap on directive lifetime; a --expires (or directive_expiry) asking for more is rejected. |
operator:
allowed_signers: operator_allowed_signers # ssh-keygen allowed_signers file, confined to the instance dir
signing_key: /abs/path/to/operator_key # default signing key for `lever directive send`; NOT confined
directive_expiry: 10m # optional; default 10m
directive_expiry_max: 24h # optional; default 24h
The operator principal is fixed as operator@<instance-name>, one principal per instance. Key
posture (where to keep the key, multi-key break-glass, no agent forwarding) is in
security model §11.5; setup and usage are in the
operator directives guide.
remote
Optional config for lever remote: a host-side reverse proxy that exposes the Scion hub web UI
(chat, transcript, xterm.js attach) through an authenticating front (tailscale serve by default, or
another front that sets a verified identity header, such as exe.dev), injecting the operator’s hub web session
host-side so the client never holds a credential. The whole block is optional and disabled by default — see the remote
access guide for the accepted security posture, setup steps, and repair.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
enabled |
bool | no | false |
Turns the proxy on. Loads on both backends (orbstack and lima); the Lima path is not yet live-validated (see the guide). Requires node >= 20 + npm on the host with scion.version or scion.source: neither carries built web assets, so lever builds the UI host-side and stages it into the guest. The scion pin of a lever release needs no node (nor Go): lever downloads that release’s prebuilt web UI (prebuilt scion). binary: is exempt, and serves only the UI its binary embeds (upstream’s make all). lever doctor checks it. |
port |
int | no | 8445 |
Port the proxy binds (on bind). Must not collide with the broker’s jail_port/admin_port (rejected at config load). Also rejected if listed in manager.allow_ports: the proxy trusts the identity header because only the front can reach its listener, and an allow_ports hole would let the jail reach it directly. Give each remote-enabled instance on the host its own port: the proxies reach their own jails, so only the host listeners are shared ground. |
base_url |
string (URL) | yes when enabled |
- | The front’s public origin, as an absolute https:// URL with its port when not 443: the tailnet serve hostname (https://myhost.tailxxxx.ts.net), or another front’s (https://myvm.exe.xyz:8445). Rejected at config load if empty while enabled: true: the proxy matches every request’s Origin against the host this resolves to, and an empty base_url would fail closed on every request — a proxy that could never serve anything, so lever refuses to load that config at all. It configures only the proxy; lever deliberately never passes it to the hub as --base-url (see the guide). |
allowed_users |
list of string | no | [] |
Optional identity pinning: when non-empty, only these logins (matched exactly against the identity_header value the front sets) may reach the proxy; a request carrying that header twice, or a comma-joined value, is refused. Empty ⇒ anyone who gets through the front. Each entry must be one login: blank entries, commas, whitespace and control characters are rejected at config load, and so are entries that differ only in case (scion lowercases emails, so they would be one hub user). It also decides the hub user the proxy signs each login in as, so the hub’s user row names the operator who actually connected — with it unset, a placeholder identity (lever-operator@lever.local) is used instead. lever apply invites, caps and binds each login’s hub user before that login first signs in, while agents run; a login removed from the list has its hub user suspended. An address reserved for lever’s own hub identities is refused at config load: lever-admin@lever.local, lever-controller@lever.local, and any @localhost address (scion’s dev user), compared without case. An entry with @ is the hub email as-is; one without (a front’s user id) becomes <id>@id.lever.local and may use only letters, digits and . _ + -. An entry may also be an object, {login, tier, agents, see, files, voice}: tier: contact makes the login a contact that may message only the agents in agents: (required). see: (optional, contact only) lists agents the contact may only see on the chat page — name, label and state, no history, no input. Config load refuses a see: name that is not the manager or a configured worker; a name in both agents: and see:; see: on an operator entry. files: false (bool, contact or operator, default true; read only while files.enabled) gives the login no file exchange: its file routes answer 404 as with files off, its page shows no paperclip or Files panel, and no agent can share with it. For a contact this is enforced (the contact fence allows nothing else); for an operator it only hides the chat page’s file feature and is not access control, since an operator has the whole hub (workspace file API, WebDAV, terminal). voice: false (bool, contact or operator, default true; read only while voice.enabled) gives the login no dictation and no read-aloud: no mic or speaker buttons on its page, and the transcribe route answers 403 voice-off. |
identity_header |
string | no | Tailscale-User-Login |
The request header the authenticating front puts the verified login in, e.g. X-ExeDev-Email behind exe.dev. Read for allowed_users and for the identity asserted to the hub; stripped before forwarding. The front must overwrite it, never append to a client-sent value, and only the front may reach the listener — confirm both for your provider. Rejected at config load if it is not a header name, or if it is a header the browser or any hop sets, or one scion reads as an identity: Authorization, Cookie, Host, Origin, Forwarded, X-Forwarded-For/-Host/-Proto/-Port, X-Real-Ip, Sec-*, X-Scion-*, X-Forwarded-User-*, X-Api-Key, hop-by-hop headers, Tailscale’s user-chosen Tailscale-User-Name/Tailscale-User-Profile-Pic, any name containing _, and a few more. See the guide. |
bind |
string (IP) | no | 127.0.0.1 |
Address the proxy listens on. Loopback by default. A front that dials the host’s external interface needs a non-loopback address; anything that can reach it can then set the identity header to any login, so the host firewall must admit only the front. Accepted only as an IP literal the jail’s egress rules drop in every posture (RFC 1918, CGNAT 100.64/10, IPv4 link-local, IPv6 ULA) — a public address would be reachable from the jail under open egress. IPv6 link-local is refused (it needs an interface zone to listen on). With the default identity_header, tailnet addresses (100.64.0.0/10, fd7a:115c:a1e0::/48) are refused: any tailnet peer could connect directly and forge Tailscale-User-Login — use tailscale serve. 0.0.0.0/:: needs allow_wildcard_bind, and is refused outright with the default identity_header (it listens on the tailnet address too). A non-loopback bind prints a warning on every apply/up/remote serve and shows as a lever doctor warning row. |
allow_wildcard_bind |
bool | no | false |
Acknowledges bind: 0.0.0.0 or "::": every host address, public ones included. Rejected when bind is not a wildcard. |
trust_forwarded_host |
bool | no | false |
Make the proxy’s Host check (its DNS-rebinding defence) read X-Forwarded-Host, when a request carries exactly one and its Host is an IP address, instead of Host — for a front that rewrites Host to an IP address the default refuses. A rebind sends a name in Host, so it is still refused. Off by default: anything that reaches the listener directly can set that header. Warned about like a non-loopback bind. Not needed when the front sends Host as base_url’s host, or — with a specific bind address — as the address it dials (127.0.0.1:<port>, or <bind>:<port>). With bind: 0.0.0.0/:: no dialled address is admitted, so a front that rewrites Host to one needs this setting (or must pass base_url’s host through). |
landing |
string | no | console |
What the remote origin opens on. console: the hub’s own web UI. chat: lever’s chat page — the proxy serves it under /lever/ and redirects / to it. An operator sees the manager and every worker and may message all of them, and the hub’s web UI stays reachable from the page (its Console link) and by its own paths. A contact sees its agents: (to message) and see: (view only), gets no Console or Terminal link, and every page navigation of a contact redirects to the chat page (no contact landing page, no hub web UI shell); the contact fence’s hub rules are unchanged. A message to an asleep worker wakes it (see the guide). chat needs at least one operator login in allowed_users (rejected at config load without one): with no verified login, no message from the page would verify. See the guide. |
labels_file |
string (tree-relative path) | no | - | A JSON file the manager writes, {"<agent name>": "<label>"}, whose labels the chat page shows after each agent’s name. Whoever can write this file sets the text contacts see beside each true agent name, the manager’s row included: by default that is the manager (and anything else that can write that part of the tree). Rejected at config load when absolute, unclean, containing .., or not under tree; when equal to or inside any workers[].dir (that worker would write it); or when inside .lever or the state directory (host-written files). Read on the host through the tree, cached at most 10 s: a regular file of at most 16 KiB with no symbolic link on its path; unknown names are ignored; each label loses control and format characters, has its spaces collapsed, and is cut to 60 characters (dropped when empty). A missing file means no labels; a bad one means no labels and a chat labels warning row in lever doctor. Refused inside a shared_folders folder that a worker writes. See the guide. |
agent_messages.enabled |
bool | no | false |
Lets an agent start a message to a contact that lists it, under a limit and with a host record, and makes the proxy show a contact only the agent messages that record holds (history, events, DM previews, unread counts). The operator’s view is unchanged. Needs at least one tier: contact entry in allowed_users (rejected at config load without one), and scion b3562fb1 (2026-08-28) or later, where the hub sets a message’s sender itself: a scion.version pseudo-version older than that is rejected at config load; for a bare commit hash, a tag or scion.binary lever cannot tell, and the agent messages row of lever doctor warns; a scion.source checkout that does not hold it (older, a fork without it, or a shallow clone that does not show it) is refused at bring-up (apply, up, remote serve) and fails that row, and one git cannot answer for (not a repository) only warns. Off, nothing changes. See the guide. |
agent_messages.follow_up_after |
duration | no | 24h |
How long after a message an agent started (with no answer from the contact) the agent may send its one reminder. 0 or unset is the default; otherwise 1h to 720h. A message from the contact resets the count. |
agent_messages.max_chars |
int | no | 4000 |
The longest message, in characters, an agent may send a contact. 0 or unset is the default; otherwise 1 to 16000 (the hub’s own limit). |
push.enabled |
bool | no | false |
Web Push notifications for the chat page: a login’s devices show “New message from |
push.subject |
string | when push.enabled |
- | The VAPID contact the push services see for this sender (RFC 8292): mailto:<local>@<domain> or an absolute https:// URL, printable ASCII, at most 200 bytes. A change restarts the proxy at the next lever apply. |
push.test_hosts |
list of 127.0.0.1:<port> |
no | - | TEST ONLY: the loopback addresses of a fake push service for lever’s end-to-end test. The proxy admits them only when LEVER_PUSH_TEST_HOSTS in its environment names the same addresses; either one alone stops lever remote serve. Needs push.enabled. lever doctor fails while it is set. Never set it for a real instance. |
files.enabled |
bool | no | false |
Files in the chat: a login uploads one file at a time to an agent it may message, and downloads the files that agent shares with it (broker share_file). Needs landing: chat (rejected at config load without it). While it is on, no worker dir and no manager.read_only entry may overlap .lever-files (compared case-insensitively). Off, the proxy has no file routes, share_file refuses with off and contact_files returns an empty answer with a note, and the skills and config stamps are unchanged. See the guide. |
files.max_bytes |
int | no | 26214400 (25 MiB) |
The largest file, in bytes, for an upload and a share. 0 or unset is the default; otherwise 1 to 104857600 (100 MiB). |
files.extensions |
list of strings | no | pdf, xlsx, xlsm, xls, csv, docx, doc, png, jpg, jpeg, txt, zip |
The accepted file types: lowercase letters and digits, 1 to 10, no dot, each once. html, htm, xhtml, shtml, svg, js, mjs and xml are refused (active content). Office types that can carry macros (xlsm, xls and doc in the defaults) are allowed on purpose; the files row of lever doctor names them (open them with macros off). |
files.uploads |
bool | no | true |
false: no login can upload (the upload route answers 403 uploads-off before it reads the body; the page shows no paperclip). Uploads already made stay listed and downloadable by their owner, and contact_files still lists them. A change restarts the proxy at the next lever apply and changes the skills (lever init first). |
files.shares |
bool | no | true |
false: share_file answers shares-off, and a share already made stays listed but its download answers 403 shares-off (the page shows it without a link). A change restarts the broker and the proxy at the next lever apply and changes the skills (lever init first). |
voice.enabled |
bool | no | false |
Dictation on the chat page: a login records a clip, the broker tool lever-tool-whisper transcribes it, and the text lands in the message box for review (never sent by itself). Needs landing: chat and voice.socket (rejected at config load without them). Off, the proxy has no voice route, and the page shows no mic and no read-aloud button. On, it also turns on read-aloud (voice.read_aloud). The transcription settings (program, model, language, vocabulary, GPU, port) are the tool’s flags, not keys here. See the guide. |
voice.socket |
absolute path | when voice.enabled |
- | The tool’s dictation socket, which the proxy posts each clip to. A clean absolute path outside the tree (a manager.read_only entry does not excuse it), at most 103 bytes, in a directory private to you. When broker.tools has a whisper tool (an entry whose program has the base name lever-tool-whisper), config load refuses a socket that is not one of their -dictate-socket values. |
voice.max_seconds |
int | no | 300 |
The longest clip, in seconds; the page stops recording there. 0 or unset is the default; otherwise 1 to 600, and at most the whisper tool’s -max-seconds (config load refuses more). |
voice.read_aloud |
device, server or off |
no | device |
The speaker button on agent messages. device: it reads with the browser’s on-device voices (nothing leaves the device). server: it reads with a neural voice of the broker tool lever-tool-tts on your host, and with the device’s voices while the tool is down; needs voice.speak_socket. off hides it. true and false are read as device and off. Shown only while voice.enabled. A login with voice: false gets neither dictation nor read-aloud. See server voices. |
voice.speak_socket |
absolute path | when voice.read_aloud: server |
- | The tts tool’s speech socket, which the proxy posts each part of a message to. A clean absolute path outside the tree (a manager.read_only entry does not excuse it), at most 103 bytes, in a directory private to you, not voice.socket. When broker.tools has a tts tool (an entry whose program has the base name lever-tool-tts, or that takes -speak-socket), config load refuses a speak_socket that is not one of their -speak-socket values. The voice settings (program, model, voice, speed, threads, longest text) are the tool’s flags, not keys here. |
remote:
enabled: true
base_url: "https://myhost.tailxxxx.ts.net" # required whenever enabled — see above
# port: 8445 # optional; default shown
# allowed_users: ["you@github"] # optional identity pinning
# identity_header: Tailscale-User-Login # optional; another front's verified-login header
# bind: 127.0.0.1 # optional; a private address only for a front that dials it
# trust_forwarded_host: false # optional; see above before turning on
# landing: console # optional; chat = open on lever's chat page (the login's agents)
# labels_file: workers/labels.json # optional; manager-written labels for the chat page
# push: # optional; chat page notifications (needs landing: chat)
# enabled: true
# subject: mailto:you@example.com
# files: # optional; files in the chat (needs landing: chat)
# enabled: true
# voice: # optional; dictation on the chat page (needs landing: chat)
# enabled: true
# socket: /home/you/my-instance/.lever-state/whisper/dictate.sock # lever-tool-whisper's -dictate-socket
# read_aloud: server # optional; device (default), server or off
# speak_socket: /home/you/my-instance/.lever-state/tts/speak.sock # with server: lever-tool-tts's -speak-socket
tree_backup
Snapshots of the tree on the host, in .lever-state/tree-backups/: see
tree backups. Every key of the block is checked: an unknown key is a
load error. Sizes take a number of bytes or a unit (KiB, MiB, GiB, TiB, KB, MB, GB,
TB); durations take Go’s form (90s, 5m, 2h) or whole days (7d), and 0 for none.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
enabled |
bool | no | false |
Turns tree backups on. |
at_stop |
bool | no | true |
Snapshot at lever stop (also --keep-vm) and lever destroy, after the VM is off. |
keep.last |
int | no | 20 |
Keep the newest N snapshots (at least 1). |
keep.daily |
int | no | 7 |
Keep the newest snapshot of each of the last N days that have one. |
keep.weekly |
int | no | 8 |
Keep the newest snapshot of each of the last N ISO weeks that have one. |
reverify |
duration | no | 7d |
Re-read a cached file verified longer ago than this, from the read budget left over (0: never). |
lock_wait |
duration | no | 2m |
How long a snapshot waits for the store’s locks before it is skipped with a warning. |
min_free |
size | no | 2GiB |
Free space (plus 1% of the inodes) below which no new content is stored; the snapshot then commits as incomplete. It is also the room the manifest may count on beyond its reserve (see a full disk). |
max_store |
size | no | 32GiB |
Disk the retained snapshots may take. Past it, GC deletes the oldest ones except the newest, the newest complete, the newest vm-off and pinned ones; doctor warns. 0: no cap, history is never deleted for space. See store size. |
limits.file |
size | no | 256MiB |
A larger file is recorded without content (too-big). |
limits.read |
size | no | 8GiB |
Bytes read from the tree per snapshot, retries included. |
limits.new |
size | no | 2GiB |
Disk that new content may take in the store per snapshot, once any snapshot of the tree exists (the first one is bounded by read only); each new file is charged at least two 4 KiB blocks. Past it, new files are skipped(new) and the snapshot is incomplete; changed files the last complete snapshot held are stored first. |
limits.entries |
int | no | 1000000 |
New names listed per snapshot (at most 10 000 000). |
limits.manifest |
size | no | 256MiB |
Encoded size of one snapshot’s entries (at most 4 GiB), each charged at its widest encoding: about 550 000 entries at the default. A snapshot holds about twice this in memory, plus 64 MiB. |
limits.dir_entries |
int | no | 100000 |
Names read from one directory. |
limits.depth |
int | no | 128 |
Directory depth (at most 4096). |
limits.path |
int | no | 4096 |
Bytes of one path (at most 4096). |
limits.time |
duration | no | 5m |
Time for the walk and the reads at stop and destroy (0: none). lever tree snapshot has none unless --time. |
exclude |
list of string | no | [] |
Paths never read: a pattern without / matches a name at any depth, one with / is anchored at the tree root and matched per component like a shell glob. Relative, no .., no empty or . component. An excluded path is recorded with its metadata only, and is a place agents can hide changes. |
ssh
lever ssh and lever ssh-config: an SSH session into the manager or a worker over scion’s hub
relay (conduit), for the host operator only. See the SSH and VS Code guide. It needs a
scion with the hub’s conduit controls and agent images built from one; without them apply keeps
conduit off and warns, and lever ssh refuses.
| Key | Type | Required | Default | Meaning |
|---|---|---|---|---|
enabled |
bool | no | false |
Turns lever ssh on. Apply then turns scion’s hub.conduit on, only after it has read lever’s conduit policy back from the running hub, and restarts the hub. Off, every apply pins conduit off. |
deny_ports |
list of int | no | [] |
Agent-local ports (1-65535) no tunnel, port proxy or SSH forward may reach, besides 8462 (lever’s gateway in every agent), which is always denied. SSH Unix socket forwarding is always off. They restrict forwarding only, not what a shell in the agent can reach. An agent enforces the list it was started with. |
Conventions & derived values
- Machine name:
lever-<name>.up/apply/stop/destroy/doctorall agree on this, derived from the config (override onstop/destroy/doctor/worker purgewith--machine).lever downis a deprecated alias ofdestroy. - Worker model inheritance: a worker with no
model:runs onmanager.model; with neither set, lever passes no--modeland the pinned scion resolves the model. The model is fixed when an agent is created (scion resumetakes no--model), so editing it only affects agents created afterwards. The broker resolves the inheritance once, when it starts, and the hash that decides whetherlever applyrestarts the broker coversbroker:/workers:/scion:but deliberately notmanager:— so an edit tomanager.modelalone reaches a newly created worker only after the broker restarts for another reason. Settingworkers[].modelexplicitly is the direct route. This is the same trade-offimage:inheritance already makes. - Worker image inheritance: a worker with no
image:runs onmanager.image(and ships inmanager.image_tar, when set). The bring-up plan loads every distinct image once (deduped), from its archive when the config names one, else from the host docker store. At dispatch the capability broker reads the config directly and supplies each worker’s resolved image, soagent start NAMEtakes no image argument. The manager names a configured worker; the broker resolves paths, image and credentials host-side. - In-place mounts: the
treesubdir (and eachworkers[].dirwithin it) is bind-mounted into the jail so agents edit the real host files. There is no copy/sync step. - Path resolution base: relative
tree/prompt_file/instructions_file/scion.source/credential_fileresolve against the config file’s directory, never the shell’s current directory.
See also
- Getting started, build a working instance from scratch.
- Security model, trust boundaries, threat model, and the credential flow these keys drive.