Give your AI coding agents their own machines — not your credentials.
You're about to hand an AI agent a terminal. On your laptop, that terminal can read your API keys, your GitHub token, your ~/.ssh, and every repo you have checked out — and anything it writes, your IDE and build tools will happily execute.
isx onboards agents the way you'd onboard a new teammate:
- A real machine of their own. Each agent gets a full Linux workstation — its own filesystem, init system, networking, and process tree. It can
dnf install, run Docker Compose, usestraceand nested containers — everything works, because it is a real system, not an app container. Hardware-isolated KVM virtual machines are one flag away when you want the strongest isolation. - Zero credential exposure. API keys and tokens never enter the environment in any form. A host-side TLS proxy injects real credentials upstream, so
claude,pi,gh,git, andcurlwork unmodified inside — yet no credential is ever stored there. See Credential Isolation. - Disposable in seconds. Branch a prepared template like you'd branch a repo — instant copy-on-write clones. Use them, throw them away, branch again from a clean state.
- Full autonomy, no babysitting. Agents commit under their own identity and run without permission prompts — safe to let run, because the blast radius is the branch.
Runs on Linux and macOS, on your hardware. Your code and credentials never leave the building. Everything an agent does can be traced back to it — and nothing it does arrives uninvited.
Agents are the headline, not the limit: the same disposable machines are ideal for triaging untrusted patches, reproducing bug reports, and testing on a clean system — anything you'd rather not run on your host.
Built with Quarkus and Tamboui, powered by Incus system containers. Written in Java, compiled to native binaries with GraalVM. (isx was formerly known as incus-spawn.)
Requires Linux, or macOS 15 (Sequoia) or later; older macOS releases are not supported. On macOS, Apple Silicon is supported; Intel Macs get a binary built for the same macOS 15 minimum on a best-effort basis: it is released, but not verified on an Intel Mac. On Linux, Incus runs natively and isx init auto-installs it via your package manager. On macOS, isx init provisions a lightweight Linux VM automatically via vfkit. The VM starts automatically when needed and can be managed with isx vm start|stop|restart|status|resize|reset. Windows is not supported.
macOS limitations: GUI/audio passthrough (Wayland + PipeWire) and overlay mode for host-resources are Linux-only features. On macOS, use readonly or copy modes for host-resources instead.
brew install Sanne/tap/incus-spawnsudo dnf copr enable sanne/incus-spawn
sudo rpm --import https://download.copr.fedorainfracloud.org/results/sanne/incus-spawn/pubkey.gpg
sudo dnf install incus-spawncurl -fsSL https://sanne.github.io/isx-apt-releases/public.gpg | sudo gpg --yes --dearmor -o /usr/share/keyrings/incus-spawn.gpg
echo "deb [signed-by=/usr/share/keyrings/incus-spawn.gpg] https://sanne.github.io/isx-apt-releases stable main" | sudo tee /etc/apt/sources.list.d/incus-spawn.list
sudo apt update && sudo apt install incus-spawncurl -fsSL https://isx.run | shjbang app install isx@Sanne/incus-spawn
jbang app install isx-proxy@Sanne/incus-spawn# One-time host setup (Incus, firewall, auth)
isx init
# Build a template (builds missing parent images first)
isx build tpl-minimal
# Launch the interactive TUI
isxSee Installation for all options and update instructions. Shell completions are available for bash, zsh, and fish via isx completion <shell>.
API keys and tokens never enter containers in any form. A host-side MITM TLS proxy (isx proxy) provides completely transparent authentication:
- The proxy uses bridge-level DNS overrides and a custom CA certificate so containers transparently route intercepted domains through the proxy
- The proxy terminates TLS, injects real authentication headers, and forwards to the real upstream over TLS — tools (
curl,git,gh,claude,pi) work unmodified inside containers - Containers hold only placeholder values (e.g.
sk-ant-placeholder) that satisfy tools' local auth checks; placeholders cannot authenticate against any real service - Vertex AI support: the proxy transparently translates requests to Vertex AI format — no GCP credentials enter the container
- Claude Pro/Max support: authenticate via
claude setup-token; the proxy injects the OAuth Bearer token transparently - Several accounts per credential: configure e.g. a personal and a client Claude subscription, or two GitHub identities, and choose per template or per instance which one is spent -- see Credential accounts
- HTTPS only: Git operations must use HTTPS URLs (not SSH).
ghdefaults to HTTPS; forgit clone, usehttps://github.com/...
There is no API, endpoint, environment variable, or file that code inside the container can access to obtain real credentials — the injection happens entirely outside the trust boundary.
Each instance also gets a random instance secret every time isx starts it, at the path in $ISX_INSTANCE_SECRET_FILE (/run/isx/instance-secret, readable by the instance user). It is not a credential for anything upstream: it lets a service on the host tell instances apart by source address and secret together, so it is useless from any other instance. A restart replaces it; a reboot isx did not perform leaves the instance without one until isx next starts it.
The proxy must be running for non-airgapped containers. isx init can install it as a systemd user service, or run isx proxy in a separate terminal. The CLI verifies proxy reachability and version compatibility before builds, branches, and shell access.
The proxy also caches container image layers and build artifacts on the host — the same dependency is never downloaded twice (see Caching).
Each start also replaces the placeholders with proof tokens derived from that start's secret (e.g. gho_isx_… in GH_TOKEN), exported by the login profile.
They reach exactly what runs in an instance's login environment.
A nested container, a sandbox or anything started without that environment gets none, unless you pass one in deliberately, e.g. podman run -e GH_TOKEN ….
This does not yet cover an isx running inside an instance: until the outer proxy checks these tokens (#1107), it still serves its credentials to anything that reaches it from the instance's address, the nested isx's instances included. Once it does, they get none of the outer credentials unless the nested isx's config says inherit (see Inheriting the outer instance's account).
A shell opened before the last isx start or isx shell keeps the tokens it was opened with: isx doctor run inside the instance compares them with the secret file and says what to do (a new login shell, or isx shell again from the host).
With a GitHub PAT configured, containers get a ready-made .gitconfig with your GitHub name and email (the private @users.noreply.github.com address when there is one) and sensible defaults.
- Dedicated agent account (recommended): give
isx initthe PAT of a separate GitHub account, so the agent's commits and PRs are clearly its own. - Your personal account: use your own PAT. To bring your aliases and settings along, mount
~/.gitconfigas a host resource; it takes precedence over the generated config.
Commit signing isn't supported inside containers yet. To sign, fetch the changes to your host through git remotes and rebase there (#271).
Like git branch, branching creates an instant copy-on-write clone of any template. Each branch has its own independent filesystem -- changes in one branch cannot affect the template or any other branch. The storage backend (btrfs/zfs/lvm) deduplicates unchanged data automatically, so branches are instant to create and only consume disk space for their own modifications. isx init automatically creates a btrfs storage pool if needed.
tpl-java (stopped template, ~2GB)
├── fix-nasty-bug (running, uses ~50MB extra)
├── review-pr-423 (running, uses ~30MB extra)
└── experiment (stopped, uses ~10MB extra)
You can install packages, break things, and destroy a branch when done. The template and other branches are completely unaffected. Sudo works without a password, and shell sessions set the terminal title to isx:<containername> so you always know which environment you're in.
Branches can optionally enable GUI/audio passthrough (Wayland + PipeWire with GPU acceleration, Linux only), restricted networking, an inbox mount to share files read-only from the host, or different credential accounts than their template's. Resource limits (CPU, memory, disk) are auto-detected from the host but can be overridden. The interactive TUI (isx with no arguments) provides a Midnight Commander-style interface with modal dialogs for branching, renaming, and building, plus F3 detail views and F9 tool actions.
The TUI shows a storage gauge and per-row disk usage so you can see what's filling the pool. Sizes are approximate (marked ~): the base template carries the shared base image, while CoW branches show only the data unique to them. Press C to reclaim space, or on macOS grow the disk with isx vm resize <size>.
Templates pre-install your baseline tools and repos, and integrations plug in through the same tool system: VS Code Remote, JetBrains Gateway, and agent skills.
Each branch runs in one of three network modes:
| Mode | Flag | Description |
|---|---|---|
| Full internet | (default) | Unrestricted network access via NAT, auth via MITM proxy |
| Proxy only | --proxy-only |
Outbound traffic restricted to MITM proxy only (iptables) |
| Airgapped | --airgap |
No network device at all, complete isolation |
Containers created with isx branch are isolated environments, but you need a way to get your changes back. isx integrates with git's native remote helper protocol so you can use standard git fetch, git push, and git pull between host repos and container repos:
# Inside the container, the agent makes some commits...
# Back on the host:
git fetch fix-auth
git diff main..fix-auth/main # review exactly what it did
git cherry-pick fix-auth/main # take what you likeThis is the intended review workflow: you always act on a specific, immutable commit — never on a live directory the agent can still modify (see the FAQ for why there is deliberately no read-write project mount).
The remote uses the isx:// URL scheme (~ expands to /home/agentuser):
git remote add fix-auth isx://fix-auth/~/quarkus
git fetch fix-auth
git diff main..fix-auth/mainThe instance must be running for git operations to work.
If you configure host-paths in ~/.config/incus-spawn/config.yaml, remotes are managed automatically:
# Base directories where your repos live on the host
# Subdirectories are scanned recursively (up to 4 levels deep),
# so ~/projects finds ~/projects/java/my-repo, ~/projects/go/another-repo, etc.
# If a repo name appears in multiple locations, add an explicit repo-paths entry
host-paths:
- ~/projects
- ~/workspace
# Explicit overrides for repos in non-standard locations or to resolve ambiguity
repo-paths:
quarkus: ~/work/quarkus
hibernate: /opt/hibernateWith this configuration, isx branch adds a git remote named after the instance in each matching host repo (protocol-lenient — SSH and HTTPS URLs for the same repo are treated as equal), and isx destroy removes it.
An agent running on your machine -- Claude Code, say -- can use isx itself: create disposable instances from templates you approved, run builds and tests in them, and hand whole tasks to Claude Code or Codex inside an instance. isx mcp serves the Model Context Protocol over stdio:
isx init offers to set this up (it is experimental, so off by default): it registers isx mcp with Claude Code for all projects and asks, for each template that installs Claude Code or Codex, whether agents may use it. Once it is set up, a re-run of isx init asks one question instead: t to change the approved templates, n to see how to turn it off, Enter to keep everything.
To register it with Claude Code by hand instead:
claude mcp add --scope user isx -- ~/.local/bin/isx mcpNothing is available until you approve templates in ~/.config/incus-spawn/config.yaml. Agents never can -- isx mcp reads this section on every call and has no way to write it:
mcp:
templates: [tpl-java, tpl-dev] # the only templates an agent may branch from
max-instances: 8 # per host user, across sessions (default 8)
max-concurrent-tasks: 8 # background commands and delegated agents, per host user, across sessions (default 8)
# delegate-max-turns: 200 # optional Claude turn ceiling; Codex is refused while this is set
delegate-permission-mode: bypassPermissions # Claude's --permission-mode (default shown)
delegate-permission-modes: # per-template overrides, e.g. a reviewer that only plans
tpl-review: plan
codex:
sandbox-mode: danger-full-access # Codex always runs with approval_policy=never
sandbox-modes:
tpl-codex-review: read-only
orphan-grace-hours: 24 # how long an instance outlives its session (default 24)
dormant-after-hours: 24 # past that, one kept only for a delegate showing no activity
# this long is stopped instead (default 24)
dormant-grace-hours: 168 # and destroyed this long after (default 168)
summary-model: haiku # answers the tools' `ask` inside the instance (default haiku)max-instances and max-concurrent-tasks are the two knobs to raise for a controller, one session running many tasks at once. They are a safety net against runaway creation, not a budget: the only cost of a high number is host memory. list_templates reports both, under the same names (max_instances, max_concurrent_tasks). Every tool also returns its facts as structuredContent matching the outputSchema it lists, so a program need not parse the text. A task's state is the same word in every tool: running, finished, attached (finished, and a person is in its conversation), lost or unknown (list_instances gives it as the session last saw it, so unknown there means nothing has read how it ended yet, as after cancel_task; task_status reads it). A refusal says why in _meta["dev.incusspawn/error"].code.
| Tool | What it does |
|---|---|
list_templates |
Approved templates, their delegate_agents, default agent, model and permissions, capability differences, and policy refusals |
create_instance |
A fresh CoW branch of an approved template, as isx branch would make it, with an optional purpose and name_hint; or, with from_instance, a fork of one of your stopped instances -- prepare once, fork N times |
stop_instance / start_instance |
Stop an instance (to fork it; refused while a task runs, unless force), and start it again |
list_instances / adopt_instance |
Your instances and their tasks, including those an ended session left behind; take one back |
exec |
Run a command as agentuser; no time limit unless the agent sets one, or background: true for a task |
delegate |
Give an instruction or installed skill to an instance, or a fresh one from a template; select agent: "claude" or "codex", optionally with model and Claude-only max_turns; purpose/name_hint for a fresh one |
task_status / wait_any / task_result |
Follow a task (optionally waiting), wait for whichever of several finishes first, read its outcome or the agent's report |
send_message |
Continue a delegated agent's conversation (e.g. "push and open a PR") |
get_diff |
What a delegated task changed, committed or not: a patch, or with stat just the files and line counts |
instance_activity |
What the proxy saw of an instance's Claude model calls: made and in flight, when the last one ended, tokens spent; and of all its traffic (traffic_*) |
cancel_task / destroy_instance |
Stop a task, or throw an instance away |
keep_instance |
Hand an instance over to you for good |
list_templates reports each template's delegate_agents and default_delegate_agent.
Use agent: "claude" or agent: "codex" on delegate to select an installed agent explicitly.
When omitted, Claude is selected if installed, otherwise Codex.
send_message keeps the task's agent and resumes its exact saved conversation.
The coordinator may be Claude Code regardless of which agent the worker runs.
Each descriptor reports supports_max_turns, supports_model_preflight, its configured model and permission settings, and a refusal if current host policy prevents launch.
supports_ask and summary_agent describe the separate summary service.
Models and permissions come from the selected agent's configuration; task model overrides persist across follow-ups and adoption.
Claude validates a chosen model against its account before launching; Codex reports model-access errors in the task result.
Codex has no equivalent to Claude's max_turns: an explicit argument is rejected, and a configured mcp.delegate-max-turns prevents Codex delegation and continuation.
The user must revise that policy to use Codex.
ask requires Claude's tool-free summarizer and is refused on Codex-only instances before its command runs; ordinary output, reports and diffs work with either agent.
Codex uses the template's openai credential account and always runs without approval prompts.
If a template has a restrictive Claude permission policy, configure its Codex sandbox mode explicitly before selecting Codex: one restricted in delegate-permission-modes needs its own codex.sandbox-modes entry, as the section-wide sandbox-mode does not apply to it.
The proxy's model_usage_providers currently lists Anthropic; OpenAI traffic is included in traffic_*, and Codex reports task token usage when available.
Restart existing MCP server connections after upgrading to enable Codex workers. Older isx servers cannot manage the new Codex task records; keep outstanding Codex instances before downgrading.
A client that drives isx mcp from code rather than from a model can ask to be told instead of waiting: list isx/task_changed under capabilities.experimental in initialize, and every change of a task's state arrives as a notifications/isx/task_changed (task_id, instance, run, state: running, finished, lost, attached or released, and exit_code once finished). Clients that do not ask are not polled for it.
A controller that recovers from a crash by re-running what it has no result for can make that safe: create_instance, delegate and exec with background: true take an optional idempotency_key (1-64 characters of A-Za-z0-9._:-). A call whose key already made something returns that instance or task, with replayed: true, instead of making a second one -- across sessions too: an orphan made under the key is adopted. The key lives exactly as long as what it made. A repeat must ask for the same thing (template or source, instance, kind of task): one that asks for something else is refused, never redirected, so a controller that picks the instance at run time records that choice with its intent. One outcome needs looking up rather than retrying: a key whose instance was kept with keep_instance is refused.
instance_activity answers from the host proxy's view, without touching the instance: a call in flight means the agent is working, a growing idle_seconds that it is stuck or finished without reporting. Its token counts (input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, as the API reports them) run from counting_since, when the proxy began counting that instance (absent while the proxy does not know the instance yet). For what a task spent, read before and after it and subtract -- only when both reads carry the same counting_since; a different or missing one means the counts started afresh in between (the proxy restarted) and the difference is not the task's. Its traffic_* fields count every request the instance made through the proxy, model calls included (package downloads, git, WebSockets, which are in flight while open), from the same counting_since: an agent with no model call in flight but a build pulling dependencies is still working. isx proxy activity shows the same counts for every instance on the host.
exec, task_result and get_diff take an optional ask: instead of the text, a one-shot Claude Code on summary-model reads it inside the instance and answers the question ("which tests fail?"), so a long log or patch never fills the host agent's context. The answer is a model's reading -- untrusted and lossy -- so anything that gates a merge stays deterministic: get_diff(stat), CI, a reviewer.
What an agent gets is deliberately narrow:
- Only approved templates. Never a project-local (
.incus-spawn/) definition or an image built from one, and never an unbuilt one -- it is told to ask you to runisx build. - Template defaults, no choices. Network mode, credential accounts, resources: exactly what
isx branch --from <template>gives. Credentials stay in the host proxy as always; which GitHub account a delegate can push with is the one you pinned on the template. - Your instances, one session at a time. Every instance is stamped with your host user, an optional purpose (
#870 implement), and the session holding it; every tool checks that stamp. Instances outlive their session, because a coordinating agent restarts while its workers wait: when a session ends (or dies), its instances become orphans that any later session of yours can take back withadopt_instance, tasks included. An orphan nobody adopts is destroyed by the nextisx mcpto start onceorphan-grace-hourshave passed -- never while someone is in it withisx shellor an agent it delegated to is still working, and never one handed to you withkeep_instance. Instances are namedmcp-<template>-<hint>-<suffix>, where the hint is thename_hintan agent gavecreate_instanceordelegate(870-impl;taskfor a delegate without one); don't give your own instancesmcp-*names.isx listadds anMCPcolumn when there are any, showing each instance's state and purpose (held: #870 implement,orphaned,kept: spike). The TUI shows the same in an instance's details (F3), with who holds it, and has no column for it. - Taking over is safe. You can join a delegate's conversation yourself:
isx shellinto the instance, thenclaude --resume <session>orcodex resume <session>(the ID is intask_status.session_id). While you are in it,task_statussaysattachedandsend_messageis refused, so the host agent never races you on one conversation. - No host access. Nothing runs on the host and no host files are read or written: results come back as text, and
get_diffreturns a patch rather than touching your checkout. - An audit trail. Every call is logged, with credentials scrubbed, to
~/.local/state/incus-spawn/mcp.log.
isx mcp runs as you, so these limits hold for the MCP tools, not for an agent that can also run isx through its shell. Allow the MCP tools and deny the rest in Claude Code's settings:
{
"permissions": {
"allow": ["mcp__isx__*"],
"deny": ["Bash(isx:*)", "Edit(~/.config/incus-spawn/**)", "Write(~/.config/incus-spawn/**)"]
}
}Delegated agents use the template's selected claude or openai account. Text coming back from an instance -- command output, a delegate's report, a diff -- is data produced inside the sandbox, and the tool descriptions tell the host agent to treat it that way.
The agent that coordinates the others can itself run in an isx instance, so that it acts with the instance's credentials -- a bot's GitHub login, say -- instead of yours, and reaches your machine only through the MCP tools. Branch it with --mcp-client:
isx branch coord --from tpl-dev --proxy-only --mcp-clientInside it, isx mcp is served at https://mcp.isx.internal/mcp (MCP's Streamable HTTP transport) by the host proxy, which runs one host isx mcp for the instance. Each request must come from the instance's own address and carry the secret isx gives it at every start, in X-Isx-Instance-Secret. If the template has the claude tool, there is nothing to set up: every isx start registers the server in the instance's Claude Code (claude mcp list shows it as isx), with a headersHelper that reads the current secret from /run/isx/instance-secret, so interactive sessions, claude --continue and claude -p under a systemd unit all have the tools. Claude Code runs a headersHelper only in a trusted workspace; the claude tool already trusts the home directory and every repository the template clones, so a coordinator started from anywhere else meets Claude Code's ordinary trust prompt once. A copy of the coordinator (isx branch --from coord) loses the registration on its first start, along with the grant. The server also offers the coordination workflow as a prompt, which Claude Code lists as /mcp__isx__coordinate. isx branch --mcp-client asks the server from inside the new instance once it has started, and says whether it was reached (with the server's version and tool count) or why not, and whether its Claude Code has the server.
It gets exactly the tools, approved templates and limits a host agent gets, and the same mcp: configuration applies. What differs:
- The session is the instance. What it creates stays held by it across restarts of its Claude Code and of the instance itself, and is picked up again on the next connection: nothing to adopt. Its instances become orphans only once it is destroyed, or no longer allowed to call; a new coordinator under the same name is another session and gets none of them. One whose template you no longer approve is released when the coordinator next connects, as a host session's would be when it ends.
- One connection at a time. A new connection from the instance ends its previous one.
- No coordinator makes another. Only
isx branch --mcp-clientgrants it, never to a copy: not toisx branch --from coord, and not to anything created through MCP. - Only the host proxy is reached.
mcp.isx.internalresolves to the bridge gateway, so this works with--proxy-only; an--airgapinstance cannot use it.
A program of your own on the host, such as a coordinator that agents report to, can be reached from instances by a name you choose, through the proxy and in --proxy-only mode too. List it in ~/.config/incus-spawn/config.yaml:
host-routes:
work.isx.internal:
target: 127.0.0.1:7070 # on this host's loopback: localhost, 127.0.0.1 or [::1]
templates: [tpl-isx-agent] # required: only instances of these templates may call itAn instance then calls https://work.isx.internal/..., sending the secret isx gave it at its last start as X-Isx-Instance-Secret (it is in the file $ISX_INSTANCE_SECRET_FILE names). The proxy checks the secret and the instance's address together, and the instance's template against templates:, and forwards the request over plain HTTP to the target with X-Isx-Instance and X-Isx-Template set to who called. Any X-Isx-* header the instance sent is removed first, so the controller never sees the instance's secret. Anything else gets an error and never reaches the controller: a wrong or missing secret, a template the route does not list, an instance built from a project-local definition, a WebSocket. Names must end in .internal, templates: must list at least one template, and an entry that is wrong in any way is skipped with a warning in the proxy's log.
The controller must check the route secret before it trusts those headers. The proxy also sends X-Isx-Route-Secret, a secret of this route alone that only this host holds: it is in ~/.config/incus-spawn/host-routes/<name>.secret (here work.isx.internal.secret), which the proxy creates readable by you only the first time it serves the route. No instance ever has it, and the proxy removes it from the controller's response headers. Compare it in constant time, and refuse the request when it does not match, for example in Python:
expected = open(os.path.expanduser("~/.config/incus-spawn/host-routes/work.isx.internal.secret")).read().strip()
if not hmac.compare_digest(request.headers.get("X-Isx-Route-Secret", ""), expected):
return 403Anything that reaches the controller without it did not come through the proxy: another program on this host, say. Keep the controller listening on loopback only (127.0.0.1, never 0.0.0.0, which instances that are not --proxy-only reach at the bridge gateway), and never echo the secret back, in a response body least of all: the proxy removes it from response headers, but relays the body as it is. To rotate the secret, delete the file; the proxy makes a new one at once. If the controller was down while something else held its port, whatever held it received the secret, so rotate it then too. The proxy refuses (503) every request to a route whose secret file, its directory or ~/.config/incus-spawn is not yours, whose secret file anyone but you can read, or whose directories others can write to (the config directory may be writable by your own private group, as a umask of 002 makes it; never by a group you share). The proxy does not see POSIX ACLs (setfacl) and does not check the directories above ~/.config/incus-spawn: keep both free of write access for anyone else. Once you fix one with chmod or chown, the route is served again at once on Linux; on macOS, run isx proxy restart, since its file watcher does not see permission changes (a rewritten or deleted secret it does see).
An isx instance is a Fedora system with a few deliberate deviations. This is where they are listed; a change to what a box does that a Fedora user would not expect belongs here.
Trust. Traffic to intercepted domains passes through the host proxy, which signs with the isx CA. The CA is installed in the system anchors (update-ca-trust), so everything that reads the system store trusts it. A runtime that ships its own root bundle does not, so isx binds it to the system store:
- Environment bindings. Every login shell (
/etc/profile.d/isx-env.sh) and systemd user session (/etc/environment.d/00-isx-trust.conf) getsSSL_CERT_FILE,SSL_CERT_DIR,REQUESTS_CA_BUNDLE,CURL_CA_BUNDLE,PIP_CERT,NODE_EXTRA_CA_CERTS,CARGO_HTTP_CAINFO,AWS_CA_BUNDLE,NIX_SSL_CERT_FILE(the system bundle,/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem;SSL_CERT_DIRits hashed directory beside it),DENO_TLS_CA_STORE=systemandUV_NATIVE_TLS=1. A value already exported, or set by the template or a tool, wins in a login shell. In a systemd user session any otherenvironment.dfile overrides the drop-in, because it is named00-isx-trust.confand sorts first (for example~/.config/environment.d/10-mine.conf). Java is never bound through a variable:JAVA_TOOL_OPTIONSprints a "Picked up" line that breaks tools parsingjava -version. - File bindings. A JDK's
lib/security/cacertsand an installed Python certifi'ssite-packages/certifi/cacert.pemare replaced by a symlink to the system store (/etc/pki/ca-trust/extracted/java/cacerts,/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem). Such a runtime then trusts the Fedora/Mozilla root set plus the isx CA instead of its vendor's bundle. The build binds what the image already carries; after that, two systemd path units watch where runtimes land and bind each new one within seconds, with no resident process:isx-trust-system.path(as root) watches/usr/lib/jvm,/optand/usr/local, andisx-trust-home.path(asagentuser) watches~/opt,~/.jdks,~/.sdkman/candidates/java,~/.gradle/jdks,~/.jbang/cache/jdks,~/.local/share/mise/installs/javaand~/.asdf/installs/java. A watch is not recursive, so a runtime is caught when it lands directly in one of those directories, and each costs one inotify instance. A bareisx-trustbinds all of those directories. For a runtime anywhere else (a JDK unpacked into~/tools, a project's.venv), runisx-trust <dir>; the symptoms arecertificate_unknown,PKIX path building failedorCERTIFICATE_VERIFY_FAILED, and the agent context pairs them with that command.
No verification is turned off anywhere: every runtime still verifies chains and hostnames, against the system store.
Host CAs (opt-in). With
host-ca:
propagate: truein ~/.config/incus-spawn/config.yaml, the certificates in the host's /etc/pki/ca-trust/source/anchors/ (Fedora/RHEL; other hosts have none there to propagate) are installed beside the isx CA in every instance, of every template, as isx-<sha256>.crt. This is for a host behind TLS inspection, or an isx inside another isx: the outer proxy's CA must be trusted for the domains the inner one does not intercept. Only certificates cross, re-encoded from each file; a file holding anything else, such as a private key, is refused with a warning. Builds install the set; a branch, isx shell and the TUI bring each instance up to date on use (an --airgap instance keeps what its template was built with), which also removes a CA taken off the host or every host CA once propagation is turned off. In an instance's anchors directory isx owns only incus-spawn-mitm.crt and names of exactly the form isx- + 64 lowercase hex digits + .crt; any other file there, such as an isx-company.crt a template adds, is left alone. It is off by default, and then instances trust only the isx CA beside Fedora's roots.
Application containers, the kind Docker and Podman run by default, are built for shipping applications: a minimal filesystem, one process, fast startup. isx is built for a different job, and the choice that follows from it is what a sandbox is. An isx sandbox is a workstation, a complete machine: a full Incus system container, or a KVM virtual machine (--vm), with its own init system, real networking (ping, strace, nested Podman/Docker), and GUI and audio passthrough (Linux only). Templates pre-install your baseline tools and repos, but what the agent gets is a real Linux system, where it can dnf install, pip install, build from source, or run Docker Compose just like on a workstation.
This matters for agents in particular, because what you hand an agent is an investigation, and nobody knows at the start what tools it will need. An agent on an isx branch installs a tracer, starts a service, or writes a tool of its own as it goes, and if it wrecks the machine, the branch is destroyed and the next one costs a second. It works exactly as it would on a developer workstation, because that is what it has. Compared with other sandboxes shows how this choice plays out against other sandboxes for agents.
For the strongest isolation, KVM virtual machines (--vm) add a separate kernel and hardware-level separation.
Template images are reusable base environments defined in YAML. They can inherit from each other -- building an image automatically builds any missing parents:
# images/java.yaml
name: tpl-java
description: JDK + Maven
parent: tpl-dev
packages:
- java-25-openjdk-devel
- java-25-openjdk-javadoc
- java-25-openjdk-src
tools:
- maven-3Three images are built-in (tpl-minimal, tpl-dev, tpl-java). tpl-dev and tpl-java are deprecated and will be removed from isx: they still build, with a warning. Define your own templates in their place; to start from something, their maintained copies live in incus-spawn-templates under the same names. Add that checkout to searchPaths (or copy its images/dev.yaml and images/java.yaml into ~/.config/incus-spawn/images/) and the copies replace the built-ins; the definitions are the same, so nothing already built goes out of sync. The root image (tpl-minimal) uses a custom Fedora base from Sanne/incus-spawn-images. Use isx update-base to check for new base image releases, pin a specific version, or track the latest:
isx update-base # interactive — shows versions, prompts for action
isx update-base --list # list available versions
isx update-base --latest # track the newest release (remove any pin)
isx update-base fedora-44-v2 # pin to a specific release tagTracking latest is the default and requires no action: when the base image is unpinned, isx build tpl-minimal resolves the newest release at build time and installs it (falling back to the version baked into the binary if it can't reach the release list). Pinning writes a user-level override to ~/.config/incus-spawn/images/minimal.yaml; --latest removes that override to resume tracking. After changing the base image version, rebuild with isx build tpl-minimal.
Add your own templates by placing YAML files in ~/.config/incus-spawn/images/ (user-level) or .incus-spawn/images/ (project-local). You can also point to external directories via searchPaths in config.yaml (see Configuration).
Use isx templates to manage templates from the CLI:
# List all available templates
isx templates list
isx templates list -v # with source path and description
# Create a new template (opens in $EDITOR with a skeleton listing every field, commented out)
isx templates new my-app # creates ~/.config/incus-spawn/images/my-app.yaml
isx templates new my-app --project # creates .incus-spawn/images/my-app.yaml
# Edit an existing template
isx templates edit tpl-java # opens in $EDITOR, validates on saveEditing a built-in template automatically creates a user-level override in ~/.config/incus-spawn/images/. The override takes precedence over the built-in but will not auto-update with isx upgrades. Templates are validated after editing: YAML syntax, required fields, and parent references are checked.
You can also define a custom root image (no parent) by specifying image, image_url, image_tag, and image_sha256 to point at your own pre-baked OS tarball. See the built-in minimal.yaml and the incus-spawn-images repo for the reference example.
Image schema fields (all optional except name; isx templates new writes them all into the new file, commented out):
image-- base OS image, only for root images (default:images:fedora/44)image_url-- download URL for the base image tarball (supports{arch}and{tag}placeholders;file://works for testing a locally built image)image_tag-- release tag identifying the base image versionimage_sha256-- per-architecture SHA256 checksums for integrity verificationtype-- instance type:container(default),vm, orkvm. VMs use a separate kernel for hardware-level isolation. With the built-in base image that is isx's own kernel, which the firmware starts directly (no boot loader, no initrd, no loadable modules). It is pinned: image rebuilds bring Fedora's userspace updates, not kernel updates, which arrive only when isx's kernel pin is bumped. By default dnf in the VM excludes Fedora's kernel packages (/etc/dnf/libdnf5.conf.d/isx-kernel.conf); kernel work belongs in a nested VM inside a--kvmbranch.kvmis a container with the host's/dev/kvmpassed through, so it can run VMs of its own (skip it per branch with--no-kvm). Inherits from parent -- a child withouttypeinherits its parent's typevm_image_url-- download URL for the VM base image (qcow2 tarball). Only used whentypeisvm. Supports{arch}and{tag}placeholdersvm_image_sha256-- per-architecture SHA256 checksums for the VM base imageparent-- parent image name (omit for root images)packages-- dnf packages to installremove_packages-- dnf packages to remove before installingpackage_repos-- additional package repositories to enable (e.g. COPR)tools-- tool names to run (resolved from YAML or Java, see Custom Tools)repos-- git repositories to clone as agentuser (see below)skills-- agent skills to bake into the image (see below); accepts a list shorthand or an object withrepoandlistsub-fieldsagent_note-- always-true fact an agent must know before acting (see Agent Environment Context)host-resources-- host files/directories to share with containers (see below)mask_services-- systemd units to mask in the imageenv-- environment variables written to/etc/profile.d/isx-env.sh(see Environment Variables)gui-- enable GUI support: container branches get GUI passthrough by default when isx runs in a Wayland session, unless the definition is project-local (isx branch --no-guiand the TUI dialog opt out)pinned-- pin the base image toimage_taginstead of tracking the newest releaseworkdir-- default working directory when shelling into a container (see below)shell-command-- command to run instead of the login shell (see below)default-action-- tool action to run when pressing Enter on an instance in the TUI (see below)description-- human-readable description for the TUIaccounts-- credential account per namespace (e.g.claude: work), overriding the namespace'sdefault(see Credential accounts)deprecated--true, or a note saying what to use instead: the template still builds, but every build of it or of a template naming it asparentwarns (templates further down do not), and the TUI shows the warning in the template's details
# Build a specific image (builds missing parents automatically)
isx build tpl-java
# Build as a VM instead of a container (overrides the definition's type)
isx build tpl-java --type vm
# Rebuild a template and all its parents from scratch
isx build tpl-java --with-parents
# Rebuild two templates and all their parents, the parents they share once
isx build tpl-isx tpl-quarkus --with-parents
# Rebuild out-of-sync templates (changed definitions, older isx version, or a parent rebuilt since)
isx build --out-of-sync
# Rebuild all discovered images from scratch
isx build --allThe TUI marks templates with ! when they were built with a different isx version, △ when the image or tool definition has changed since the last build, and ↑ when their parent was rebuilt after them (so they were copied from its earlier build; a VM over a container parent is built from the definitions, never copied, so it is never ↑) — isx build --out-of-sync rebuilds all three, and everything that inherits from what it rebuilds. If a build fails, the container is promoted to an inspectable instance so you can shell in and debug.
Images can declare git repositories to clone into the container. Declaring a git repository rather than using shell commands to fetch it allows for better integration into other tools, such as Claude Code.
name: tpl-quarkus
description: Quarkus development
parent: tpl-java
tools:
- podman
- gradle
repos:
- url: https://github.com/quarkusio/quarkus.git
path: ~/quarkus
prime: mvn -B dependency:go-offlineRepo entry fields:
url(required) -- git clone URL (HTTPS, for proxy compatibility)path(optional) -- target directory (~expands to agentuser's home); defaults to~/<repo name>branch(optional) -- branch or tag to check out; defaults to the repo's default branchdepth(optional) -- a positive number of commits to keep: the clone is shallow and follows only its branch, for repositories that store release binaries in their history. Omit it for development repositories, which get the full history. In the instance,git fetch --unshallow && git remote set-branches origin '*' && git fetchgets all of it.prime(optional) -- shell command to run inside the repo directory after cloning, typically to pre-fetch dependencies (e.g.mvn dependency:go-offline,gradle dependencies)
Declared repos are automatically pre-trusted in .claude.json so Claude Code doesn't prompt for trust on first use.
Templates can configure the default working directory, shell command, and default action when connecting to a container:
name: tpl-quarkus
parent: tpl-java
tools: [claude]
repos:
- url: https://github.com/quarkusio/quarkus.git
path: ~/quarkus
workdir: ~/quarkus
default-action: claudeworkdir-- the directory tocdinto when opening a shell. Inherits from parent templates (the nearest one that sets it wins), even when a child adds repos of its own; when no template in the chain sets it, defaults to the first repo of the nearest template that declares any.shell-command-- a command to run instead of the default login shell (e.g.claudeorpi). Falls back tobash --loginif it fails to start.default-action-- a tool action to run when pressing Enter on an instance in the TUI or when runningisx run <instance>from the CLI. The value is a tool name (e.g.claude) if the tool has a single action, ortool:action-id(e.g.claude:launch) if the tool has multiple actions (see Tool Actions for theidfield). When set, Enter runs the action and F2 opens a shell; when unset, Enter/isx runopens a shell. Inherits from parent templates; a child overrides the parent's default action. No rebuild required when changing this field.
Claude Code is Anthropic's official CLI for Claude. Add it to any template with tools: [claude]:
name: tpl-agent
description: Isolated dev environment with Claude Code
parent: tpl-dev
repos:
- url: https://github.com/myorg/myproject.git
path: ~/myproject
workdir: ~/myproject
tools:
- claude
default-action: claudeThe claude tool downloads the latest Claude Code binary, configures permissions for unattended agent use, and sets up authentication. All three auth modes work transparently — the MITM proxy injects credentials so no real API keys or tokens enter the container.
To preconfigure which model Claude Code uses, pass the model parameter:
tools:
- claude:
model: claude-sonnet-4-6When omitted, Claude Code uses its own default. Model IDs follow the claude-* naming convention (e.g. claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001).
Codex CLI is OpenAI's coding agent. Add it to any template with tools: [codex]:
name: tpl-codex-dev
description: Isolated dev environment with Codex CLI
parent: tpl-dev
repos:
- url: https://github.com/myorg/myproject.git
path: ~/myproject
workdir: ~/myproject
tools:
- codex
default-action: codexCodex needs an OpenAI API key (create one here; API usage needs billing credits, even on free accounts). Run isx init to configure it: the key stays on the host and the MITM proxy injects it.
The build configures Codex for unattended use and trusts the template's repos. The default action resumes your most recent session.
The footer shows the model, the current directory and Running unconstrained in isx [version] [container name], the name of a permission profile with full filesystem and network access (and live web search).
The version and container name refresh at login, including after cloning a template.
Use /statusline to customize the footer; the permissions item displays the isx message.
To pin a model or reasoning effort (by default Codex picks both):
tools:
- codex:
model: gpt-5.3-codex
effort: highmodel is any model your OpenAI account can use (model list); effort is minimal, low, medium, high or xhigh.
Pi is a provider-agnostic CLI coding agent that uses the standard Anthropic API. Add it to any template with tools: [pi]:
name: tpl-pi-dev
description: Isolated dev environment with Pi coding agent
parent: tpl-dev
repos:
- url: https://github.com/myorg/myproject.git
path: ~/myproject
workdir: ~/myproject
tools:
- pi
shell-command: piPi works out of the box with all three Anthropic auth modes (API key, Claude Pro/Max OAuth, Vertex AI) — the MITM proxy injects credentials transparently. To use Pi without making it the default shell, omit shell-command and launch it manually after isx shell.
Pi can also run against OpenAI, using the same key as Codex CLI:
tools:
- pi:
provider: openai
model: gpt-5.3provider defaults to anthropic and also accepts vertex/google; model defaults to claude-sonnet-4-6.
Bob Shell is IBM's AI-powered coding assistant. Add it to any template with tools: [bob]:
name: tpl-bob-dev
description: Isolated dev environment with Bob Shell
parent: tpl-dev
repos:
- url: https://github.com/myorg/myproject.git
path: ~/myproject
workdir: ~/myproject
tools:
- bob
default-action: bobBob Shell requires an IBM API key (create one here). Run isx init to configure it — the real key stays on the host and the MITM proxy injects it transparently. Containers only hold a placeholder value. During isx init you'll be asked to accept the IBM license agreement; accepting pre-configures it in all containers so Bob Shell won't prompt again. The default action launches Bob with --auto-approve (auto-approve all tool calls); running bob manually in a shell uses normal approval mode.
Add GitHub Copilot CLI to a template with tools: [copilot]:
name: tpl-copilot-dev
description: Isolated dev environment with GitHub Copilot CLI
parent: tpl-dev
repos:
- url: https://github.com/myorg/myproject.git
path: ~/myproject
workdir: ~/myproject
tools:
- copilot
shell-command: copilotCopilot uses the same GitHub PAT as the gh tool -- run isx init to configure your GitHub token if you haven't already. No separate credential is needed.
Template images can declare agent skills to bake in at build time. Skills are installed once into the template, at ~/.agents/skills, the shared location any agent can read (Claude Code finds them through ~/.claude/skills), and inherited by every instance branched from it.
name: tpl-agent
description: Agent with security skills
parent: tpl-dev
skills:
repo: myorg/claude-skills # default catalog for bare skill names
list:
- security-review # short name → myorg/claude-skills@security-review
- code-review # short name → myorg/claude-skills@code-review
- xixu-me/skills@xget # explicit owner/repo@skill-name
- myorg/catalog # all skills from a repoThere is no implicit default catalog -- repo is only needed to resolve bare skill names (like security-review above). When all entries use the fully qualified owner/repo@skill or owner/repo form, you can omit repo and use the list shorthand:
skills:
- xixu-me/skills@xget
- myorg/catalogSkill source formats:
owner/repo@skill-name-- specific skill from a GitHub repoowner/repo-- all skills from a GitHub repo./local-path-- local directory (relative to whereisx buildis run)skill-name-- bare name, resolved using theskills.repofield (required for bare names)
To find available skills, browse skills.sh.
Every build writes a short primer to /etc/claude-code/CLAUDE.md, so Claude Code starts each session knowing where it is: the box is disposable, sudo needs no password, credentials are handled by the proxy, and outward-facing actions such as opening a PR wait until asked. It also lists the tools and repositories the template provides. Your own ~/.claude/CLAUDE.md and project CLAUDE.md files load as usual.
Templates and tools can add their own lines with agent_note:
name: tpl-openjdk
parent: tpl-dev
agent_note: |
The build needs a boot JDK of 26/27/28. This box ships 26 at $JAVA_HOME and
deliberately omits 25.A tool's note, and any skills it ships, come along to every template that installs the tool. Bare skill names in a tool resolve against the tool's own skills.repo:
name: mvnd
description: Apache Maven Daemon 1.x
agent_note: |
`mvnd` is Maven running as a resident daemon: prefer it over `mvn` for repeated
builds in the same repo. It takes the same goals and flags as `mvn`.
skills:
repo: myorg/catalog
list:
- mvnd-buildsNotes are read in every session, so save them for things an agent would otherwise get wrong: a required flag, a version to avoid, an "obvious" fix that isn't. Be specific:
agent_note: |
Run configure with CC=/usr/bin/gcc CXX=/usr/bin/g++. Without them it aborts on
/usr/lib64/ccache/gcc being a symlink; dropping --enable-ccache "fixes" it and
silently costs every later rebuild.Some content is better placed elsewhere:
| Instead of | Use |
|---|---|
agent_note: Maven 3.9 build tool |
description, which is what template and tool listings show |
agent_note: maven, podman and jtreg are installed |
Nothing -- the generated file already lists installed tools and cloned repos |
agent_note: Build with 'make images', test with 'make test TEST=tier1' |
A skill, which loads on demand rather than in every session |
agent_note: Be careful when editing the parser |
A specific constraint, or nothing at all |
agent_note: Follow the Quarkus code style |
The repository's own CLAUDE.md, which applies only to work in that repo |
Changing a note marks the templates that use it as out of sync (△ in the TUI); rebuild them with isx build --out-of-sync.
Template images can declare host files and directories to make available inside containers. This is useful for sharing configuration files, pre-populating caches, or providing large datasets without copying them into every template.
name: tpl-my-java
parent: tpl-java
host-resources:
- source: ~/.m2/repository
mode: overlay
- source: ~/.gitconfigThe ~/.m2/repository entry shares your host Maven cache with the container. With mode: overlay, the container sees a normal read-write directory pre-populated with your cached artifacts, but writes go to a container-local layer -- your host cache is never modified. Maven builds that would normally download hundreds of megabytes of dependencies can instead resolve them instantly from the shared cache.
The ~/.gitconfig entry mounts your git configuration read-only (the default mode), so git inside the container picks up your name, email, aliases, and other settings (see Git Configuration).
Three modes are available:
| Mode | Default? | Description |
|---|---|---|
readonly |
Yes | Read-only bind mount. Simple, safe. |
overlay |
No | Read-only lower layer from host + ephemeral writable upper in the container. Tools see a normal read-write directory. Host is fully protected. Linux only — not yet supported on macOS. |
copy |
No | Copied into the container at build time. Becomes part of the template. Also supports URL sources. |
readonly and overlay resources can't be mounted into system directories such as /, /etc, /usr, /var or /tmp. Use /home/agentuser, /opt, /srv or /mnt instead, or mode: copy.
If path is omitted, it defaults to the same relative path under /home/agentuser/. Missing host paths are skipped with a warning, so templates remain portable. Host resources compose across the parent chain, with child entries overriding parent entries matched by container path.
Project-local templates (.incus-spawn/images/) come from whatever repository you cloned, so they can't reach outside it: host resources and file:// images must point inside the project directory, and their repos are always cloned from the network, never from your host checkouts. To give a template you trust wider access, move it to ~/.config/incus-spawn/images/.
VMs mount host resources via virtiofs. Single-file resources fall back to copy mode, since VMs only mount directories.
These tools ship with incus-spawn and can be referenced by name in a template's tools: list:
| Tool | Description |
|---|---|
claude |
Claude Code: AI coding assistant |
gh |
GitHub: PAT for git operations |
pi |
Pi: AI coding assistant |
bob |
Bob Shell: IBM AI coding assistant |
codex |
Codex CLI: OpenAI coding assistant |
copilot |
GitHub Copilot CLI: AI coding assistant (shares gh token) |
maven-3 |
Apache Maven |
mvnd |
Apache Maven Daemon |
podman |
Podman container runtime configured for Testcontainers |
sshd |
OpenSSH server for remote access |
idea-backend |
JetBrains IntelliJ IDEA Remote Development backend |
vscode-remote |
VS Code Remote Development via SSH |
starship |
Starship cross-shell prompt with incus-spawn indicator |
tmux |
Terminal multiplexer with incus-spawn session integration |
zmx |
zmx: session attach/detach for the terminal |
headroom |
Headroom context optimization for Claude Code |
Run isx tools list -v to see all available tools including user-defined and project-local definitions. Use isx tools show <name> to inspect a tool's dependencies, packages, configurable parameters, downloads, TUI actions, and proxy domains.
Template inheritance forms a single chain -- a template has exactly one parent. Tools provide composition: reusable capabilities that any template can mix in independently. A gradle tool can be added to a Java template, a Kotlin template, or a project-local template without duplicating definitions or creating diamond inheritance.
Tools are defined as YAML files and referenced from image definitions via tools::
# .incus-spawn/tools/gradle.yaml
name: gradle
description: Gradle 9.4.1
downloads:
- url: https://services.gradle.org/distributions/gradle-9.4.1-bin.zip
sha256: 2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
extract: /opt
links:
/opt/gradle-9.4.1/bin/gradle: /usr/local/bin/gradle
verify: gradle --version
Downloads declared this way are cached on the host at ~/.cache/incus-spawn/downloads/, so rebuilding images doesn't re-download unchanged artifacts.
Extraction happens on the host -- the container doesn't need tar, unzip, or curl.
Tool schema fields (all optional except name):
packages-- dnf packages to installdownloads-- artifacts to download, cache on the host, and copy and/or extract into the containerrequires-- list of other tool names that must be installed first (resolved transitively; circular dependencies are detected and rejected)run-- shell commands as rootrun_as_user-- shell commands as agentuserfiles-- files to write (with optionalowner)env-- environment variables written to/etc/profile.d/isx-env.sh(supports structured entries with merge strategies; see below)verify-- verification command (logged, non-fatal); runs after every tool is installed, asagentuserin a login shell with the image environment (/etc/profile.d/isx-env.sh) loaded, so it may rely on another tool's variables such asJAVA_HOMEverify_as_root-- set totruewhen the verify needs root (for examplesshd -t, which reads the host keys); it then runs as root in root's own environment (HOME=/root, the defaultPATH), without the environment file, so it cannot rely on another tool's variablesactions-- runtime actions available from the TUI when the tool is installed (see Tool Actions)agent_note-- always-true fact about this tool that an agent must know before acting (see Agent Environment Context)skills-- skills to install alongside the tool, teaching an agent how to drive it; same forms as the image-level field, but bare names resolve against this tool's ownskills.repoproxy-- credential injection rules for the MITM proxy (see Proxy Credentials)
Download entry fields:
url(required) --http://orhttps://download URL (local addresses such aslocalhostare refused)sha256(recommended) -- SHA-256 checksum; enables cache reuse and verifies integrityextract(optional) -- directory in the container to extract intodestination_file(optional) -- exact path at which to expose the downloaded file in the container;~/resolves to/home/agentuser/extract_in_container(optional) -- extract inside the container instead of on the hostlinks(optional) -- map ofsource_path: symlink_pathto create after extraction
Each download must set extract, destination_file, or both. Setting both preserves the downloaded archive at destination_file and also exposes its extracted contents. Supported archive formats for extract are .tar.gz/.tgz, .tar.bz2, .tar.xz, .zip.
Execution order during install(): packages → downloads → run → run_as_user → files. Environment variables are collected from all tools and the template chain after install, then written centrally; each tool's verify runs after that. Resolution follows the same order as templates (see Configuration).
Environment variables support four merge strategies:
env:
# Simple set (default strategy):
- name: MAVEN_HOME
value: /opt/apache-maven-3.9.16
# Set only if not already defined:
- name: DOCKER_HOST
value: "unix:///var/run/docker.sock"
strategy: set-if-unset
# Prepend to existing value (with separator):
- name: MAVEN_OPTS
value: "-Xmx2g"
strategy: prepend
separator: " "
# Append to existing value:
- name: PATH
value: /opt/bin
strategy: append
separator: ":"All env entries from the template chain and installed tools are collected by the build system and written to /etc/profile.d/isx-env.sh. Conflicting definitions are caught at build time: if two tools both set the same variable to different values, the build fails with a descriptive error naming both sources. Every entry must use the structured name/value form; a shell string such as - export FOO=bar is rejected when the definition is loaded.
Templates can also declare environment variables directly:
name: tpl-my-project
parent: tpl-dev
env:
- name: MY_DEBUG
value: "true"
strategy: set-if-unsetTools can register domains with the MITM proxy for transparent credential injection. This lets you add authentication to any HTTPS API without exposing secrets inside containers. The built-in claude, gh, bob, codex, and copilot tools use this mechanism -- but you can declare the same for your own tools.
A proxy: block has three parts: a config-namespace that scopes config paths (letters, digits, ., - and _, starting with a letter or digit), a configuration map that declares what credentials are needed, and auth entries that map domains to authentication rules.
# ~/.config/incus-spawn/tools/artifactory.yaml
name: artifactory
description: JFrog Artifactory
proxy:
config-namespace: artifactory
configuration:
token:
config-path: "token"
description: "Artifactory API token"
secret: true
auth:
- domains:
- artifactory.internal.example.com
type: bearer
token: "${token}"After placing this file, isx init will prompt for the Artifactory token alongside other credentials. The token is stored in ~/.config/incus-spawn/config.yaml under artifactory.token (the namespace prefixes the config path). Inside containers, HTTPS requests to artifactory.internal.example.com get a Authorization: Bearer <token> header injected automatically -- no configuration inside the container needed.
Three auth types are supported:
| Type | Injected header | Required fields |
|---|---|---|
bearer |
Authorization: Bearer <token> |
token |
basic |
Authorization: Basic <base64(username:password)> |
username, password |
header |
Custom header | name, value |
Auth fields can be literal values (e.g. username: "deploy-bot") or ${configKey} references resolved against the configuration map. A tool with all three types:
proxy:
config-namespace: myService
configuration:
api-key:
config-path: "apiKey"
description: "API key for myservice.com"
secret: true
password:
config-path: "password"
description: "Registry password"
secret: true
auth:
- domains:
- api.myservice.com
type: bearer
token: "${api-key}"
- domains:
- registry.myservice.com
type: basic
username: "deploy-bot"
password: "${password}"
- domains:
- "*.internal.myservice.com"
type: header
name: X-API-Key
value: "${api-key}"Wildcard domains (*.internal.myservice.com) match any subdomain. The most specific wildcard wins when multiple tools register overlapping suffixes.
Configuration entries can also use value for hardcoded literals (no prompt during isx init) and type: confirm for yes/no prompts like license acceptance.
If the tool insists on having a token locally before it will send a request, give it a placeholder through env: and let it read that variable whenever it runs. Never copy the value into a file with files: or a build step: the token an instance presents is tied to the instance and changes on every start, so a copy made at build time goes stale, and isx warns when it finds one.
Tool YAML files with proxy: blocks must be placed in ~/.config/incus-spawn/tools/ or a configured search path -- project-local tools (.incus-spawn/tools/) cannot declare proxy rules because the proxy daemon runs independently of any project directory.
Both VS Code and JetBrains IntelliJ can connect to containers with their UI running natively on the host and all backend processing (indexing, builds, terminals, extensions) running inside the container. SSH keys are managed automatically: isx init generates a dedicated passphraseless key pair at ~/.config/incus-spawn/ssh/, and each branch injects it into the container along with your personal ~/.ssh key. Container host keys are pre-validated so ssh <instance-name> just works — no passphrase prompt, no host key warning. Entries are cleaned up when instances are destroyed.
Both tools declare TUI actions — press F9 on a running instance to open a repo directly in your IDE.
The built-in vscode-remote tool provides one-click "Open in VS Code" actions. It declares requires: [sshd], so the SSH server is installed automatically. No backend is pre-installed inside the container — VS Code downloads its own server component on first connect.
Host prerequisite: install the Remote - SSH extension in VS Code.
name: tpl-java-vscode
parent: tpl-java
tools:
- vscode-remote # auto-installs sshd via requiresThe built-in idea-backend tool installs the JetBrains IntelliJ IDEA remote development backend inside the container. It declares requires: [sshd], so the SSH server is installed automatically.
Host prerequisite: install JetBrains Gateway.
name: tpl-java-ide
parent: tpl-java
tools:
- idea-backend # auto-installs sshd via requiresThe idea-backend tool accepts a memory parameter to control the JVM heap size (default 2g). Use the map form to customize it:
tools:
- idea-backend:
memory: "8g"zmx provides session attach/detach for the terminal — persistent shell sessions that survive disconnections, with native scrollback and multi-client support. Unlike tmux, it delegates window management to your OS rather than reimplementing it.
name: tpl-agent
parent: tpl-dev
tools:
- zmxBy default, auto_attach is enabled: shelling into the container automatically attaches to a persistent zmx session named isx. To install zmx without auto-attaching:
tools:
- zmx:
auto_attach: "false"On Linux, the container's zmx socket directory is shared with the host via a disk device. Container sessions appear in your native zmx socket directory with an isx- prefix — no configuration needed:
zmx list # shows isx-my-branch alongside local sessions
zmx attach isx-my-branch # attach to the container's session from the host
zmx history isx-my-branch # view scrollback from the container
zmx run isx-my-branch git status # run a command in the container's sessionAll zmx commands work natively, including interactive zmx attach. On macOS, where Incus runs inside a VM, host-side socket sharing is not available. zmx sessions still work inside containers — isx shell (or Enter in the TUI) auto-attaches, and sessions persist across disconnections.
The built-in headroom tool installs Headroom, a context optimization proxy for Claude Code. It runs as a local proxy on port 8787 that compresses conversation context, reducing token usage and improving response quality on long sessions. An MCP server is also registered with Claude Code for direct integration.
name: tpl-agent
parent: tpl-java
tools:
- headroom # auto-installs claude via requiresThe tool sets ANTHROPIC_BASE_URL=http://localhost:8787 so Claude Code routes through the optimization proxy. The proxy runs as a systemd user service and starts automatically on container boot.
Tools can define parameters for build-time configuration. Parameter types: string (with optional pattern), integer (with min/max), boolean, and enum (with options). Use ${param_name} to reference values in scripts, env, and file content:
# tools/my-server.yaml
name: my-server
parameters:
memory:
type: string
default: "2g"
pattern: "^[0-9]+[gGmM]$"
env:
- name: SERVER_MEMORY
value: ${param_memory}Pass parameter values using the map form in image definitions (the idea-backend memory example above shows this pattern).
Tools can declare runtime actions that appear in the TUI (press F9 on a running instance, or Enter to run the template's default action) and can be invoked from the CLI via isx run <instance> --action <tool:action-id>. Running isx run <instance> with no --action flag executes the template's default action. Actions can be declared in YAML tool definitions or programmatically by Java/CDI tools. The built-in claude and pi tools automatically contribute shell actions ("Claude Code" and "Pi Coding Agent") when included in a template's tools list.
Action entry fields:
label(required) -- display text shown in the F9 menu; supports template variablestype(required) -- one of:url,command,shell,copy-to-clipboardid-- stable identifier for referencing fromdefault-action: tool:action-idrequires_running-- whether the instance must be running (default:true)expand-- set toreposto generate one action per declared repositoryauto_return-- return to TUI automatically after the action completes (default:false; only meaningful forcommandandshell)
Type-specific fields:
url:url-- URL to open in the host browsercommand:command-- shell command to run on the hostshell:command-- command to run inside the container as an interactive terminal sessioncopy-to-clipboard:text-- text to copy to the host clipboard
Action visibility fields:
shell_menu-- whentrue, the action also appears in the F12 shell status bar menu duringisx shellsessions (onlyurlactions, which open on the host without taking over the terminal)shortcut-- the single key that runs the action from that menu: one printable ASCII character, not shared with another menu action (case-insensitively); an action without a usable one is left out of the menu with a warning
Template variables available in label, url, command, and text: ${ip}, ${name}, ${parent}. When expand: repos is set, repo-specific variables are also available: ${repo_name}, ${repo_path}, ${repo_url}.
actions:
- label: "Open repo '${repo_name}' in Gateway"
type: url
expand: repos
url: "jetbrains-gateway://connect#host=${ip}&projectPath=${repo_path}"
- label: "Launch agent"
id: launch
type: shell
command: "my-agent --continue"
auto_return: trueThe proxy and build system cache downloads on the host, shared by all templates and branches. Only content that can be verified is cached; everything is checked against its digest or upstream checksum before it's stored.
Proxy caches (from container traffic):
- Container image layers — OCI blobs from Docker Hub, GHCR, and Quay, keyed by SHA256 content digest
- Maven and Gradle artifacts — release JARs, POMs, and plugins from Maven Central and the Gradle plugin portal, verified against the upstream checksum and re-confirmed with upstream (a
HEADon Maven Central, the checksum file elsewhere) as it ages; see below. When the repository is unreachable, cached artifacts are served unconfirmed so builds keep working offline - Gradle distributions — verified against the upstream
.sha256sidecar, and re-confirmed against it the same way
How recently a cached Maven or Gradle artifact must have been confirmed with upstream is configurable. Within fresh of its last confirmation it is served straight from the cache. Within max-stale it is served at once and confirmed again in the background, and a copy upstream has changed or withdrawn is evicted for the next request. Older than that, it is confirmed before it is served. Gradle Plugin Portal artifacts are always confirmed first, since the portal can delete a published version. The defaults:
artifact-cache:
fresh: 2h # durations: 30m, 2h, 7d ...
max-stale: 7dSetting both to 0 confirms every hit before serving it. This is not recommended: Maven Central does not allow published artifacts to change, so it only adds latency.
Build-time caches:
- DNF packages, shared by every build, so builds reuse each other's downloads
- Tool downloads, reused by rebuilds
All caches live under ~/.cache/incus-spawn/. Nothing expires by age or size; an artifact is dropped only when upstream changes or withdraws it.
isx is evolving from a container manager into mission control for parallel coding agents. Per-instance identities have begun with credential accounts — each instance can use its own Claude and GitHub accounts; next come audited commit signing without exposing private keys (#271), proxy-derived monitoring of agent status and spend, task dispatch, and an in-TUI review lane (#322). Dispatch is also coming to agents themselves: an experimental isx mcp server lets an agent on your host create instances from templates you approved, run builds and tests in them, and delegate whole tasks to the agents inside (#859, #898). Local-first stays the core conviction — your hardware, your network, your repos. See docs/VISION.md for the full direction.
brew install Sanne/tap/incus-spawnUpdates with brew upgrade incus-spawn. See docs/HOMEBREW.md for details.
sudo dnf copr enable sanne/incus-spawn
sudo rpm --import https://download.copr.fedorainfracloud.org/results/sanne/incus-spawn/pubkey.gpg
sudo dnf install incus-spawnAvailable for Fedora 43+, RHEL 9–10, CentOS Stream 9, AlmaLinux 9, and Rocky Linux 9 (via EPEL). Updates automatically with sudo dnf upgrade.
curl -fsSL https://sanne.github.io/isx-apt-releases/public.gpg | sudo gpg --yes --dearmor -o /usr/share/keyrings/incus-spawn.gpg
echo "deb [signed-by=/usr/share/keyrings/incus-spawn.gpg] https://sanne.github.io/isx-apt-releases stable main" | sudo tee /etc/apt/sources.list.d/incus-spawn.list
sudo apt update && sudo apt install incus-spawnUpdates automatically with sudo apt upgrade. See docs/APT.md for details.
curl -fsSL https://isx.run | shInstalls a self-contained native binary to ~/.local/bin/isx. No JVM required. Set INSTALL_DIR to change the install location. To update, re-run the same command. To uninstall, run uninstall.sh (caches at ~/.cache/incus-spawn/ are preserved unless you pass --purge; on macOS the VM's data disk, with every instance and template, is kept unless you pass --delete-instances). To switch install channel, e.g. from a source build to Homebrew, uninstall.sh --binaries-only removes only the binaries and keeps everything else.
jbang app install isx@Sanne/incus-spawn
jbang app install isx-proxy@Sanne/incus-spawnBoth are needed, and jbang must stay on your PATH. To update, re-run both commands.
~/.config/incus-spawn/config.yaml-- auth credentials and global settings (including themcp:section, see Delegating from an agent)~/.config/incus-spawn/ssh/-- managed SSH key pair, per-instance config, and known_hosts~/.config/incus-spawn/images/*.yaml-- user-level template definitions~/.config/incus-spawn/tools/*.yaml-- user-level tool definitions.incus-spawn/images/*.yaml-- project-local template definitions.incus-spawn/tools/*.yaml-- project-local tool definitions
You can configure a credential more than once under different names, for example a personal and a client Claude subscription, or two GitHub identities, and choose which one each instance uses. You can even switch a running instance to another account, with no restart.
claude:
accounts:
personal:
type: oauth
oauthToken: "sk-ant-oat01-..."
acme:
type: vertex
cloudMlRegion: europe-west1
vertexProjectId: acme-prod
default: personal
github:
accounts:
personal:
token: "ghp_..."
acme-bot:
token: "ghp_..."
email: "bot@acme.example"
default: personalYou don't need to write this by hand: isx init adds, renames and removes accounts and sets the default. Re-run it any time to change them.
An instance uses, most specific first:
- its own choice:
isx branch --account,isx account set, or a in the TUI; - its template's
accounts:; - the credential's
default.
name: tpl-acme
parent: tpl-java
accounts:
claude: acme
github: acme-botWhen you branch from a template, the new instance takes the template's account choices and keeps them. Editing accounts: later only affects instances branched after that.
isx build tpl-acme
isx branch acme-42 --from tpl-acme
isx account show acme-42acme-42 (branched from tpl-acme):
claude acme -- Google Cloud Vertex AI (region: europe-west1, project: acme-prod)
Pinned by template tpl-acme's accounts: setting, copied onto this instance when it was branched.
github acme-bot -- commits as bot@acme.example
Pinned by template tpl-acme's accounts: setting, copied onto this instance when it was branched.
Everything in acme-42 now runs as Acme's accounts. For a one-off, choose per branch: isx branch review-1 --account github=acme-bot.
isx account list # accounts, and which instances use each
isx account show review-1 # what review-1 uses, and why
isx account set review-1 github=acme-bot
isx account unset review-1 github # follow the default againIn the TUI, F3 on an instance shows its accounts and a changes them. The branch dialog (F4) lets you pick accounts too.
Changes take effect on the instance's next request, with no restart, and switching GitHub accounts also updates its git identity. The exception is switching between Claude auth modes (Pro/Max, API key, Vertex), which needs a template built for that mode (#866).
Changing a default moves every instance that follows it; isx init lists them first and lets you keep them where they are. Renaming an account in isx init updates the instances using it, and removing one tells you what still uses it. An instance left pointing at a missing account stops working rather than using another one; isx doctor and isx account show point these out.
An isx running inside an isx instance serves its own instances from its own config.yaml. To let them use the outer instance's account for one credential instead, choose the reserved account inherit for it, like any other account: isx branch inner-1 --account github=inherit, isx account set inner-1 github=inherit, a template's accounts:, or default: inherit under github: in the inner config.yaml. The inner proxy then presents the outer instance's proof token in place of the inner instance's own, so the outer proxy serves the outer instance's account for that credential only. Outside an isx instance, inherit is refused when you branch, and so is any request that relies on it. inherit cannot be used as the name of an account of your own.
What holds today: the inner proxy presents the outer instance's proof for an instance that inherits, and only for it. Until the outer proxy checks proof tokens (#1107), the outer proxy serves its credentials to everything from the outer instance's address anyway, so inherit makes no difference yet, and leaving it out does not keep the outer credentials from the nested instances.
A template whose own accounts: says claude: inherit builds Claude for an API key, the form the outer proxy turns into whichever account it serves. The git identity of an instance that inherits GitHub is the outer account's when it starts inheriting; if the outer instance later switches GitHub accounts, branch again or run isx account set to pick up the new one.
The config.yaml also supports git remote auto-management via host-paths and repo-paths (see Git Remotes), and a searchPaths list for loading templates and tools from external directories. Each directory should contain images/ and/or tools/ subdirectories following the same YAML schema as the built-in definitions. Tilde (~) expansion is supported for all path settings:
searchPaths:
- ~/my-templates
- /absolute/path/to/templatesmy-templates/
images/
quarkus.yaml
tools/
gradle.yaml
Resolution order (later sources override earlier ones with the same name):
- Built-in (bundled with isx)
- User (
~/.config/incus-spawn/) - Search paths (in listed order)
- Project-local (
.incus-spawn/)
The TUI updates live as instances are started, stopped, created or deleted elsewhere. To turn this off:
tui-live-refresh: falseQuery commands take --format=table|plain|json: isx list, isx templates [list], isx tools list, isx tools show, isx account list, isx account show, isx proxy status, isx proxy activity, isx doctor, isx vm status (macOS), isx update-base --list and isx branch.
tableis for people. It may change in any release, so don't parse it.plainprints one record per line, its fields tab-separated, with-for an empty field. There is no header, padding, colour or glyph, and no results means no output.- Neither format lets a value write a control character to your terminal. A value can hold one: an instance stamp set by hand with
incus config set, say, escape sequence and all.plainkeeps each record on one line and safe to print, so every control character in a value becomes a space: the C0 controls U+0000 to U+001F (tab, line feed, carriage return, backspace, form feed and ESC among them), DEL, the 8-bit controls U+0080 to U+009F, U+2028/U+2029, and the bidirectional embeddings, overrides and isolates U+202A to U+202E and U+2066 to U+2069, which would make your terminal show the rest of the line out of order. That loses them, so readjsonwhen you need a value exactly: it keeps every value as it is, writing each of those characters as a JSON escape that any JSON parser turns back into the original:\t,\n,\r,\band\ffor tab, line feed, carriage return, backspace and form feed, and\uXXXXfor every other one. jsonprints an array of objects, or one object for a command about one thing (tools show,proxy status,vm status,branch). A list value is an array injsonand its elements joined by,inplain. An absent value isnull, and sizes are in bytes. Times are ISO-8601 with their offset:2026-10-05T12:34:56+02:00, or2026-10-05T12:34:56Zon a host whose zone is UTC, so match both forms. Instances stamped by older isx releases recorded only a date, which prints as an ISO-8601 date (2026-09-01). A time isx cannot read isnull(-inplain).- When isx cannot read what Incus answered, the command fails (exit 1, the reason on stderr). It never prints an empty result.
- Both
plainandjsonare stable. Fields may be added at the end, but are never renamed, removed or reordered. - Results go to stdout. Errors and diagnostics go to stderr, so stdout holds only results. Commands that act (
build,branch,destroy, ...) report their steps on stdout and their warnings and notes on stderr. A status command's report is its result in every state:isx proxy statusprints it on stdout whether or not the proxy is healthy, and the exit code says which. - isx writes colour, clickable links and animated progress only when stdout is a terminal. When stdout is redirected or piped, or
NO_COLORis set to a non-empty value, neither stdout nor stderr holds escape sequences. isx cannot tell whether stderr alone is a terminal, soisx build tpl-dev 2>build.logrun from a terminal still colours the warnings inbuild.log. For a clean log, redirect both streams or setNO_COLOR=1.
isx list prints name, status (running, stopped, ...), ipv4, parent, runtime (container or virtual-machine), created, mcp_state and mcp_purpose, in that order. The last two are null (-) unless an isx mcp session made the instance. mcp_state is then held (a session holds it), orphaned (its session ended and nobody has adopted it yet) or kept (an agent handed it to you with keep_instance), and mcp_purpose is what the agent said the instance is for, or null. For names alone, use isx list -q:
for name in $(isx list -q --status=stopped); do isx destroy "$name" --skip-confirmation; done
isx list --format=json | jq -r '.[] | select(.parent == "tpl-java") | .name'The fields of the other commands, in order:
| Command | Fields |
|---|---|
isx templates |
name, parent, source (built-in or the file), description, built, built_at, version_outdated (built by another isx version), definition_changed (its definition or a tool it uses changed since), parent_rebuilt (its parent was built after it, and it was copied from the parent: never for a VM over a container parent, or the reverse, which is built from the definitions). The staleness flags are null for a template that is not built, and everything from built on is null when Incus could not be asked, with the reason on stderr. isx build --out-of-sync builds the templates that are not built, rebuilds those with version_outdated, definition_changed or parent_rebuilt, and rebuilds the descendants of every template it builds |
isx tools list |
name, source, description |
isx tools show <tool> |
name, description, source, feature, requires, packages, parameters, actions (labels), downloads (URLs), proxy_domains |
isx account list |
one record per account: namespace, account, description, default, problem (incomplete, or not-configured for an account only a pin names), pinned_by, following (the instances that follow the default). The last two are null when Incus could not be asked, with the reason on stderr; plain prints that as -, like an empty list, so read json to tell them apart |
isx account show <instance> |
one record per namespace: namespace, account, description, chosen_by (default, template, explicit, copied or unknown), chosen_in (the template or instance it came from), template, template_account, problem, identity_pending (the git identity the instance takes on its next start or shell, from its stamps: one missing from an instance whose template an older isx built is filled in then too, but not listed), template_problem (why the template's own choice cannot be used) |
isx proxy status |
status (running, waiting_for_dns, not_running, stale_dns, stale_gateway, or unknown when the state could not be checked), version, git_sha, runtime, dns_overrides, drift (one string), auth_error, health_endpoint, mitm_port, service_installed, managed_by (systemd, launchd or manual), restart_helps (whether restarting clears the drift; null without drift), check_error (why the state is unknown). Printed in every state; the exit code is the table's, so unknown exits 1 like not_running: read status to tell them apart |
isx proxy activity |
one record per instance the proxy knows, by name, then one whose instance is null: traffic from no known instance (the host, or an address the proxy has not listed). instance, counting_since, then all traffic, model calls included: requests, in_flight (an open WebSocket included), last_activity_at, request_bytes, response_bytes (body bytes, counted as each request ends); then model calls alone: model_requests, model_in_flight, last_model_request_at, last_model_response_at, input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens (null on the unattributed record). Subtract two reads only when both carry the same counting_since. With --by-domain, one record per instance and domain: instance, domain (an intercepted domain, a subdomain counted under it, or (other)), counting_since, requests, in_flight, last_activity_at, request_bytes, response_bytes. Exits 1 when the proxy cannot be read |
isx doctor |
one record per check: level (ok, note, warn, fail), label, detail, remediation. detail and remediation are free text for people: key a script on level and label. Nothing is offered or applied; --bundle cannot be combined with it |
isx vm status |
running, pid, rest_api, log, appliance, appliance_pending (the installed appliance a restart would apply), vsock_connections, incus_reachable, incus_error, vsock_connections_high (the in-VM forwarder may be leaking streams; isx vm restart clears them). Exits 1 when Incus is unreachable |
isx update-base --list |
newest first: tag, date, latest, current, pinned (the current release is pinned, so builds stay on it), listed (false for the current base image when it is not among the releases fetched, e.g. older than all of them, unpublished or deleted: it then gets a record of its own, last, with no date. That alone says nothing about its age) |
isx branch <name> |
name. Progress goes to stderr and no shell is opened, so name=$(isx branch my-box --from tpl-java --format=plain) captures it |
Exit codes:
| Code | Meaning |
|---|---|
| 0 | Success, including a listing with no results |
| 1 | The command failed, with the reason on stderr: a value it rejects (--format=yaml, --status=frozen), Incus unreachable, ... isx doctor also exits 1 when a check fails, isx vm status when Incus is unreachable, and isx proxy status when the proxy is not running (that report is on stdout) |
| 2 | The command line could not be parsed (an unknown option, an option missing its value, a stray argument), with the usage on stderr. For isx proxy status only, 2 also means the proxy is down but its DNS overrides are still active |
| 3 | isx proxy status only: the proxy runs on an old bridge address |
| 4 | isx vm stop --require-clean only: the VM is stopped, but with signals rather than a guest shutdown |
| 78 | isx proxy start cannot run the proxy at all, e.g. isx-proxy is not installed |
The term "sandbox" is a bit generic. There are different kinds of sandboxes, and it is useful to be aware of the differences to pick the right one for your needs. Broadly speaking, there are three categories:
- A sandboxed process. The agent runs as a regular process in your own operating system, as your user, and is confined to a list of allowed files, hosts and operations that you configure.
- A sandboxed workload. The agent runs in an application container with a fixed set of tools, and cannot reshape its environment the way it could on a real system.
- A sandboxed workstation. The agent gets a complete Linux system of its own, to investigate on without constraints: it installs tools, runs services and builds what is missing, as the research requires. Once the work is done the system is considered tainted and discarded.
isx is in the third category. The system is an Incus system container or a VM, and it is a branch of a template, in the way a git worktree is a branch of a repository: a second working copy that shares everything unchanged and costs only what it changes, except that here the working copy is the whole machine, toolchain, dependencies and services included. That is what makes it cheap to create one per task and throw it away afterwards. Day to day a workstation is one command away. A template declares the tools and the repositories, and is built once: the projects are cloned and primed, dependencies fetched and builds warmed, so every branch of it starts ready to work, and every branch is the same. isx branch creates one in about a second, isx shell or the TUI gets you in, your editor attaches over SSH, an isx:// git remote fetches the agent's work, and isx destroy ends it.
Below, the other tools are grouped by category, with a note on when each is the better fit. Why full system containers? explains what isx's own choice gives an agent.
In this category the agent is an ordinary process in your operating system, running as your user, confined to the files, hosts and operations you allow: Landlock or bubblewrap on Linux, Seatbelt on macOS, plus a local proxy for the allowed hosts. There is nothing to install and nothing to boot, and the agent works with the tools and files that are already on your machine. Codex's built-in sandbox works this way too.
nono runs an agent with read and write access to the current directory and no access to anything else on disk or on the network unless its profile allows it. Profiles are published per agent and can be extended. A broker can run the commands the agent calls (git, gh, kubectl) in separate sandboxes with their own credentials, so a token is available to one command and never to the agent process. Rust, Apache-2.0.
sandbox-runtime is the implementation behind Claude Code's /sandbox. Writes are denied except to listed paths, network is denied except to listed domains via a proxy, and reads are allowed everywhere except listed paths. It always denies writes to .git/hooks, .mcp.json and the other files a session could use to run code in a later session. /sandbox applies this to Bash commands only. Running claude itself under the runtime applies it to the file tools, hooks and MCP servers as well.
Where isx differs. A sandboxed process has its world fixed before it starts: the paths and hosts in the profile, the tools already on your machine. Landlock rules can be tightened after launch, never loosened, so an agent that needs one more path, a package or a running service has to be stopped, given a wider profile and started again, and the package still has to be installed on your host. In an isx branch the world is the agent's to change. It installs tools, writes its own, starts services and runs containers as the task turns out to need them, and your host is untouched. isx asks for more setup once, isx init and a template build; after that a branch costs one command and a second, primed with the project and its dependencies, with a shell, an editor and a git remote already attached. The two also answer "what if a tool is compromised" differently. nono's answer is fine-grained: each command runs in its own sandbox and a credential works only for the endpoints its profile lists, which needs the profile to anticipate each command and endpoint. isx's is coarser and simpler: assume the whole instance is compromised, give it only credentials whose misuse you can live with (a read-only GitHub account on a review template), and make the instance disposable. Use a sandboxed process when the agent edits your working tree with the tools you already have. Use isx when the agent has to work out what it needs as it goes.
In this category the agent runs in an application container with a fixed set of tools. It may be allowed to add a package, but it cannot reshape the environment the way it could on a real system: there is no init system, so no services, and privileges are reduced.
OpenShell agrees with isx on credentials: the sandbox never holds a real key, a trusted process outside it terminates TLS, injects the credential on approved requests, and identifies the caller in a way the sandbox cannot forge. The two differ in what is sandboxed.
OpenShell confines a workload: an application container with no network device, one non-root user, no Linux capabilities, and a Landlock filesystem policy fixed at startup that leaves /usr and /etc read-only. Seccomp hands every TCP connect and DNS lookup to a supervisor that checks it against a policy naming hosts, HTTP methods and the calling program. That is a finer-grained egress policy than isx's network modes.
isx hands the agent a workstation, because what we hand an agent is an investigation, and nobody knows at the start what tools it will need. A test that only fails under load: install bpftrace and write a probe. A slow native build: install async-profiler and compare flame graphs. A Testcontainers suite: run podman inside. A flaky remote integration: write a mock server, point /etc/hosts at it, capture the exchange with tcpdump. None of these is possible in OpenShell: there is no root to install anything, bpf and perf_event_open need capabilities the workload does not have, /etc is read-only, nested containers need namespaces and a NIC, and nothing about the sandbox can change once it runs, so each step means a person rebuilds the image and the agent's context is lost. In isx the agent has root on a system with systemd, nested containers, debuggers and a real NIC, and if it wrecks the box, the branch is destroyed and the next one costs a second. Pick OpenShell to run an agent as a governed workload in a fleet; pick isx to let one work the way a good engineer does.
A dev container is a Docker container managed by an editor, with the project directory bind-mounted read-write and the agent installed in the image. Claude Code's reference configuration adds an iptables firewall inside the container that denies egress except to listed domains, so the agent can run without permission prompts. It is a good way to give a team one identical environment. Whether the agent can add packages, or undo that firewall, depends on the image: the common base images give the user full sudo, which also lets it flush the rules, while the reference limits sudo to re-running the firewall script and so allows neither. The filter lives inside the boundary, so the agent's freedom and the firewall's integrity trade off against each other. Either way it is an application container: no init system, so no services; capabilities dropped, so nested containers and tracing need privileged flags; and the workspace the agent writes is your host's directory. The container is also yours to keep, rebuilt from the Dockerfile rather than discarded after the task, and Claude Code's own docs note that the credentials live inside it, in ~/.claude, where an unattended session can read them. Pick a dev container to give a team one environment with the editor attached; pick isx when the agent should reshape its environment freely and its edits should not land on your working tree.
In this category the agent has root on a complete Linux system, with its own init, network stack, and either its own kernel or a kernel namespace. The tools differ in how a system is created, what it costs while it runs, and how cheaply it is discarded and replaced once the work has tainted it.
Docker Sandboxes (sbx) and isx agree on credentials, and on what the agent should get: a full Linux machine with sudo, package managers, nested containers, and state that persists. Both can put that machine in a VM. The design choice that separates them is what a sandbox is. In Docker it is always its own microVM, built from an image; in isx it is a branch of a template, as a system container by default, or a VM (--vm) when you want the strongest isolation.
A Docker sandbox runs on Docker's own VMM with its own kernel, disk image and Docker store, and holds the memory it is given whether or not the agent is using it; a saved template is an image the next sandbox is built from. An isx template is built once on a copy-on-write pool, and a branch of it costs a second and the disk of what it changes, so trying five approaches in five branches and keeping one is the normal way to work, and an agent that wrecks its environment costs a destroy and a new branch rather than a rebuild. A chain of templates (base, tools, project) shares everything below it, and a container branch uses only what its processes use, so a laptop holds many of them at once. Docker's runtime is proprietary and free to use, on macOS, Windows and Linux; isx is open source on Incus, on Linux and macOS.
The workspace is the other divergence. sbx run mounts the current directory read-write by default, so the agent edits the host's files in place, and clone mode (--clone) is the opt-in that keeps work inside the sandbox until fetched. isx is more cautious here: it never mounts a host directory read-write, since files the agent writes can end up executed on the host by a build, a commit or an IDE (the FAQ explains why), and work comes out through isx:// git remotes instead, so clone mode is the only mode isx has. Pick Docker Sandboxes for one governed VM per developer, especially on Windows; pick isx when you want many cheap environments from one template, as containers or VMs as the work demands.
A VM from Lima, Multipass, Tart or Vagrant, or a cloud instance, gives an agent a complete machine with its own kernel. Claude Code's docs recommend one for untrusted code. The VM provides nothing beyond the machine: credentials have to be copied into it and stay there, tools are installed by hand or by a provisioning script, SSH and editor access are yours to set up, and the VM holds its full memory allocation while it runs. isx branch --vm is such a VM with the rest of isx around it: templates that declare the tools, a branch per task, the credential proxy, and the option of a container instead of a VM as the pragmatic choice when a separate kernel is not worth its overhead. Each branch also comes wired into the developer's tools: an isx:// git remote to fetch its work, SSH keys and VS Code Remote or JetBrains Gateway access from the first start, and isx mcp so an agent on the host can delegate to branches of its own.
Use Ctrl+Shift-click to open the URL in your host browser. This worked in our testing: adding Shift lets the host terminal handle the link instead of passing the click to Codex. The exact modifier keys depend on your terminal; macOS users have also reported success with Cmd+Shift-click.
Codex CLI can intercept URL clicks and try to launch a browser inside the isx instance, producing Failed to open browser for ...: No valid browsers detected. This also affects remote SSH sessions and is tracked upstream in openai/codex#52766. The tui.link_activation = "disabled" setting did not resolve it in our testing, and other users report the same limitation in openai/codex#48886.
A project directory is not just data — it is an implicit code execution channel. Build tools, package managers, and IDEs all trust its contents and execute them with your full host privileges. A read-write mount turns the agent's output into unreviewed host-side code execution, which is exactly the threat model isx exists to prevent.
Two attack surfaces make this dangerous even for "just the project directory":
-
Executable project content. Build plugins, Makefiles,
gradlew,.mvn/jvm.config, git hooks, IDE run configurations, and dependency declarations with local path references (<systemPath>,file:deps, Goreplacedirectives) all execute when you run a normal build command. An agent that modifies a Maven build plugin or a git pre-commit hook gets code execution on your host with your credentials and network access — without you ever intentionally "running the agent's code." -
IDE auto-execution. VS Code, IntelliJ, and most editors auto-execute project configuration the moment you open a directory:
.vscode/settings.json(task auto-run),.idea/workspace files, ESLint/TypeScript/Pyright configs that load plugins. An agent writing to the project directory can trigger code execution on your host just by the folder being open — no build command required.
These risks are compounded by a race condition: with a live read-write mount, files can change between review and execution. You inspect a git hook or build script, decide it's safe, and run your build — but the agent modified the file between your review and your command. Unlike git fetch, which gives you a specific immutable commit to review and act on, a live mount means your review is never final.
Beyond security, a shared project directory is also misleading. The agent's code runs against the container's execution context — container-local SNAPSHOT dependencies, container-local node_modules, container-local pip packages. None of that comes through the mount. The source code on the host looks like a complete project, but when you build it locally it may behave differently or break entirely because the dependency state is invisible. The project directory is only a partial view of the agent's environment.
What to use instead:
isx://git remotes (Git Remotes):git fetch <instance>pulls a consistent, atomic snapshot — a specific commit you can review withgit diffbefore merging. Even if the agent pushes new commits between your review and your merge, you act on the exact commit you reviewed. Git's content-addressed model eliminates torn reads by design. This is the intended workflow for getting work out of containers.- VS Code Remote SSH / JetBrains Gateway (Remote IDE Access): the IDE backend runs inside the container while the UI runs on your host. You see live edits, have full debugging, and the security boundary stays intact. Both are built-in tools (
vscode-remote,idea-backend). readonlyandoverlayhost-resources (Host Resources): for sharing files into the container safely.readonlyis a read-only bind mount;overlaygives the container a writable view backed by an ephemeral layer, without modifying the host.
| Command | Description |
|---|---|
isx |
Launch the interactive TUI |
isx init |
One-time host setup |
isx build |
Build or rebuild template images |
isx branch |
Create a CoW clone from a template or instance |
isx shell |
Open a shell in an instance |
isx run |
Run an action on an instance |
isx destroy |
Destroy an instance |
isx list |
List instances, for people or for scripts |
isx update-base |
Check for and install base image updates |
isx update-all |
Update packages and repos in all templates |
isx templates |
Manage template definitions |
isx tools |
List and inspect available tool definitions |
isx account |
Show or change the credential accounts an instance uses |
isx project |
Manage project templates |
isx proxy |
Manage the MITM authentication proxy |
isx doctor |
Diagnose host, proxy, VM, and tunnel health |
isx clean |
Remove cached data, state, or configuration |
isx reset |
Reset to a clean slate |
isx vm |
Manage the VM appliance (macOS only) |
isx ask |
AI-powered help |
isx mcp |
Serve isx to a local agent over MCP |
isx completion |
Print shell completion script |
Use isx <command> --help for detailed options on any command.
Launch the interactive TUI. Falls back to plain-text listing when stdout is not a terminal.
isx
One-time host setup: install Incus, configure credentials and their accounts, test connectivity. Safe to re-run, e.g. to add, rename or remove an account.
isx init
Build or rebuild a template image.
isx build [<template>...] [options]
A template's name may be at most 52 characters, since it is built as <name>-rebuilding first and swapped in once built; a longer one is refused before anything is built, its parents included.
Several templates build in one invocation: plainly, each in turn, a named parent before its child (each with any parent that is missing or out of sync, so a parent they share is built once); with --with-parents or --with-descendants, as one batch with one confirmation, parents before children and every template once. An unknown name anywhere in the list builds nothing. A target that fails does not stop the others: one that inherits from it is skipped, the rest are built, and the build then exits 1 with one Some templates failed to build: line on stderr naming every template it left unbuilt (a parent a target's chain failed on included).
| Option | Description |
|---|---|
--all |
Rebuild all defined templates |
--out-of-sync |
Rebuild templates whose definition or isx version changed, or whose parent was rebuilt after them, with everything inheriting from them |
--with-parents |
Rebuild the templates and all their parents unconditionally |
--with-descendants |
Rebuild the templates and all templates inheriting from them |
--missing |
Build only templates that don't exist yet; like several named templates, a failure skips what inherits from it and the rest are still built, then exits 1 naming what was not |
--type <type> |
Instance type: container, vm, or kvm (overrides image definition) |
--yes |
Skip interactive confirmations |
--skip-git-refresh |
Skip refreshing host-side git repositories before building |
Create a new instance as a copy-on-write clone from a template or existing instance.
isx branch <name> [options]
| Option | Description |
|---|---|
--from <source> |
Source instance to branch from (auto-detected from cwd if omitted) |
--gui |
Enable GUI passthrough (Wayland + GPU + audio); the default for a gui: true container template (not a project-local one) when isx runs in a Wayland session |
--no-gui |
Disable GUI passthrough even if the template has gui: true |
--kvm |
Expose /dev/kvm for nested virtualization |
--no-kvm |
Disable KVM even if the template was built with type: kvm |
--airgap |
Disable all network access |
--proxy-only |
Restrict network to the host proxy only |
--inbox <dir> |
Host directory to mount read-only at ~/inbox inside the instance |
--cpu <N> |
CPU core limit (default: none for containers; for VMs host CPUs minus 2, at most 8, and on a hybrid host at most its top tier's physical cores) |
--memory <size> |
Memory limit, e.g. 8GB (default: adaptive) |
--disk <size> |
Disk size limit (default: adaptive) |
--no-start |
Don't start the instance after creation |
--shell |
Open a plain shell instead of running the default action |
--account <ns>=<account> |
Use this credential account instead of the template's or the default; repeatable |
--mcp-client |
Let the instance drive isx over MCP, at https://mcp.isx.internal/mcp: a coordinator in an instance. Never inherited by its copies |
Open a shell in an existing instance.
isx shell <instance>
When the shell-status-bar feature is enabled, a two-line status bar is pinned at the bottom of the terminal showing the instance name, template, IP address, and network mode. Press F12 to open a quick-action menu with shortcuts from tool actions that set shell_menu: true. Enable with:
# ~/.config/incus-spawn/config.yaml
features:
- shell-status-barRun the default action or a specific action on an instance.
isx run <instance> [options]
| Option | Description |
|---|---|
--action <ref> |
Action to run (tool-name or tool-name:action-id) |
Destroy an instance, or bulk-destroy all templates or instances.
isx destroy <instance>
isx destroy --all-templates # destroy all built templates (derived first)
isx destroy --all-instances # destroy all instances
| Option | Description |
|---|---|
--all-templates |
Destroy all built templates (reverse order, derived first) |
--all-instances |
Destroy all instances |
--skip-confirmation |
Skip the confirmation prompt |
List the instances isx created, without opening the TUI. Templates are not listed: isx templates lists those. On macOS it starts the VM if it is stopped, except with -q, which shell completion runs. See Scripting isx for the plain and json formats.
isx list [options]
| Option | Description |
|---|---|
--format=<format> |
table (the default, for people), plain or json (for scripts) |
--plain |
Same as --format=plain. Before this release it was a no-op that printed the table, so a script that skipped a header with it now gets records from the first line |
-q, --quiet |
Print instance names only, one per line |
--status=<status> |
Only instances that are running or stopped |
isx instances, which printed the names of every Incus instance not named tpl-*, is deprecated and will be removed in a later release; it now runs isx list -q. Shell completion scripts generated by this release no longer call it.
Check for and install base image updates.
isx update-base [<release-tag>] [options]
Pass a release tag (e.g. fedora-44-v2) to pin to that version.
| Option | Description |
|---|---|
--list |
List available versions |
--latest |
Track the latest version (remove any pin) |
Update system packages, npm globals, and git-fetch repos in all templates. Does not re-clone repos or reinstall tools; for that, use isx build.
isx update-all [options]
| Option | Description |
|---|---|
--prime |
Also re-run prime commands (e.g. mvn install -DskipTests) for repos that define one |
Manage template definitions. Running bare isx templates defaults to list.
isx templates <subcommand>
| Subcommand | Description |
|---|---|
list |
List available template names |
new |
Create a new template definition |
edit |
Edit a template definition in $EDITOR |
isx templates list [options]
| Option | Description |
|---|---|
-v, --verbose |
Show source and description |
isx templates new [<name>]
| Option | Description |
|---|---|
--project |
Create in project-local directory (.incus-spawn/images/) |
isx templates edit <name>
List and inspect available tool definitions. Running bare isx tools defaults to list.
isx tools <subcommand>
| Subcommand | Description |
|---|---|
list |
List available tools |
show |
Show details of a tool definition |
isx tools list [options]
| Option | Description |
|---|---|
-v, --verbose |
Show source and description |
isx tools show <name>
Show or change which credential account an instance uses. Accounts themselves are configured with isx init. Running bare isx account defaults to list.
isx account <subcommand>
| Subcommand | Description |
|---|---|
list |
Configured accounts per credential, the default, and the instances pinned to each |
show <instance> |
The account the instance uses for each credential, and where it comes from |
set <instance> <ns>=<account>... |
Pin the instance to these accounts; takes effect on its next request |
unset <instance> <ns>... |
Remove the pins, so the instance follows the default again |
Manage project templates defined by an incus-spawn.yaml file.
isx project <subcommand>
| Subcommand | Description |
|---|---|
create |
Create a project template from a parent base image |
update |
Update an existing project template (packages, repos, deps) |
isx project create <name> [options]
| Option | Description |
|---|---|
--config <path> |
Path to incus-spawn.yaml (default: auto-detect from cwd) |
A project name may be at most 52 characters, since it is built as <name>-rebuilding first. Re-creating an existing template builds the new one alongside it and replaces it only once the build succeeds. If a repository fails to clone or the pre-build fails, the command exits non-zero, deletes the incomplete build and keeps the previous template.
isx project update <name> [options]
| Option | Description |
|---|---|
--config <path> |
Path to incus-spawn.yaml (default: auto-detect from cwd; a detected file must name this template) |
If any step fails (system update, npm update, git fetch, pre-build), the command exits non-zero and the template is kept.
Manage the MITM authentication proxy.
isx proxy <subcommand>
| Subcommand | Description |
|---|---|
start |
Start the proxy |
stop |
Stop the proxy (handles both systemd and manual processes) |
restart |
Restart the proxy service |
status |
Check if the proxy is running |
activity |
What each instance sent through the proxy: requests, in flight, bytes, model calls and tokens (--by-domain for one row per domain, --format=plain|json for scripts) |
install |
Install as a systemd user service (auto-starts on boot) |
uninstall |
Stop and remove the systemd proxy service |
logs |
Follow the proxy log in real time |
dump |
Run a pass-through proxy for host-side traffic capture |
isx proxy start [options]
| Option | Description |
|---|---|
--port <port> |
MITM TLS proxy port (default: 18443) |
--health-port <port> |
Health check HTTP port (default: 18080) |
--gateway-ip <ip> |
Incus bridge gateway IP, for when Incus cannot report it. On Linux it must be the bridge's own address, which is where every isx command looks for the proxy: when the bridge can be read, exactly its gateway; when not, a private (RFC 1918) or loopback address matching the gateway isx init recorded, if any. On macOS, the host's VM-facing bridge address |
--debug |
Log full request/response details |
isx proxy dump [options]
| Option | Description |
|---|---|
--port <port> |
Local HTTP port (default: 19080) |
Diagnose host, proxy, VM, and tunnel health; offers to fix problems found.
isx doctor [options]
| Option | Description |
|---|---|
--deep |
Run per-instance checks (DNS, TLS, resolv.conf) |
--bundle |
Collect findings and logs into a support archive (.tar.gz); implies --deep |
Findings are marked ✓ healthy, ⚠ worth a look, ✗ broken, or · for a note. Notes don't affect the exit code.
--bundle creates an archive to attach to a GitHub issue. Credentials are redacted before anything is written, and REDACTIONS.txt in the archive lists what was removed.
Remove cached data, state, or configuration.
isx clean <subcommand> [options]
| Subcommand | Description |
|---|---|
cache |
Remove cached downloads, registry blobs, and build caches |
state |
Remove VM state, logs, and appliance artifacts; on macOS the VM's data disk (every instance, template and image) is kept unless you pass --delete-instances. Run isx init afterwards: the VM needs the appliance again before it boots on the kept disk |
config |
Remove configuration, SSH keys, and CA certificate |
pool |
Reclaim space from the storage pool (failed builds, unused images, DNF cache); --base-images also removes the downloaded base images, which the next build downloads again |
all |
Remove cache, state, and configuration (does not touch Incus templates or instances unless you pass --delete-instances, which on macOS also deletes the VM's data disk) |
All subcommands accept these options:
| Option | Description |
|---|---|
--dry-run |
Show what would be deleted without deleting |
--skip-confirmation |
Skip the confirmation prompt |
Return isx to a freshly installed state. Removes all instances and templates, cached images and downloads, the proxy service, the VM (macOS) and all configuration, including SSH keys and the CA certificate. It shows what will be removed and asks first. Run isx init afterwards to set up again.
isx reset
| Option | Description |
|---|---|
--skip-confirmation |
Skip the confirmation prompt |
Manage the incus-spawn VM appliance. macOS only.
isx vm <subcommand>
| Subcommand | Description |
|---|---|
start |
Start the VM (creates disk image on first run) |
stop |
Stop the VM (graceful shutdown). It asks the guest to shut down (Incus and its instances stop, the disks are synced) and waits up to 30 s for it. It warns when the guest did not and had to be stopped with signals, which loses writes of the last few minutes; that is every stop of an appliance from before the fix for #881, until isx vm restart applies a newer one; --require-clean also exits 4 then |
restart |
Stop and restart the VM (applies pending appliance updates) |
status |
Show VM status and system diagnostics |
resize |
Grow the VM data disk that backs the storage pool |
reset |
Wipe the VM data disk: deletes every instance, template and cached image |
console |
Follow VM serial console output |
check-version |
Say whether the running appliance matches the installed version; with --quiet, only when a restart would change it |
isx vm restart [options]
Stops and restarts the VM, applying any pending appliance updates. Running containers will be stopped.
| Option | Description |
|---|---|
-y, --yes |
Skip the confirmation prompt |
isx vm resize <size>
Size must be larger than the current disk (grow-only), e.g. 100G.
| Option | Description |
|---|---|
-y, --yes |
Skip the confirmation prompt |
isx vm reset [options]
Wipes the VM's storage pool, for when it's beyond repair (for example, orphaned subvolumes reported by isx doctor). Every instance, template and cached image is deleted; isx lists them and asks first, or needs --yes without a terminal. Rebuild your templates afterwards with isx build --all.
| Option | Description |
|---|---|
-y, --yes |
Skip the confirmation prompt |
AI-powered help. Ask any question about incus-spawn (uses AI tokens).
isx ask <question...>
| Option | Description |
|---|---|
--with-templates |
Include template and tool definitions in the AI context |
Serve isx to an agent on this machine over the Model Context Protocol (stdio). Started by the agent, not by hand:
claude mcp add isx -- isx mcp
See Delegating from an agent on your host for what it offers and how to approve templates.
isx mcp --caller-instance <name> serves an instance branched with --mcp-client instead; the proxy runs it for each connection that instance makes to https://mcp.isx.internal/mcp (a coordinator in an instance).
Print a shell completion script.
isx completion [<shell>]
Supported shells: bash (default), zsh, fish.
The scripts also complete <namespace>=<account> for isx branch --account and isx account set, and the namespaces for isx account unset. They read account names from isx account list --completion, which prints names only, never credentials.
| Option | Description |
|---|---|
--install |
Print installation instructions instead of the script |