Workspaces and sandboxes
A workspace is an org's own machine: a long-lived container with a home that survives rebuilds, where the org's people and agents keep their sessions, tools and checkouts. It is also an actor in the org: an org token lives inside it, so the agents running there administer the org's apps through the org's MCP and reach nothing outside it. Heavy or risky work goes to sandboxes they make through the same MCP, which expire on their own.
| Workspace | Sandbox | |
|---|---|---|
| What it is | The org's long-lived machine, where its people and agents work | A throwaway instance for a build, a test, an experiment |
| How many | One per org | Any, within the org's quota |
| Lifetime | Months; restarting it ends live sessions | Hours: it expires (24 h) and is deleted when idle (2 h) |
| State | A home that survives rebuilding the machine | None worth keeping |
| Identity | It is an actor: an org token (full admin by default) lives inside it | isb.owner names who made it |
Both live in the org's incus project, on its network, under its quotas and ACL (Orgs). There is no nested incus anywhere: an agent in the workspace asks the daemon for a sandbox, and the daemon makes it beside the workspace. Docker inside the workspace is an exception a superadmin can allow per org (below).
isb workspace create [--image IMAGE] [--name N] [--user dev] [--cpus N] [--memory 8GiB]
[--root-size 30GiB] [--home-size 20GiB] [-e KEY=VALUE]...
[--secret NAME]... [--token-role viewer|member|admin] [--home-bind DIR]
[--setup FILE]
isb workspace show [NAME] [--json] status, resources, sessions, token metadata, URL
isb workspace ls [--json]
isb workspace start|stop|restart [NAME] [--yes]
isb workspace rebuild [NAME] [--image IMAGE] --yes
isb workspace update [NAME] [--image I] [--cpus N] [--memory M] [--root-size S]
[--home-size S] [--token-role R] [--setup FILE | --no-setup] [--yes]
isb workspace setup [NAME] [--run] the first-boot script's state; run it again
isb workspace image build|ls|logs|rm workspace images from recipes (platform admins)
isb workspace rm [NAME] [--keep-home] --yes
isb workspace rotate-token [NAME]
isb workspace settings [--max-workspaces N] [--sandbox-expiry 24h] [--sandbox-idle 2h|none]
[--home-kind volume|host] [--home-pool POOL]
isb workspace sandboxes [--json] creator, age, expiry, resources
isb workspace extend SANDBOX [--by 24h] [--idle-timeout 4h|none]
isb workspace port ls [--json] published ports, preview hosts, URLs
isb workspace port add PORT [--host HOST|default|auto]
isb workspace port rm PORT
isb workspace port open PORT [--origin URL] a one-time preview link
isb org nesting ORG [on|off] superadmins: Docker in the workspace
isb workspace ssh-config [--name N] ... the Host block (isb ssh-config for it)
isb workspace ssh [--name N] ... the ProxyCommand (isb ssh-proxy for it)
All of them take --org (or $ISB_ORG) and go through isb serve. The web
UI's Workspace page does the same
(The web UI), and
SSH and herdr covers getting a shell in it from your own
terminal.
The machine#
- An unprivileged container in the org's project, named after the workspace
(
workspaceunless--name), from any incus image (isb-workspace,dev-base,images:ubuntu/24.04) or the org's ownregistry:APP:TAG. Without--imageit isisb-workspace(isb's default image, built from its recipe: Claude Code, omp, herdr, mise) when the host has it, thendev-base, elseimages:ubuntu/24.04; a local image the host lacks is refused with the ones it has. A create leaves it running (one that is not is started before the call returns), and it starts with the host (boot.autostart). Workspace images covers building images from recipes. - A first-boot script (
--setup FILE, optional) runs once as root on the first start after a create or a rebuild, logged to the history, and again on request (isb workspace setup --run): small per-org tweaks with no image of their own (First-boot scripts). - Creating it in an org that does not exist is refused up front (
org X not found), as everyworkspace_*andsandbox_*tool is. A create that fails (an image, the org's quota) leaves nothing behind: no instance, definition or token, and no home it made. A full quota is named with its usage and how to raise it (orgs). - The home is mounted at the workspace user's home (
--user, defaultdev, made when the image lacks it) and survives rebuilds. It is one of two things (below): a managed volume, or a host folder the host backs up. - In an org with a disk quota incus needs a size on every disk, so a
workspace without
--root-sizegets a 20 GiB root, and a sandbox spec without one a 10 GiB root. - Rebuild replaces the machine with a fresh one from its image (or a new
--image, which becomes the workspace's) and mounts the same home again: a damaged root is a replaced guest. Anything installed outside the home is gone; the token stays, and the first-boot script runs again. - Resizing (
cpus,memory, root and home size) applies at once.imagechanges apply on the next rebuild;envandsecrets(throughworkspace_update) are delivered again at once (new login shells see them). - Delete removes the machine and revokes the token. A volume home is deleted
unless
--keep-home(a new workspace of the same name then mounts it again); a host-folder home is always kept.
The home: a volume or a host folder#
| Managed volume | Host folder | |
|---|---|---|
| Where | <org>_<name>_home in a storage pool |
<root>/<org>/home (<root>/<org>/<name>/home for another name) on the host |
| When | the default | isb serve --workspace-home-root DIR (ISB_WORKSPACE_HOME_ROOT) |
| Size | --home-size (20 GiB), grown with workspace_update; counts against the org's --disk quota |
the host's disk; no quota |
| First contents | what the image has at that path (incus initial.copy: dev-base's mise and dotfiles), else /etc/skel |
/etc/skel when empty |
| Backups | isb: snapshots, backups to the org's S3 destinations, staged restores (Volumes), on the Home tab | the host's own (restic of the root, say); isb takes none |
| Deleting the workspace | deletes it, unless --keep-home |
keeps it |
Volumes: the pool. A new home volume goes in the org's home_pool
setting (platform admins), else isb serve --workspace-pool POOL
(ISB_WORKSPACE_POOL), else the org's default pool; the workspace keeps the
pool it was created in. The Home tab shows the pool and its driver. The
driver decides the default snapshots: on a copy-on-write pool (zfs, btrfs,
lvm, ceph) a new home is snapshotted @hourly, keeping 24; on dir (and any
other driver) every snapshot is a full copy of the home, as large and as
slow as the home itself, so none are scheduled. Back such a home up to S3
instead, or schedule snapshots by hand with a small keep (two or three); the
Home tab says so.
Host folders. With --workspace-home-root, a workspace's home is a host
folder the daemon creates (0750, owned by the daemon's user) and binds at
the user's home. The instance maps the daemon's uid 1:1 (idmap: auto), and
a workspace user the image lacks is made with that uid, so files have the
same owner inside and on the host; other uids show as nobody inside. For
the bind, the org's restricted project is allowed <root>/<org>
(restricted.devices.disk.paths), recorded on the project as a workspace
home (user.isb.workspace-homes) so that isb org create and org_update
list it whatever bind roots they set; nothing else of the host. An org opts out (or in) with its
home_kind setting, volume or host (platform admins).
Restoring a host-folder home is the host's job and follows the same rule as isb's staged restores: restore into a folder beside the home (bound or copied in), compare, copy back what you need; never write over the live home while the workspace runs.
Security. A host-folder home puts host disk into a restricted project by
a path only the operator chooses: --workspace-home-root is the daemon's
configuration, home_kind is for platform admins, and naming a path per
workspace (home_bind) is for superadmins. Org members and the workspace's
own token can set none of them. The folder is readable on the host by its
owner (the daemon's user), and the host's backups hold the org's data, so
they must be kept with the same care as the daemon's state.
Using an existing folder as the home#
To move an existing machine's home into an org's workspace without copying
it, point --workspace-home-root at the directory that holds such homes
(say /srv/workspaces), stop the old machine, and have a superadmin (the
unix socket, a superadmin token or identity) create the workspace with
home_bind naming the folder:
isb --org acme workspace create --image dev-base --home-bind /srv/workspaces/box/home
The folder's name need not match the org's. It is allowed in the org's project and kept as it is, and its files keep their owner as long as the workspace user's uid is the daemon's.
The web terminal#
The Workspace page's Terminal tab opens shells as the workspace user, in its home, over the daemon's terminal websocket. How long a shell lives depends on the image:
- With herdr in the workspace (
isb-workspacehas it), each tab is a herdr session: a tab in a herdr workspace labelledisb web, on the workspace user's own herdr server (its default session, started detached on first use with the login environment and the user's shell). A tab attaches withherdr terminal attach ID --takeover; closing the page, a reload or a dropped connection kills only that attach client, so the next attach finds the same shell, scrollback included, and the page reconnects on its own after a drop. Closing a tab asks whether to detach (the shell keeps running and is offered to reattach) or end the session (the herdr tab and every shell in it close). Renaming a tab renames the herdr tab. The same tabs show in herdr itself, over SSH orherdr machine add(SSH and herdr). One browser attaches to a session at a time: a second takes it over. - Without herdr, a tab is a plain login shell that ends when its tab closes or the page reloads, as on any other instance.
The tab says which it is. herdr stays outside isb: isb only runs the herdr CLI in the workspace, as its user, when the image has it.
| Tool | Who | Does |
|---|---|---|
workspace_terminals |
members | mode (herdr with its version, or shell; null while stopped) and the herdr sessions (name, tab_id, panes) |
workspace_terminal_update |
members | session with rename, or end: true |
The websocket names a session with &session=NAME (The web
terminal).
Confirmations and live sessions#
Stop, restart, rebuild, delete and resizing end (or can end) every session
on the machine, so the tools refuse without confirm: true and say what is
live; the CLI's --yes sets it. Agents get the same sentence in the tool's
error:
Restarting workspace in org acme ends every session on it (2 web terminal(s) and 1 SSH connection(s)). If that is intended, call again with confirm: true.
Live sessions are what isb can see: web terminals open through this
daemon, and established SSH connections inside the machine (ss on port 22,
asked at most every 30 seconds).
Who may do what#
| Viewer | Member | Admin, owner, platform admin | |
|---|---|---|---|
See it (workspace_get, workspace_list, sandbox_list) |
yes | yes | yes |
| Start, stop, restart; the terminal and SSH (attach) | yes | yes | |
| Create, change, rebuild, delete; rotate the token | yes | ||
Sandbox defaults (workspace_settings) |
yes | ||
| Publish, list, remove and open ports | yes | yes | |
max_workspaces, home_kind, home_pool |
platform admins only | ||
home_bind (a host path per workspace) |
superadmins only | ||
allow_nesting (Docker in the workspace) |
superadmins only |
The workspace is an org actor#
Creating a workspace mints its token, isb_ws_..., with a role in the org
(--token-role, default admin: the org's apps, deployments, secrets,
sandboxes and audit log). The token:
- is confined to its org: it acts as no user, reaches no other org, no
platform or host tool, and cannot use the identity endpoints
(
/api/v1/auth/*) or the account tools butwhoami: no members, invitations, tokens, keys or sessions; - arrives inside as
/run/isb/token(0400, owned by the workspace user) and, through/etc/profile.d/isb.sh, as$ISB_TOKENin login shells, with$ISB_URL,$ISB_ORGand$ISB_WORKSPACE. TheisbCLI inside works with no setup (with no daemon socket it uses$ISB_URLand$ISB_TOKEN), and MCP clients use$ISB_URL/orgs/$ISB_ORG/mcp(Agents and MCP); - is never shown: no tool returns it, it is never logged or put on a
command line (it reaches the machine through incus' file API). The daemon
keeps its SHA-256 for authentication and the token itself encrypted to its
age key, under
<state>/orgs/<org>/workspaces/(0600), so it can deliver it again after a restart or rebuild (/runis empty after a boot; the daemon notices a new init process within 15 seconds); - is rotated by
workspace_token_rotate(the old one fails at once, the new one is delivered) and revoked by deleting the workspace; - shows in the audit log and the history as actor
workspacein its org, and labels the sandboxes it makesisb.owner=workspace.
Named org secrets (--secret NAME) are delivered as /run/isb/secrets/NAME
(0400, the workspace user's). -e KEY=VALUE are plain variables for login
shells (ISB_* are isb's and refused).
Reaching isb from inside: the bridge listener#
The workspace reaches isb on its own org's network, never the host's
loopback or a public URL: for each org with a workspace, isb serve serves
the org-bound surface on the org bridge's gateway address,
http://<gateway>:8481 (--workspace-mcp-port, ISB_WORKSPACE_MCP_PORT).
That is $ISB_URL.
- It answers only peers in the org's own subnet (others get 403), and other orgs' instances cannot route to it anyway: their ACLs reject the private ranges.
- It serves only
/orgs/<org>/...(MCP, the REST tools, theworkspaceresource, the terminal and SSH websockets) and/healthz; the authorizer pins every call to the org. - It takes bearer tokens only, the workspace's or an org API token: no session cookies, no Access assertions, no superadmin tokens.
- The host firewall must let the bridges reach the port:
sudo isb host setupaddsufw allow in on isbbr+ to any port 8481 proto tcp(Host firewall). It must differ from the ingress's tunnel port (8480).
For an org placed on a server, the server's agent runs the workspace, keeps
its token and serves the listener on that server's bridge, which takes the
workspace's token only (org API tokens live on the control plane); the
control plane forwards the workspace_* tools, the terminal and SSH like
any org call, and the agent reaps the org's sandboxes
(Servers).
A default org that is incus' own default project (no org network) has no
workspace: create one in an org of its own.
Ports#
A dev server in the workspace (a Vite app, python3 -m http.server)
becomes reachable by publishing its port. It must listen on 0.0.0.0
inside the workspace (vite --host), since isb reaches it on the
workspace's address on the org's bridge, not on its loopback.
isb --org acme workspace port add 5173 # preview through isb
isb --org acme workspace port add 3000 --host default # also 3000-workspace.<the org's first domain>
isb --org acme workspace port ls
Every published port can be previewed through isb, for the org's members; a port with a host is also served through the org's ingress, like an app's domain. Members and above publish, list, remove and open ports; each add and remove is in the audit log and the history.
Hostnames through the ingress#
--host (host on workspace_port_add) puts the port on a hostname
through the org's ingress, with everything an app's domain gets
(Domains and ingress): the org's domain allowlist,
first claim wins (a hostname an app or another org holds is refused),
HTTPS with a certificate from Caddy, or the org's Cloudflare Tunnel with
its rules and DNS records kept by isb. host is a hostname, default
(<port>-<workspace>.<the first suffix in the org's --allow-domain>) or
auto (<port>-<workspace>-<org>.<a-b-c-d>.sslip.io). It needs an
ingress on the server (--ingress-http, --ingress-https or
--ingress-tunnels). Publishing a port again replaces its host.
Such a hostname is public, as an app's is: anyone who knows it reaches the dev server. Put Cloudflare Access in front of it (the tunnel provider) for a private preview, or use the preview through isb, which needs an isb sign-in.
The route follows the workspace's address, so a restart or rebuild keeps it working; a stopped workspace's hostname answers 503.
Previews through isb#
isb workspace port open PORT (the Open button on the Ports tab,
workspace_port_open) gives a one-time link to the port's preview, served
by isb serve itself and proxied to the workspace: it works on a host with
no domain and no ingress, and only for the org's members.
Each preview has an origin of its own, the host
<port>-<workspace>-<org> under the preview domain:
isb serve --preview-domain DOMAIN(ISB_PREVIEW_DOMAIN): a domain whose subdomains reach the daemon's listener, such as a wildcard DNS name routed through the tunnel to it (*.preview.example.com): previews arehttps://<port>-<workspace>-<org>.preview.example.com;- without it, when isb is reached on loopback (
http://127.0.0.1:8192,http://localhost:8192), previews arehttp://<port>-<workspace>-<org>.localhost:8192: browsers resolve*.localhostto loopback by themselves, so nothing needs setting up. Elsewhereworkspace_port_openrefuses and names the flag.
Opening a preview: the link carries a random token, good once and for 60
seconds, for that preview's host and the caller. Spending it sets a cookie
for that host alone (isb_preview: HttpOnly, SameSite=Strict, 8 hours)
and moves on to the app. Every request then needs the cookie; the caller's
membership is checked again every minute, and unpublishing the port ends
its previews at once. Websocket upgrades pass through, so dev servers' hot
reload works. Why it is built this way:
Security.
Limits: one request per connection (isb's server closes each), request
bodies up to 4 MiB, no chunked uploads. A dev server that checks the
Host header must allow the preview host: Vite allows *.localhost by
itself; with --preview-domain, add it to server.allowedHosts.
Docker in the workspace#
The workspace runs unprivileged without nesting, like everything else in the org, so Docker does not work in it. A superadmin can allow it per org:
isb org nesting acme on # on the host (the unix socket), or a superadmin token
isb --org acme workspace restart --yes
The org's workspace, and nothing else in the org, then runs with
security.nesting and the system-call interception unprivileged Docker
needs; sandboxes, apps, builds and stack replicas are refused it whoever
asks. Turning it on applies from the workspace's next start; a workspace
created or rebuilt meanwhile has it from the start. Turning it off is
refused while the workspace runs with nesting (its containers live on it):
stop it, turn nesting off, start it. The workspace and org settings pages
show the warning badge Nesting allowed. What it costs:
The Docker exception.
Sandboxes are short-lived#
Every sandbox made through isb serve (sandbox_create) gets deadlines,
kept on the instance as user.isb.expires_at and user.isb.idle_timeout:
- an expiry: the org's
sandbox_expiry(default 24 h; at most 30 days), or the call'sexpires; - an idle timeout: the org's
sandbox_idle(default 2 h), or the call'sidle_timeout, ornone. Idle means no exec or terminal through isb and under 2 % of a core of CPU since the daemon last saw it used (a daemon that just started counts from its start). Org admins change both defaults withworkspace_settings, or Defaults on the workspace's Sandboxes tab.
sandbox_extend pushes the expiry out (by, default 24 h, from the later of
now and the current expiry, never past 30 days from now) or changes the idle
timeout; its creator (the isb.owner the caller would get) or the org's
admins may. sandbox_list shows each instance's kind (sandbox,
workspace, replica, build), creator, age, expiry, idle timeout, last
activity, limits and use, and whether the caller made it (mine).
The reaper in the daemon checks every minute and deletes a sandbox past
either deadline, recording sandbox.reaped in the history with the reason.
It only takes instances that carry isb's deadlines and are neither a
workspace, a stack replica nor a build, re-reads each before deleting it,
skips one with a terminal open, and leaves orgs placed on servers to their
agents. Sandboxes made by isb create and isb up on the host have no
deadlines and are never reaped.
The workspace cannot be reached as a sandbox: sandbox_create over its name
and sandbox_remove of it are refused.
The tools#
| Tool | Who | Does |
|---|---|---|
workspace_get |
members | the workspace (or null) and the org's settings: definition (with setup and setup_state), status, resources, home, live sessions, last activity, token metadata, connect (url, mcp_url), sandbox count; with none yet, create: the images it can be made from, the default_image, whether isb's default image exists here (default_recipe), and the org's quota and usage |
workspace_list |
members | every workspace in the org (one, unless max_workspaces was raised) |
workspace_create |
admins | image (default above), name, user, cpus, memory, root_size, home_size, env, secrets, labels, token_role, setup (the first-boot script); home_bind (superadmins: a host folder as the home) |
workspace_update |
admins | any of those but name, user, home_bind; resizing needs confirm |
workspace_start, workspace_stop, workspace_restart |
members | stop and restart need confirm |
workspace_rebuild |
admins | image, confirm |
workspace_delete |
admins | keep_home, confirm |
workspace_token_rotate |
admins | a new token, delivered; the old one revoked |
workspace_setup_run |
admins | run the first-boot script again: now when running, else on the next start |
workspace_terminals, workspace_terminal_update |
members | the web terminal's herdr sessions (above) |
workspace_image_build, _logs, _list, _remove |
platform admins | images from recipes (Workspace images) |
workspace_settings |
members read, admins change | sandbox_expiry, sandbox_idle; max_workspaces, home_kind, home_pool (platform admins) |
sandbox_create |
members | takes expires, idle_timeout |
sandbox_extend |
the creator, admins | name, by, idle_timeout |
workspace_port_list |
members | the published ports: preview host, ingress hostname, URL and state |
workspace_port_add |
members | port, host (a hostname, default, auto; none: a preview through isb only) |
workspace_port_remove |
members | port |
workspace_port_open |
members | port, origin (the isb URL the browser uses, without --preview-domain): a one-time link |
org_nesting |
superadmins | org, allow_nesting (left out: reads it) |
The same as a REST resource, per org:
Method and path (/orgs/<org>/api/v1/...) |
Tool |
|---|---|
GET workspace[?name=] |
workspace_get |
POST workspace |
workspace_create |
PATCH workspace |
workspace_update |
DELETE workspace (body {"confirm": true}) |
workspace_delete |
POST workspace/start, /stop, /restart, /rebuild, /token/rotate |
the action |
GET, PATCH workspace/settings |
workspace_settings |
Files#
Under the org's state directory (<state>/workspaces/ for the default org,
<state>/orgs/<org>/workspaces/ for the others), 0600: <name>.json (the
definition, the token's id and hash, the home's pool or host folder),
<name>.token.age (the token, encrypted to the daemon's key),
settings.json. A workspace's published ports are in its definition. A volume home's snapshot schedule and backups are the
volume's (Volumes).