Documentation
Running code in Sandy
Sandy runs your code in an isolated Linux sandbox with a real, persistent shell. This page covers everything from your first command to the HTTP API underneath.
Quickstart
Create an account and issue an API key from the dashboard. The plaintext key is shown once, at creation, and never again — we only store its hash.
Install the CLI
# macOS and Linux, amd64 and arm64$curl -fsSL https://sandy.computer/install.sh | shdownloading https://sandy.computer/dl/sandy-darwin-arm64.gzsandy 0.1.0 installed to /usr/local/bin/sandyThe CLI is a single static Go binary with no runtime dependencies, which is also what makes it safe to hand to an agent as an ordinary bash command. The script installs it to /usr/local/bin, using sudo only if that directory needs it; set SANDY_BIN_DIR to put it somewhere else. Nothing here is a black box — read the script before you pipe it to a shell.
Run something
$export SANDY_API_KEY=sk-...$sandy newsb-a1b2c3d4e5f60718 $sandy run "echo hello from the sandbox"hello from the sandboxThat is the whole loop: sandy new to get a sandbox, sandy run to use it, sandy rm when you are done. Everything else is detail.
No sandbox id appears after the first line. sandy new points this terminal at the sandbox it just made, and later commands go there — see Sessions for what "this terminal" means and why it is not a file in your home directory.
How it works
A sandbox is a pod on our cluster, running a small Go server that wraps a bash process. Your commands go to that server; it feeds them to the shell and streams back what the shell says.
The persistent shell
The bash process is started when the sandbox is created and stays alive until it is destroyed. It is not restarted between commands, so shell state simply persists:
$sandy run "cd /tmp && mkdir work"$sandy run "pwd"/tmp # the second call is a separate HTTP request,# but the same bash processWorking directory, environment variables, shell functions, installed packages and background jobs all carry across calls. Shell syntax works in full — pipes, redirects, &&, subshells — because a real bash is parsing it.
exit terminates the shell. The sandbox survives and a fresh shell is started for the next command, but everything that lived in the old shell's memory — environment variables, working directory — is gone. Files on disk are unaffected.Sessions
A sandbox holds one shell per session, not one shell in total. Two terminals, an agent and a browser tab can all work in the same sandbox — sharing its filesystem, its packages and its running processes — without sharing a working directory:
# terminal 1$sandy run "cd /app/api && pwd"/app/api # terminal 2, same sandbox$sandy run pwd/app # back in terminal 1 — still where you left it$sandy run pwd/app/apiYou never name a session. The CLI works out which one it is from the terminal it is running in, so a new tab is a new session and the same tab is the same session, for as long as it is open. An agent gets one per instance, which is what keeps two agents working in one directory out of each other's way.
Processes carry on regardless. A server started with & keeps running, and keeps serving its public URL, after the session that started it has gone.
Sandbox lifecycle
Sandboxes are allocated from a warm pool, so sandy new typically returns in under two seconds rather than the 30–60 seconds a cold pod start would take.
A sandbox lives until you destroy it, until it has been idle for its timeout, or until the account runs out of credit. Idle means no command, no attached terminal and no traffic on its public URLs — 15 minutes by default, and settable per sandbox with --timeout.
Destroying one is final, however it happened. The filesystem, the shell, any running processes and any public URLs all go away together, and the id stops resolving. There is nothing to clean up afterwards.
What it costs
You are charged for the wall-clock time a sandbox exists, by the second, at a rate set by its size. Two other things cost money and only if you use them: bandwidth your sandboxes serve on their public URLs, and the minutes a template spends building an image. Nothing else is metered — not API calls, not storage, not seats, and there is no per-seat or per-project anything.
Billing is prepaid. Credit is spent as sandboxes run, and when the balance reaches zero every running sandbox is destroyed and POST /sandboxes starts answering 402. You cannot run up a bill on Sandy, because there is no bill — which is also why an idle timeout exists rather than being optional.
sandy usage reports what has been spent and what is left; the same numbers are on the dashboard and behind GET /usage. The full rate card is on the pricing page.
Public URLs
Any port a process listens on inside the sandbox is reachable from the internet, with no additional call:
https://<port>-<sandbox-id>.sandy.host
https://8080-sb-a1b2c3d4e5f60718.sandy.hostThe port and the id are joined with a hyphen into a single DNS label, which is what lets one wildcard certificate cover every sandbox that will ever exist.
Treat the URL as a secret. Sandbox ids carry 64 bits of randomness, so the hostname is not guessable — but anyone who has it can reach your app. It grants nothing else: the Sandy API still requires your API key, and the sandbox's own control port is never routable.
Isolation
Each account gets a dedicated Kubernetes namespace. Your API key maps to it, your sandboxes are created inside it, and every operation is resolved through it.
A request for a sandbox in someone else's namespace does not fail a permission check — it fails to find anything, and returns 404. The isolation is a property of where the objects live, not of a rule we remembered to write.
Templates
Every sandbox starts from Sandy's own image: Python, git, curl, a pager and an editor. A template is how you get something else: any public container image, running as your sandbox with nothing to install — or an image Sandy builds for you from your own Dockerfile.
There is no Sandy-specific base image to inherit from and no Dockerfile of ours to copy. Name an image that already exists and it works — or point from at your own Dockerfile and Sandy builds it for you.
The file
A template is a file in your project, next to the code it is for. Four keys, and you will usually use two:
# sandy.yaml
name: py-slim
from: python:3.13-slim
workdir: /srv
env:
PYTHONUNBUFFERED: "1"| Key | Description |
|---|---|
name | What you type at --template. Optional — without one, use the hash. |
from | An image, or a Dockerfile. Anything public that Sandy can pull: python:3.13-slim, node:22, ghcr.io/acme/api:v2 — pin a digest if you want the environment to stop moving. A path ending in Dockerfile, or starting with ./, is built instead. |
workdir | Where your shells start. Defaults to the image's own WORKDIR. |
env | Variables every shell gets. This half is committed to your repository, so put configuration here and secrets in sandy new --env, which is encrypted and per sandbox. |
Register it, then create sandboxes from it. When from names an image there is nothing to build, so registering is instant.
$sandy template buildpy-slim $sandy new --template py-slimsb-a1b2c3d4e5f60718 $sandy run "python --version"Python 3.13.15Building an image
When the image you want does not exist, write a Dockerfile and point from at it. It is an ordinary Dockerfile — there is no Sandy base image to start from, no instructions of ours to learn, and nothing in it that only works here.
# sandy.yaml
name: data-env
from: ./DockerfileFROM python:3.13-slim
RUN pip install --no-cache-dir pandas pyarrow
WORKDIR /srvsandy template build uploads the directory your sandy.yaml is in, builds the image on isolated hardware, and streams the output back. Then it is a template like any other.
$sandy template buildpacking build context…uploading 3 KB…Step #0: Step 1/3 : FROM python:3.13-slimStep #0: Step 2/3 : RUN pip install pandasStep #0: Successfully built 13821a1d924edata-env $sandy new --template data-envsb-a1b2c3d4e5f60718 $sandy run "python -c 'import pandas; print(pandas.__version__)'"2.2.3The upload skips anything a .dockerignore next to your Dockerfile excludes — the same file docker build reads, with the same rules — and skips sandy.yaml itself. If the context is large, that file is where to fix it.
Building the same files twice is free and instant. Sandy identifies a template by its declaration and the contents of its build context, so an unchanged directory is recognised as unchanged and the image you already have comes straight back. Change one line of the Dockerfile and it builds again.
Closing the terminal does not cancel a build. It finishes on the server; sandy template show has the answer, and sandy new --template tells you if it is still going.
Names and versions
A template is identified by its contents — a hash of everything in the file except the name, and, when it builds, of every file in the build context too. A name is a pointer to one of those, and nothing more.
That is what stops the failure every template system eventually has: you edit the file, forget to rebuild, and create a sandbox from last month's environment without being told. Here the file is the truth. Build again and the name moves to what the file now says; the version it pointed at before is still there, addressed by hash, still running whatever it was already running.
# edit sandy.yaml, then build again$sandy template buildpy-slim $sandy template listc266b6d2da66 py-slim python:3.13-slim0ad0873c5b30 - python:3.13-slim # the name moved; the old version is still there, by hashRegistering an unchanged file twice is free and changes nothing — same hash, same template, same creation date. That is also what makes rebuilding an untouched Dockerfile cost nothing: same files, same hash, same image.
What an image needs
Almost nothing. Sandy's runtime is injected into your image at start rather than baked into it, so the image needs no cooperation and stays usable everywhere else. Two requirements:
| Requirement | Why |
|---|---|
bash | Sandy's shell and terminal are bash. Debian- and Ubuntu-derived images have it; Alpine does not, and an Alpine image gives you a sandbox that starts and then fails every command. apk add bash in your own image if you need one. |
| A public registry | Private registries need credentials Sandy cannot hold for you yet. |
Your image is pulled the first time a sandbox needs it, so the first sandy new --template after registering is slower than the default image — which is already on the machine. A large image is a slow first start and a normal one after that.
Neither requirement applies to an image Sandy builds for you: it is pushed to a registry only your account's sandboxes can pull from, and it is referenced by digest, so nothing about it can change under you. bash is still needed — put an apk add bash in the Dockerfile if you start from Alpine.
CLI reference
Commands run against the sandbox this terminal is using, which sandy new and sandy use set. Every command also takes --id to address a different sandbox for that one call. Authentication comes from the environment.
| Variable | Description |
|---|---|
SANDY_API_KEY | Your API key. Required. |
SANDY_URL | API endpoint. Defaults to https://api.sandy.computer. Set it only to reach a different Sandy — a local stack, say. |
sandy new
Create a sandbox, print its id, and start using it. Later commands in this terminal need no id.
$ sandy new
sb-a1b2c3d4e5f60718sandy new --env
Set environment variables for every shell in the new sandbox. Repeat the flag for more than one. They are fixed for the sandbox's life — to change them, create another sandbox — and anything running inside can read them, so treat a key here as given to the code you run.
$ sandy new --env STAGE=dev --env API_KEY=sk-...
sb-a1b2c3d4e5f60718
$ sandy run 'echo $STAGE'
devsandy use
Switch this terminal to a sandbox that already exists. The id is checked before anything is recorded, so a typo fails here rather than on your next command.
$ sandy use sb-a1b2c3d4e5f60718
sb-a1b2c3d4e5f60718sandy run
Run a command in this session's shell. Output comes back combined — stderr is interleaved with stdout, as it would be in a terminal — and the command's exit code becomes the CLI's exit code.
$ sandy run "python train.py"
epoch 4/4 loss 0.0132sandy cp
Copy a file in or out. The sandbox side is marked with a leading ':' — that is what sets the direction — and must be an absolute path.
$ sandy cp data.csv :/app/
$ sandy cp :/app/output.csv .sandy new --template
Create the sandbox from a registered template instead of Sandy's default image. Takes a template's name, or enough of its hash to be unambiguous.
$ sandy new --template py-slim
sb-a1b2c3d4e5f60718sandy template build
Register the environment described by sandy.yaml, or by a file you name. Prints the name, or the hash if the file has none. Registering an unchanged file again changes nothing.
$ sandy template build
py-slimsandy template list
Every template on the account: hash, name, and the image it resolves to — or its state, if it is still building or failed to build. A template whose name has moved to a newer version shows a dash; it still exists, and sandboxes can still be created from it by hash.
$ sandy template list
c266b6d2da66 py-slim python:3.13-slim
0ad0873c5b30 - python:3.13-slimsandy template show
One template in full, by name or hash, with the exact declaration the hash was taken over. For a template that builds, this is where a build's outcome is, including why it failed.
$ sandy template show py-slim
hash c266b6d2da66...
name py-slim
status ready
image python:3.13-slimsandy template rm
Forget a template. Sandboxes already created from it are untouched — they are running an image, not a description of one.
$ sandy template rm py-slimsandy new --size / --timeout
Pick the machine and how long it may sit idle. Sizes are small (0.5 vCPU / 1 GiB, the default), medium and large; the timeout defaults to 15m and accepts anything from 1m to 24h. Both are fixed for the sandbox's life, and both are what it costs.
$ sandy new --size=medium --timeout=30m
sb-a1b2c3d4e5f60718sandy rm
Destroy the sandbox and everything in it.
$ sandy rmsandy usage
What this account has spent, and what is left. Add --weeks for the last eight weeks, or --sandboxes for the lifetime cost of every sandbox ever billed.
$ sandy usage
this week 11m 53s $0.0280
this month 3h 47m $0.8655
last month 6h 12m $1.3200
all time 9h 59m $2.1855
credit remaining $4.8145sandy version
Which build this is. Re-run the install command to get the current one — it always replaces what is there.
$ sandy version
sandy 0.1.0JavaScript SDK
The same API, typed, for Node and Bun. Reach for it when a program creates sandboxes — a backend serving an agent, a test harness, a job runner. The CLI is for a person or an agent at a shell prompt; this is for code.
npm install @sandy-computer/sdkNo dependencies, ESM and CommonJS both. Node 20.4 or newer.
import { Sandbox } from '@sandy-computer/sdk'
export async function analyse(csv: string): Promise<string> {
const sbx = await Sandbox.create()
await sbx.commands.run('pip install pandas')
await sbx.files.write('/app/data.csv', csv)
const { stdout } = await sbx.commands.run('python analyse.py')
await sbx.kill()
return stdout
}Every type the examples below name is exported alongside the class, so nothing has to be redeclared to be referred to:
import type {
ClientOptions,
CommandResult,
ConnectOptions,
CreateOptions,
RunOptions,
SandboxInfo,
SandboxStatus,
} from '@sandy-computer/sdk'The API key comes from SANDY_API_KEY and the endpoint from SANDY_URL, the same two variables the CLI reads, so a machine set up for one is set up for the other.
Creating and connecting
| Method | Description |
|---|---|
Sandbox.create(options?) | Creates a sandbox and resolves once it can actually run commands. |
Sandbox.connect(id, options?) | Attaches to a sandbox that already exists, on the same terms. |
sbx.sessionId | The shell this Sandbox runs in. Keep it to come back to the same one. |
sbx.releaseSession() | Ends this shell. The sandbox and its running processes are untouched. |
Sandbox.list(options?) | Every sandbox on the account that is still alive, as { id, status, createdAt }. |
sbx.status() | What this sandbox is doing right now, resolved from the cluster. |
sbx.kill() | Destroys it: disk, shell, processes. Safe to call twice. |
create never hands back a sandbox in starting. It polls until the pod is ready, so callers do not each reimplement the same waiting loop and get it subtly wrong. A cold start takes 30–60 seconds; the default patience is two minutes, adjustable with readyTimeoutMs, and running out of it throws SandboxStartTimeoutError with the id attached — the sandbox may still be coming up, and it is not destroyed.
A sandbox with a known lifetime should say so, and then it cannot leak:
await using sbx = await Sandbox.create()
await sbx.commands.run('python train.py')
// killed at the end of the block, exception or notEnvironment variables
Pass env at creation and every shell in the sandbox has them.
const sbx = await Sandbox.create({
env: { OPENAI_API_KEY: key, STAGE: 'dev' },
})
await sbx.commands.run('echo $STAGE') // 'dev'They are fixed for the sandbox's life. There is no call that changes them — create another sandbox instead — which is what keeps a running shell's environment from moving underneath it. Names beginning with SANDY_ belong to the sandbox runtime and are rejected.
They are not private from the sandbox. Whatever runs in there is root and can read them, so a key passed this way is exposed to the code you hand it to, and to anything that code can be talked into running. Sandy encrypts them at rest, which protects them from a database dump and from nothing else.
Sessions
Every Sandbox gets its own shell. Two processes connected to one sandbox no more share a working directory than two people with accounts on one server share a terminal — they share the filesystem, the packages and the running processes, and nothing else.
const a = await Sandbox.connect(id)
const b = await Sandbox.connect(id)
await a.commands.run('cd /tmp')
await b.commands.run('pwd') // "/app" — b was never movedPicking up where an earlier one left off is possible, but it has to be asked for by name:
const first = await Sandbox.create()
await first.commands.run('cd /app/project')
const session: string = first.sessionId // keep this
// another process, later
const opts: ConnectOptions = { session }
const again = await Sandbox.connect(id, opts)
await again.commands.run('pwd') // "/app/project"Sandbox is a bash process, and a sandbox holds up to 16 of them. A program that connects once per request and never lets go will reach that ceiling and start seeing SessionLimitError — call releaseSession() on the way out, or reuse a session id. Shells left behind are reclaimed after 30 minutes of disuse, which is a safety net rather than a strategy.Running commands
const { stdout, exitCode }: CommandResult =
await sbx.commands.run('ls -la /app')State accumulates between calls exactly as it does in a terminal, because it is a terminal — one long-lived bash process per session, not a fresh subprocess per call:
await sbx.commands.run('cd /tmp && export TOKEN=abc')
await sbx.commands.run('pwd; echo $TOKEN') // "/tmp\nabc\n"stdout is everything the command printed, in the order it printed it. There is no separate stderr: the runtime interleaves the two for the life of the session, the way a terminal does, so a second stream would have to be invented rather than reported.
| Option | Description |
|---|---|
timeoutMs | How long the command may run. Defaults to the server's own 60 seconds. Overrunning throws CommandTimeoutError. |
throwOnExit | Throw CommandExitError on a non-zero exit. Defaults to true. |
signal | An AbortSignal to give up early. |
Throwing on a failed command is the default because the overwhelming majority of calls are "this must work", and a result object nobody checks makes that failure silent. When you do want to inspect it, ask:
const opts: RunOptions = { throwOnExit: false }
const { stdout, exitCode } = await sbx.commands.run('grep -q TODO src/*', opts)timeoutMs rather than letting it be killed.Files
await sbx.files.write('/app/config.json', JSON.stringify(config))
await sbx.files.write('/app/model.bin', bytes) // Uint8Array or ArrayBuffer
const text: string = await sbx.files.read('/app/out.csv')
const raw: Uint8Array = await sbx.files.readBytes('/app/out.bin')Paths are absolute container paths, and a relative one is rejected before the request leaves the process. It would otherwise resolve against wherever the shell has most recently cd'd to, which moves — the same string would mean different files at different times.
Writing creates parent directories and overwrites whatever was there. Listing, deleting and renaming are not here yet; use the shell for those until they are.
Configuration
Every entry point takes the same options, so a client pointed somewhere unusual does not have to be threaded through your code as a separate object.
| Option | Description |
|---|---|
apiKey | Defaults to SANDY_API_KEY. A bearer token with full access to the account — it belongs on a server, never in code that reaches a browser. |
url | Defaults to SANDY_URL, then https://api.sandy.computer. |
requestTimeoutMs | Deadline for a single HTTP request. Defaults to five minutes, deliberately above the server's own 120-second wait for a pod so a slow cold start surfaces as the server's error rather than ours. |
readyTimeoutMs | How long create and connect wait for the sandbox to become ready. Defaults to two minutes. |
session | connect only: join an existing shell instead of getting a new one. |
Errors
Everything the SDK throws descends from SandyError, so one instanceof catches everything this library produces and nothing it does not. The subclasses map onto the API's code field, and matching on a class rather than a status code is what lets the server change a status without breaking you.
| Error | When |
|---|---|
AuthenticationError | The API key is missing, malformed, revoked, or not ours. |
NotFoundError | No such sandbox, or no such file. |
SandboxGoneError | It was ours and is no longer there. Terminal — retrying does not bring it back. A subclass of NotFoundError, because the handling is nearly always the same. |
SandboxNotReadyError | The pod is not up and the server gave up waiting. Retrying is reasonable. |
SandboxStartTimeoutError | create waited and the sandbox never became ready. Carries sandboxId. |
CommandExitError | A command exited non-zero. Carries command, exitCode and stdout. |
CommandTimeoutError | A command outran its timeout, and this session's shell was killed to stop it. |
SessionLimitError | The sandbox is already holding as many shells as it is allowed to. |
InvalidRequestError | Rejected before anything ran — a relative file path, for instance. |
ConnectionError | We could not reach the API, or it could not reach the sandbox. |
A sandbox belonging to another account is indistinguishable from one that never existed: the API answers 404 rather than 403 on purpose, so an error cannot be used to discover which ids are real.
API reference
The CLI is a thin client over this API; anything it can do, you can do directly. Authenticate with your API key as a bearer token:
Authorization: Bearer sk-...Every request that runs a command also carries a session — the shell it runs in:
X-Sandy-Session: any-stable-string-you-chooseMint one per client and keep it for as long as that client lives. It is required on both exec endpoints: a request that names no session has not said which shell it means, and picking one for it is how a cd in one place ends up moving somebody else. It grants nothing and identifies nobody — your API key does that — so it can be any string you like, as long as two of your clients never choose the same one.
| Endpoint | Description |
|---|---|
POST /sandboxes | Create a sandbox. Every field of the body is optional: env, size, template and idle_timeout_seconds. Returns its id, status, size and idle timeout, and points the session at it. Answers 402 when the account has no credit left. |
GET /sandboxes | List every sandbox on the account that is still alive. |
POST /templates | Register a template. The body is the declaration itself — the bytes of a sandy.yaml, or the same document as JSON — so --data-binary @sandy.yaml is a complete client. A declaration whose from names a Dockerfile answers context_required; post it again as multipart/form-data with a declaration part and a gzipped-tar context part, and it answers 202 with the build under way. |
GET /templates | Every template on the account. |
GET /templates/{ref} | One template, by name or by hash prefix. An ambiguous prefix is an error rather than a guess. |
GET /templates/{ref}/build | What a build is doing, and what it has printed. Takes ?since=, the number of log lines already seen, and returns the rest with a next to send back. |
DELETE /templates/{ref} | Forget a template. Sandboxes created from it keep running, and a build already under way still finishes and is still billed. |
GET /sandboxes/{id} | Fetch current status: starting, running, destroyed or error. |
DELETE /sandboxes/{id} | Destroy the sandbox. |
GET /usage | What this account has spent — this week, this month, last month and all time, a week-by-week breakdown, the lifetime cost of every sandbox ever billed, and the credit remaining. |
POST /sandboxes/{id}/exec | Run a command in a named sandbox. Returns combined output and the exit code. |
POST /exec | Run a command wherever this session is already working. Same body, same response, no id to repeat. |
PUT /session | Point this session at a sandbox: { "box_id" }. What sandy use calls. |
GET /session | Which sandbox this session is working in. |
DELETE /sandboxes/{id}/session | End this session’s shell. The sandbox and its running processes are untouched. |
PUT /sandboxes/{id}/files/{path} | Upload a file to an absolute path in the sandbox. |
GET /sandboxes/{id}/files/{path} | Download a file from the sandbox. |
Creating a sandbox
POST /sandboxes
Authorization: Bearer $SANDY_API_KEY
{
"id": "sb-a1b2c3d4e5f60718",
"status": "starting"
}A body is optional, and the only thing it carries is env: variables for every shell in the sandbox, fixed for its life. Names beginning with SANDY_ belong to the sandbox runtime and are rejected with invalid_request.
POST /sandboxes
Authorization: Bearer $SANDY_API_KEY
Content-Type: application/json
{ "env": { "STAGE": "dev", "OPENAI_API_KEY": "sk-..." } }Anything running in the sandbox can read them — it is root in there. Sandy encrypts them at rest, which protects them from a database dump and from nothing else.
Executing a command
POST /sandboxes/sb-a1b2c3d4e5f60718/exec
Authorization: Bearer $SANDY_API_KEY
X-Sandy-Session: my-worker-1
Content-Type: application/json
{ "command": "cd /app && python train.py" }
{
"stdout": "epoch 4/4 loss 0.0132\n",
"exit_code": 0
}exit_code is the shell's, passed through untouched. A non-zero value means your command failed; a transport or auth problem shows up as an HTTP error status instead, so the two are never confused.
An optional timeout, in whole seconds, caps how long the command may run. It defaults to 60.
timeout rather than letting it be killed.A sandbox will hold up to 16 shells at once. Past that, /exec answers 429 session_limit — each one is a real bash process in a memory-limited pod, so the ceiling is real rather than a policy. Release the sessions you are finished with, or reuse one.
Errors
Every failing request answers with the same shape. Match on code — it is stable. message is for humans and may change.
{
"error": {
"code": "not_found",
"message": "sandbox not found"
}
}A sandbox that belongs to someone else answers 404, not 403: the reply must not confirm which ids exist.