#Studio
Studio is the browser view of your sandboxes: launch a command or an agent,
watch its output, type into its terminal, read its files and its audit
events. It is a client of the same API as the CLI and the SDKs, served by
sandbox-cli itself on a loopback port, and it talks to whichever endpoint
your context points at — a sandboxd on your Mac, on your Linux machine, or
a gateway in front of many.
sandbox-cli studio
# Studio: http://127.0.0.1:7080/#token=cb0fbb4f…
# studio: context local · Ctrl-C to stop
Open the address it prints. --context picks another endpoint, --port
another port. Closing Studio leaves every sandbox it started running; the
next Studio finds them again.
This page is the user's guide. Working on Studio itself — building it, its tests, how its routes are laid out — is studio/README.md.
#Screens
Nothing in Studio is a second implementation of the CLI: every screen is an API call or the same host-side code the CLI runs, so a rule the CLI keeps, Studio keeps.
| Screen | What it does |
|---|---|
| Sandboxes (home) | Every sandbox on the endpoint — started from Studio, the CLI or an SDK — each with what its agent is doing (working, waiting for you, idle) where it runs one, under a strip of how many are running and the vCPU, memory and disk given to them, against the machine's capacity where the endpoint reports it (a plain sandboxd does; a gateway gives totals). Searched, filtered by state, sorted, paged; each row shows what the sandbox was given — a click there opens its CPU and memory over the last hour, where the endpoint measures them, and for a GPU sandbox its GPUs' utilisation, memory, temperature and power too, with its memory charted as what the guest uses, since the host holds all of it (gpu.md) —, a terminal button and a menu (open, open as a page, copy the id, suspend, terminate); select several to terminate them at once. Terminating, from the list or a sandbox's panel, opens a dialog listing what goes and asks for the sandbox's name (or terminate N for several) to be typed first. With none yet, a first-run panel with the code to start one. |
| A sandbox | A click on a row opens it in a panel beside the list: its up and down buttons step through the list, and it widens to two panes or opens as a page of its own (/sandbox?id=). Its overview — id, image, network, resources, lifecycle, labels, environment names, volumes, processes —; a real terminal (Terminal goes back to the shell open there, or starts one as sandbox-cli shell does; the tab lists every process with a terminal, an agent's console included, to attach to, and New shell starts another); a Desktop tab, where the sandbox's image has one (desktop.md): its screen, with a terminal and a browser, to see and use; an Agent tab, where the image is an agentos one (see below); its processes' logs from the first byte; its files; and its audit events. A sandbox with GPUs has a GPU chip beside its CPUs, memory and disk (its devices on hover), and no Suspend, Snapshot or Resize: none can be done to it. Wide, the overview stays beside the other tabs. A live sandbox is edited from its overview: name, labels and idle auto-stop in one dialog; a sandbox with a lifetime counts down to its end, with −15m to bring it nearer and, for an admin key or a plain sandboxd's token, +15m to push it out; its network (none, an allowlist from the groups and hosts, or open) where the endpoint can change a running sandbox's; a metrics export, where the endpoint offers one: the sandbox's CPU, memory, network and disk pushed to your OpenTelemetry collector every so often, with when it last worked or why it is failing — its headers (a collector's credential) are entered, never shown again, and through a gateway one can name a secret instead; and Resize: on Firecracker the same sandbox at its new size — a larger disk alone is grown live (Grow disk), its processes running on; new vCPUs or memory restart it (Resize and restart), keeping its files, volumes and network —; on macOS, where a running VM's size is fixed, a copy at a template's size — its files, name, labels and network come across, its processes, memory and environment do not. |
| Playground | A command, an agent run unattended, or an agent's interactive console, in a fresh sandbox that starts in its own home directory, from the server's image, one you name (suggested: those the machine has built, those its sandboxes run, the desktop image) or a snapshot where the backend takes them, with a snapshot schedule and a metrics export if asked for, at a size from Templates and, where the endpoint has GPUs, with whole GPUs (gpu.md; how many are free is shown, and with one the snapshot and suspend choices step aside) — with the same config, profile, network policy, labels, volumes and agent login a sandbox-cli run would get. Beside the form, the same run written as CLI, curl, Python and TypeScript, to repeat it from a script. |
| Snapshots | Sandboxes captured to start new ones from — whole (memory, processes and disk) where the backend can, their files only where it cannot capture more (macOS) — with their kind and size; Launch starts a new sandbox from one (the launch page, with it chosen), Take snapshot captures one of your running sandboxes, and Delete removes one. A running sandbox's panel takes one with Snapshot, and its overview's Snapshots section sets a schedule — every so often, the newest few kept, within the server's limits — and lists that sandbox's snapshots, scheduled or manual. |
| Agents | The agents Studio runs, each with a verified headless mode, one row each: whose login is saved, which API keys are set or saved, and the API it reaches. Open a row to add, change or remove an API key for any variable the agent reads. A key is write-only: it is kept in ~/.config/sandbox/agent-keys.json (yours only, 0600), never shown again, and used by an agent run — Studio's or the CLI's — when the environment does not set that variable; the environment's value wins. |
| Templates | Sizes to launch at — vCPUs, memory and disk — by name: micro, small, medium, large and xlarge built in, and any of your own, made, edited and deleted here (a built-in one is copied, not changed). The Playground's Size step offers them, and a launch sends the size as --cpus, --memory and --disk would; the endpoint's limits still apply, and a template above them is marked and refused. Kept in ~/.config/sandbox/studio.json, and read by the CLI too: sandbox-cli run --template large -- make (or agent claude --template medium) launches at the same size, with --cpus, --memory or --disk beside it overriding that field; sandbox-cli template ls lists them. |
| Images | On a plain sandboxd: the images sandboxes start from, each with its state, size, how many sandboxes use it and when it was installed; Download image installs one ahead of the first sandbox that wants it, with its progress; Start opens the Playground with it; Remove frees one nothing uses (the default and pooled images are locked); a failed download shows why, with Retry and Clear. Through a gateway: for an admin key, the same for every node at once, with which nodes have each image; for any other key, what the nodes have built, read-only. |
| Volumes | Named volumes and where each is mounted. |
| Settings | The context, what its endpoint can deliver, allowlist groups and deny rules. A group is a named set of hosts — go, npm, an internal registry — edited in place; the Playground's Network step offers none, open, or an allowlist made of the groups you pick (those marked default are picked to start with, and are what a launch on an endpoint whose default is an allowlist gets), plus one-off hosts, with or without the built-in agents' APIs and registries (--no-baseline). An agent run always reaches its own API. Deny rules, each with a switch, apply to every launch with a network. The endpoint's policy still decides — a host it does not let a request add is refused at launch — and the Playground's code shows it all as --allow, --deny and --no-baseline. Kept in ~/.config/sandbox/studio.json; for rules every sandbox-cli run gets, use network.allow in ~/.config/sandbox/config.yaml. |
#An agentos sandbox's agent
An agentos image is a desktop image with an agent that works the desktop —
screenshots, mouse, keyboard, a browser — reached over the Agent Client
Protocol (ACP). It says so in /etc/agentos/manifest.json: the agents it
offers, the command that speaks ACP for each, its desktop and where the agent
saves files. Studio reads that file, never the image's name, and shows the
Agent tab only for a running sandbox that has one; the tab opens first.
The agent needs its login in the sandbox. On a gateway that keeps yours
(agent logins), Start has the gateway copy it in
first, as an agent console does, so a sandbox a bot or a script started works
too; the login named is the manifest's login, and only for an agent this
side knows to keep login files. Otherwise start the sandbox with the agent's
login, the way the CLI does:
sandbox-cli agent claude --image ghcr.io/<owner>/sandbox-agentos:edge \
--keep --name os --network open -- --version
Start opens a session: Studio's server starts the manifest's command in
the sandbox's home and bridges it to the page (/api/ws/acp). Give a task;
the tab shows the agent's plan, each tool call (open one for its output and
any screenshot), and its replies. The menus above it are the session's
settings, as the agent offers them: its mode, and where the agent has them,
its model and reasoning level. A mode that asks before each tool call shows
the question with its options; Stop ends the turn, End session the
agent's process.
An agent may send its settings in either of ACP's two forms, and the tab
shows both: Claude Code's mode menu (modes), and OpenCode's mode, model and
reasoning menus (configOptions: build or plan, its models, and the
model's reasoning levels, which follow the model chosen). A manifest offers
OpenCode with "acp": ["opencode", "acp"]; its free models need no login,
and the sandbox needs a network that reaches them. Leaving the tab keeps the
session, so the Desktop tab shows the agent at work; closing the page ends it.
What the agent sends is the guest's: the server checks the manifest's fields and passes only JSON messages on. The page draws the agent's replies as Markdown — tables, lists, emphasis, code — and nothing more. HTML in a reply stays text. A link is a link only to an absolute web or mail address, opened in a new tab; any other, such as one back into Studio, is its text alone. An image in a reply is shown as its alt text, never fetched. The images the page shows are what the agent saw, from its tool calls, and only as PNG, JPEG or WebP. The page offers the agent no files or terminals of its own: it works with the sandbox's.
#The History tab: what the agent did
Studio's server records each session from the messages it relays, one JSON
line per thing done, and the sandbox's History tab shows them by
session, newest first. The record outlives the VM: an ended sandbox keeps
its History tab for as long as sandboxd still lists it (it keeps the newest
100 ended sandboxes), and the file keeps the records after that, for jq.
| Record | What it holds |
|---|---|
session.start |
the agent, the image |
prompt |
what was asked (images counted, not kept) |
tool |
one line per tool call once it finishes: title, kind, status (completed, failed, or unfinished when the session ended first), when it started, its arguments and its result |
permission |
what the agent asked to run and the option chosen, or unanswered |
mode, cancel |
a mode asked for; a turn stopped from the page |
turn.end |
why the turn ended, and the agent's reply |
limit |
the session wrote 4 MiB of records: from here only its end is kept |
session.end |
the agent's exit code, or closed when the page left first; dropped, how many records the limit cost |
Every record names the sandbox by id and by its name then, the session and the agent; hosted, also the signed-in user and the organisation.
jq -c 'select(.name == "os" and .type == "tool") | [.time, .title, .status]' \
~/.config/sandbox/studio/agent-log.jsonl
- Where.
sandbox-cli studio --agent-log FILE, by default~/.config/sandbox/studio/agent-log.jsonl;--agent-log nonekeeps none, and the History tab says so.sandbox-cli studio hostkeeps none unless given--agent-log FILE: a hosted Studio otherwise writes nothing on its machine, and this would be its users' sessions. - How much. Each text field is cut at 4 KiB (
truncatedsays so) and images are never written. The file is 0600 and rotates as the audit log does: at 8 MiB, keeping five old generations. One session writes at most 4 MiB, so an agent that floods tool calls cannot rotate other sandboxes' history away. A read returns the newest 2,000 records of the sandbox. - Who reads it.
GET /api/agent-log?sandbox=looks the sandbox up with the caller's own credentials first and returns only that sandbox id's records, so a hosted user sees the sessions of sandboxes the gateway lets them see, and a name used again later starts with no history. - What it can be trusted for. It is written on Studio's machine, so nothing in the sandbox can change it afterwards; it says what the agent reported, not what the guest did (a tool's title and result are the agent's account). sandboxd's events (the Events tab) are the guest's side. It is best-effort, as the audit log is: a session is not refused because its record could not be written. Treat it as sensitive: a tool call's arguments are where a token the agent handled would show.
- Only sessions through Studio are recorded; an editor that speaks ACP to
agentos-acpdirectly (sandbox-cli exec … -- agentos-acp) is not.
#A plain sandboxd, or a gateway
On load Studio asks GET /v1/whoami. A plain sandboxd answers 404 and
gets exactly the screens above. A gateway (fleet.md) answers with
the API key's user, tenant and scopes, and those decide the rest:
sandbox-cli studio --context fleet
sandbox-cli studio holds the API key and adds it to each call it proxies;
the browser never sees the key. It is the same Studio either way.
#Scopes and screens
| The key holds | Studio adds |
|---|---|
| any scope | Jobs (list with search and state and label filters; submit with labels; detail with each run's kept output and files), Services (list with search and health filters, detail with replicas, health and rollout), Secrets (names only), SSH (where to connect, the host key to pin), Account (user, tenant, current organisation, key id, scopes), API keys (keys you make for your programs: a name, some of your scopes and an expiry; the secret shown once; revoke any — fleet.md), the organisation switcher at the top of the sidebar and Members (list; add, remove and change roles as an owner) |
org:create |
Create organization in the switcher |
sandbox:create |
the Playground, submitting and cancelling jobs, deploying (a JSON spec) and scaling services, creating volumes, a sandbox's Terminal, Suspend and Snapshot |
sandbox:delete |
terminating sandboxes, deleting volumes and snapshots; with sandbox:create, removing a service |
sandbox:ssh |
adding and removing your SSH keys, issuing a short-lived access token for a sandbox (shown once) |
secrets:write |
setting and removing secrets. A value goes in a password field and is never shown: no call returns it |
admin |
everything above, plus Dashboard (first in the sidebar: users, organizations, keys, nodes, sandboxes running and created, jobs, services, and memory, CPUs and disk given of what the nodes have — totals only; the super admin sees no one else's sandboxes), Nodes (health, allocated capacity, cordon, uncordon, drain, add, remove), Lost sandboxes, Left behind (sandboxes still running whose owner was suspended, revoked or removed from the organization, with why; terminate one or all), Users & keys (invite people by email, reset their passwords, issue and revoke API keys, any user's SSH keys, and user policies: create one, give it when issuing a key, change a user's), Members (any organisation's members, picked from every one: add, remove, make an owner), Organizations (every organisation, with its members and owners; each links to its members) and Audit |
An action the key's scopes do not allow is not offered, rather than offered
and refused. A screen the key may not have — an admin screen for a tenant's
key, or a gateway screen on a plain sandboxd — is missing from the sidebar
and the palette, and a typed URL shows a plain Not available page that makes
no request for it. That is a convenience, not the control: the gateway refuses
a call without its scope (403) whoever sends it.
What each gateway screen is about is on its own page: jobs and secrets, services, SSH, and for an admin, operations (nodes, drain, lost sandboxes, keys, the audit log).
#Organizations
On a gateway the top of the sidebar is the organisation switcher: the key's
own tenant and every organisation its user belongs to,
Create organization with org:create, and Members. The switcher
keeps its choice per browser and sends X-Sandbox-Org on every call Studio
makes, a terminal's included; switching clears what was loaded, so nothing of
the previous organisation stays on screen. If the remembered one is no longer
allowed — you were removed — Studio goes back to your key's own tenant and
says so. With nothing chosen in this browser, Studio starts in the context's
organisation (sandbox-cli org use). On a plain sandboxd there is no
switcher and Studio never asks for /v1/orgs.
#What Studio never shows
- A node's endpoint, which is dropped as the node list is read (and taken out of a node's error text).
- A secret value. No call returns one, and neither does any call return an agent's saved API key.
- A credential twice. A new API key's secret is in one answer only; Studio shows it once, with a copy button and a warning, and drops it when you click Done. An SSH access token is shown the same way.
#A hosted dashboard without the admin screens
Building Studio with NEXT_PUBLIC_STUDIO_ADMIN=off leaves the admin screens
out of the bundle altogether — their pages are not routes in that build and
nothing they import is included — so a Studio served to many tenants does not
carry the operator's UI at all:
NEXT_PUBLIC_STUDIO_ADMIN=off make studio build # a sandbox-cli whose Studio has no admin screens
The default build keeps them, for an operator running their own gateway.
npm run check:admin-off in studio/ (part of npm run check) makes such a
build and fails if any admin route, admin API path or admin screen title is
in it. The gateway's refusal stays the control; the build only means the
screens are not shipped.
#Hosted Studio
sandbox-cli studio host serves Studio on a public address to a gateway's
users. Nobody installs anything: each user opens an invite link, and from then
on acts as themselves, seeing only what their key may see. A person invited
by email chooses a password with the link and signs in with it afterwards.
# the UI for it: invite-link sign-in, no Studio token
make studio-hosted # writes studio/out-hosted
sandbox-cli studio host \
--gateway http://127.0.0.1:8443 \
--public-url https://studio.example.com \
--listen 127.0.0.1:7080 \
--ui-dir studio/out-hosted \
--allow-admin # optional: the admin manages users here too
Put a TLS proxy in front of the loopback port (a reverse proxy, or a tunnel
that terminates HTTPS), or give it --tls-cert and --tls-key and listen on
a public address itself.
Inviting a user is issuing them a key with --invite-url, which prints
the link that signs them in:
sudo -u sandbox-gateway sandbox-gateway --state-dir … keys create --user alice --tenant alice \
--scope sandbox:read --scope sandbox:create --scope sandbox:delete \
--invite-url https://studio.example.com
# …
# invite: https://studio.example.com/#key=sgk_…
Give each user their own tenant: users without one share one quota and one set of secrets. The link is the key — send it as you would a password.
Inviting a person by email is better for anyone who is not a program: the
link is a one-time invite rather than the key, so the email is worthless once
used or after two days. They open it, choose a password, and sign in with
their address and password from then on, on the form the page shows to anyone
without a session. In Users & keys, type the address in Add a user
(the user name follows it) and press Create invite. A gateway with a mail
relay (fleet.md)
emails it itself; otherwise Email invite opens your mail program with the
message written, and Copy invite link copies it. Reset password… on an account makes a new link for a forgotten one.
From the command line it is sandbox-cli gateway invites create --email …
(fleet.md, which
also says how accounts, sign-ins and guessing are handled). A signed-in
account changes its own password on the Account page.
The admin in hosted Studio. With --allow-admin, an admin key signs in
the same way (https://studio.example.com/#key=<admin key>) and gets the
admin screens: Users & keys, Nodes, Lost sandboxes, Audit, All organizations.
From Users & keys the admin adds users while the gateway serves:
- Demo user or Member fills the form: a demo user gets the sandbox
scopes, a policy (
demowhere there is one) and a key that expires in 7 days; a member, someone using the service for good, gets SSH too, no policy and a key that never expires. Every field stays editable. - Create invite, with an email, makes a sign-up link instead of a key (above); Issue key shows the key's secret and its invite link once, each with a copy button. Pending invites are listed, and can be withdrawn.
- Renew on a key sets it to expire 7 or 30 days from now, never, or now; expiring it now ends the user's session and what they have open, as revoking does, but the key can be renewed again.
An admin's session ends after an hour unused or twelve hours in all, whatever
--session-idle and --session-max-age say: whoever holds its cookie holds
every user's sandboxes. Without --allow-admin an admin key is refused, and
the operator uses sandbox-cli studio on their own machine. The hosted build
carries the admin screens' code either way, shown only to an admin's session;
to serve none of it, build with NEXT_PUBLIC_STUDIO_ADMIN=off npm run build:hosted.
How a user signs in. The key rides in the link's fragment, which a browser
never sends to a server. The page posts it once to POST /api/session;
Studio asks the gateway who the key is (GET /v1/whoami), keeps the key in
memory and answers with an HttpOnly, SameSite=Strict session cookie
(__Host-sbx_studio over https). The key is then gone from the address bar
and from the page. Opening the link again, in any browser, signs in again.
With a password, the page posts the email and password to POST /api/login
(an invite's, with the token, to POST /api/register); Studio passes them to
the gateway, which answers with a key minted for this sign-in alone, lasting
as long as a session may. From there it is the same: the key stays in
Studio's memory and the browser gets the cookie. Signing out also ends that
key at the gateway. Studio keeps no password and no account of its own.
| Every call | made with the signed-in user's key, so the gateway's ownership, tenants, quotas and scopes apply exactly as to their own CLI. Studio adds no isolation of its own and needs none. |
| What it holds | no credential of its own: no context, no key file. It reads nothing of the machine it runs on: no agent logins, no environment, no config. |
| Agent logins | each user's own, kept by the gateway, never by Studio: an agent console asks the gateway to copy the user's kept login in before the agent starts, and the gateway saves the one made or refreshed in the sandbox (agent logins). Log in once in a console and the next one starts logged in; the Agents screen shows it as logged in and can forget it. The gateway needs --secrets-key-file; without it every console asks for a login, as before. |
| Agents | an interactive agent logs in inside its sandbox; an unattended one runs as a job, with its API key stored under Secrets. The Playground offers commands and interactive agents. |
| Studio's settings | the host user's own — saved agent keys, saved templates, egress rules and allowlist groups — are not shown, not applied to hosted launches and cannot be changed: their routes do not exist when hosted. Users get the built-in sizes (micro to xlarge), name hosts to allow themselves or pick the gateway's allowlist groups, which an admin keeps on its own screen, and keep agent keys under Secrets. |
| Admin keys | refused, unless --allow-admin (above), whether by link or by password. |
| Sessions | end after --session-idle unused (12h) or --session-max-age (7 days) — an admin's after 1h and 12h —, on Sign out, and at the next call after the key is revoked or expires at the gateway. They are kept in memory: a restart signs everyone out. |
| Refused | any Host but --public-url's (and --allowed-host, for a proxy that rewrites it); any request that changes something, and every WebSocket, from another Origin; every /api call without a session — local Studio's token and a gateway key presented directly included; more than 5 failed sign-ins a minute, and, at the gateway, an address after five wrong passwords in a row, for 15 minutes. |
sandbox-cli studio host refuses plain http:// for the gateway or the public
URL anywhere but loopback, and a non-loopback --listen without a
certificate. npm run check:hosted in studio/ fails if the hosted build
carries local Studio's token handling or the editors of the host's settings.
Not yet: single sign-on, sessions that survive a restart, and counting failed sign-ins per client (behind a proxy every request comes from the proxy, so there is one count for everyone).
#How it is served, and who may use it
Studio is a static export embedded in sandbox-cli. sandbox-cli studio
serves it on a loopback port together with a small API, from the same
origin: the endpoint's API proxied to the current context, a WebSocket bridge
to a process's terminal, and launching a run through the same code the CLI
uses.
Anything that can reach Studio's API can start sandboxes, and agents with your saved logins. These are the three reasons a web page you happen to have open cannot:
- Loopback only, and a loopback Host. Studio listens on 127.0.0.1 and
answers only
Hostheaders naming loopback, so a page whose own name resolves to 127.0.0.1 — DNS rebinding — is refused: the name it dialled gives it away. - A token per launch. A loopback port is reachable by every user on the
machine. Every request to Studio's API needs the token in the address
sandbox-cli studioprints; it travels in the URL's fragment, which is never sent to a server, is kept in the tab'ssessionStorageonly, and is wiped from the address bar. - The browser never holds the endpoint's credential. Studio proxies
sandbox calls to the context's
sandboxdor gateway and adds that token or API key itself. A cross-origin request is refused outright, and a body that is not JSON — the shape of a request that skips a browser's preflight — is refused too.
#Checking it works
Studio's end-to-end suite runs the real sandbox-cli studio in front of a
sandboxd and a real gateway on the in-memory backend. What it cannot prove
— a job's kept file from a real guest, an SSH token logging in, a real
drain — is row 44 of testing/end-to-end.md.