box operates on a remote container, not this machine. Your own file and shell
tools act locally; anything that must happen inside the box goes through box.
Install
npm i -g @upstash/box-cli
Authentication
Every command needs an API key, or it fails with "API token required". Set it once,
or pass --token on any single command. Create one at
https://console.upstash.com/box.
export UPSTASH_BOX_API_KEY=box_...
Selecting a box
Resolution order is --box <id>, then $BOX_ID, then the nearest .box file
(searched upward). Create one and pin it to the working directory:
box create --no-repl --runtime node # prints the id, writes .box
box create --no-repl --runtime node --clone-repo https://github.com/org/repo
box list # find an existing box
box use <box-id> # pin one to this directory
box status # id, where it came from, state
--keep-alive, --browser, --env and --size can only be chosen at create
time; to change any of them you make a new box. There is no resize.
Default to a plain box create --no-repl. A plain box pauses when it goes idle
and resumes on the next command, which is what almost all work wants:
box create --no-repl --browser # provision a headless Chromium
box create --no-repl --env KEY=VAL # env for this box (repeatable)
box create --no-repl --size medium # small (default), medium, large
Add --keep-alive only when something has to survive an idle gap: a detached
server you are about to reach over a preview URL, or a job that keeps running
between commands. It stops the box pausing, so the box keeps costing money until
you pause or delete it. --init-command is rejected without it:
box create --no-repl --keep-alive # stays up when idle
box create --no-repl --keep-alive --init-command "npm ci" # startup script
The rest of the create-time options, all equally unchangeable afterwards:
box create --no-repl --skill upstash/skills/redis # repeatable
box create --no-repl --mcp docs=@org/mcp-server # or name=https://url
box create --no-repl --mcp-file servers.json # for args and headers
box create --no-repl --network-policy deny-all # or allow-all, or custom
box create --no-repl --network-policy custom --allow-domain api.example.com
box create --no-repl --attach-headers-file headers.json
--attach-headers-file holds a JSON object keyed by host pattern
({"api.stripe.com": {"Authorization": "Bearer ..."}}), and those headers are
injected into matching outbound requests from the box. There is an
--attach-header host:Name=value form too, but the value lands in ps and in
shell history, so prefer the file for anything secret.
A skill id has three parts, owner/repo/skill-name. A malformed one is only
warned about server-side, so the box comes up with the skill silently absent.
--env is per-box. box env set is account-level: it is merged into every box
created afterwards, never into one that already exists. A per-box --env wins
for the same key, so account-level values only fill in what the box did not set.
Account-level skills and MCP servers are merged the same way.
paused is not an error; the next command resumes the box.
A .box file is found by walking up from the working directory, so cd-ing
into another project can silently pick up a pin left there earlier and run
against the wrong box. In any session touching more than one box, pass --box
explicitly; box status says which box it resolved and where that came from.
Clean up when the work is done. Boxes cost money while they exist:
box pause # keeps the workspace, resumes on the next command
box resume # rarely needed; any command resumes a paused box
box delete --yes # irreversible; --yes is required without a terminal
Never run box create or box connect without --no-repl: they open an
interactive REPL and will hang. box from-snapshot takes --no-repl too.
box snapshot # snapshot this box, prints the id
box snapshot list
box from-snapshot <snapshot-id> --no-repl # restore into a new box, pinned
box snapshot delete <snapshot-id>
Running commands
Put the remote command after --, or its flags are parsed as box's own.
box exec -- npm install
box exec -C repo -- npm test
box exec --json -- node -e 'console.log(1)' # {stdout, stderr, exit_code}
The remote shell is sh, not bash. A heredoc inside box exec fails with
Syntax error: redirection unexpected; write the file with box files write -
instead, or wrap the command in bash -c when the box has bash.
For an interactive shell, ssh straight in. The box id is the user and the Box API key is the password:
ssh <box-id>@us-east-1.box.upstash.com
Commands run as boxuser, so a global npm install needs sudo, which is
passwordless:
box exec -- 'sudo npm install -g @upstash/docs7' # EACCES without sudo
One argument is a shell expression, sent as written, so pipes and redirection work. Several arguments are argv and are quoted individually, so an argument containing spaces stays one argument.
The remote command's exit code is passed through, so box exec -- npm test && ...
chains normally. Exit code 125 means the CLI itself failed (bad box, bad flags),
never a status the remote command returned.
A background server dies with the command that started it. Detach it:
box exec -- '( npm run dev > dev.log 2>&1 & )'
box public-url 3000 # prints the public URL
box public-url list
box public-url delete 3000
Inline code, when a shell one-liner would be worse than a program:
box code - --lang python < script.py
box code 'console.log(1 + 1)' --lang js
Building something and handing back a link
"Make me a snake game, use Upstash Box" is a request to build it in a box, run it there, and reply with a URL the user can open. Do the whole thing; do not stop at writing the file.
box create --no-repl --runtime node --keep-alive # writes .box; stays up for the URL
box files write index.html - <<'HTML'
<!doctype html><meta charset="utf-8"><title>Snake</title>
<canvas id="c" width="400" height="400"></canvas>
<script>/* the game */</script>
HTML
box files write server.js - <<'JS'
const http = require("http"), fs = require("fs");
http.createServer((_, res) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(fs.readFileSync("index.html"));
}).listen(3000, "0.0.0.0"); // the default binds ::, which the check below misses
JS
box exec -- '( node server.js > server.log 2>&1 & )' # detached, or it dies
box exec -- 'sleep 1; ss -ltn | grep -q "0.0.0.0:3000" && echo up'
box public-url 3000 # the link to reply with
Node's own http module rather than a package: no install, no network fetch, and it
works on a bare node runtime.
Check the port before publishing it, and check what it is bound to, not just
that it answers. A server on 127.0.0.1 replies to a curl from inside the box
and still cannot be published: the proxy reaches the container by address, so
box public-url returns 502. That is why the check above greps for 0.0.0.0
rather than curling localhost, which passes in exactly the case that fails.
Most dev servers need telling: --host 0.0.0.0 for Vite and many others,
-H 0.0.0.0 for some, and a few cannot be moved off loopback at all.
--keep-alive is what keeps the link working. Without it the box pauses when
idle, the detached server dies with it, and the URL you handed over starts
answering errors some minutes later.
Reply with the URL itself, not just "it is running". Say that the box keeps costing
money until box delete --yes, and that the URL is public to anyone who has it —
box public-url 3000 --basic-auth puts credentials in front of it.
Files
Paths are relative to /workspace/home.
box files list src
box files read src/index.ts
box files write src/app.ts - < local.ts # - reads stdin: use this for code
box files write notes.txt "short text"
box files stat src/index.ts
box files mkdir -p a/b/c
box files rename old.ts new.ts
box files remove build -r # a directory needs -r
box files upload ./local.zip /workspace/home/local.zip
box files download repo # a folder lands in ./repo
box files download logs/app.log -o ./app.log # a file; -o names the destination
Write code with - and stdin. Passing source as an argument mangles it in the shell.
To search, use the box's own tools: box exec -- grep -rn TODO src.
Git
A clone lands in a directory named after the repo, and every git verb except clone
needs that directory via -C. Without it git runs at the workspace root, which is not
a repository.
box git clone https://github.com/org/repo
box git clone https://github.com/org/repo -C my-app # -C is the destination here
box git status -C repo
box git diff -C repo
box git config -C repo --name "Bot" --email bot@example.com
box git checkout -C repo feature/x # creates the branch if missing
box git exec -C repo -- add -A
box git commit -C repo -m "message"
box git push -C repo # pushes the checked-out branch
box git create-pr -C repo --title "Fix the thing" --base main
box git create-pr -C repo --title "Fix the thing" --body-file notes.md
box git create-issue -C repo --title "Search returns nothing"
Use --body-file for anything longer than a sentence: a body worth writing does
not survive shell quoting. - reads stdin.
box git exec takes git's arguments without the leading git, and passes git's exit
code through.
Private repos and PRs need a token at creation: box create --no-repl --git-token $GITHUB_TOKEN.
Attaching a screenshot to a pull request or issue
--attach uploads an image or video to the new pull request or issue, and repeats
for several. Alt text for an image goes after a #. A video renders as a player and
takes no alt text.
The path is read inside the box, relative to -C. A browser screenshot is written to
the machine running the CLI, not into the box, so it has to be uploaded first. That
upload is the step people miss:
box browser screenshot -o /tmp/shot.png # lands here, not in the box
box files upload /tmp/shot.png repo/shot.png # now it is in the repository
box git create-issue -C repo \
--title "Search returns nothing" \
--body 'Reproduced on staging.
' \
--attach 'shot.png#the empty result list'
A  reference in the body is rewritten to point at the uploaded
asset, so the image renders in the issue instead of pointing at a path that exists
only inside the box.
Four rules are enforced, each a 400 before anything is created: the extension must be
png, jpg, jpeg, gif, webp, mp4, mov or webm; at most 50 files; the path must stay
inside the -C directory; and a video cannot carry alt text.
When some attachments upload and others fail, the item is still created and its URL
is still returned, with a warning alongside it. Text output prints the warning on
its own line, and --json carries it as the warning field. Check it before
reporting the issue as filed with its evidence attached.
Agent
If the box was created with an agent, hand it a task:
box create --no-repl --agent-harness claude-code --agent-model anthropic/claude-sonnet-5
box run "Fix the failing test in src/auth.test.ts"
box run - < prompt.txt
Text goes to stdout, tool calls to stderr. Prefer doing the work yourself with the
commands above; box run is for delegating a whole task to the box's own agent.
Watching and stopping work
box status runs # id, type, status, duration, cost
box status logs --limit 50
box cancel <run-id> # ids come from status runs
A run started by another process cannot be stopped any other way: box cancel
takes the id, so a long agent run or build is interruptible from a fresh shell.
Browser
Only on a box created with --browser. Chromium runs inside the box, so it
reaches your app on http://localhost:3000 with no public URL involved. What
lives outside is only the control path: these commands reach Chromium through
the API, so there is no box exec spelling of them. A script running in the box
can still talk to Chromium directly over CDP, which is the escape hatch at the
end of this section.
box browser open https://example.com # prints the tab id
box browser tabs
box browser content # title, url, text, links
box browser screenshot -o page.png
box browser goto https://example.com/login
box browser act "click the login button"
box browser close
box browser cdp-url # drive it with Playwright instead
box browser observe "what can I click here?"
box browser live-url # a URL for a human to watch the tab
Every box browser act is metered: it takes an instruction in words and needs
a model to read the page. The SDK can replay an observe() result for free,
but the CLI takes only the string form, so a loop of act calls costs a model
call each time. content, goto, screenshot and close are not metered.
Recordings, when you need to show what happened rather than describe it:
box browser recordings start --max-seconds 120
box browser recordings stop
box browser recordings list
box browser recordings get <recording-id>
box browser recordings download <recording-id> -o session.mp4
Chromium starts on first use, so the very first box browser open is slower
than the rest, and anything talking to CDP directly fails until it has run once.
--tab <id> is optional while one tab is open and required once there are
several. screenshot writes to a file because stdout carries text, and that file
lands on this machine rather than in the box. To put a screenshot on a pull request
or issue, see "Attaching a screenshot to a pull request or issue".
Pull structured data off the page with a flat JSON Schema file:
echo '{"type":"object","properties":{"price":{"type":"string"}},"required":["price"]}' > s.json
box browser extract "the listed price" --schema s.json
A property not named in required is optional. Nested objects are refused.
Capturing straight into the box
box browser screenshot --full-page -o page.png is the short way, and it is
enough whenever the image can live on this machine. It writes to the machine
running the CLI, though, so getting the image into the box costs an upload.
Chromium's CDP is open on 127.0.0.1:9222 from inside the box with no
token, so a script running there captures and writes in one step, and can clip
to a single element, which the CLI does not expose:
Write the script with box files write rather than inlining it: the remote
shell is sh, and quoting a program through box exec is where this goes
wrong.
box files write shot.mjs - <<'JS'
const targets = await (await fetch("http://127.0.0.1:9222/json")).json();
const page = targets.find((t) => t.type === "page");
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const pending = new Map();
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
pending.get(msg.id)?.(msg.result);
pending.delete(msg.id);
};
const send = (method, params = {}) =>
new Promise((resolve) => {
const callId = ++id;
pending.set(callId, resolve);
ws.send(JSON.stringify({ id: callId, method, params }));
});
// captureBeyondViewport only permits capture outside the viewport; the clip is
// what makes it the whole page. cssContentSize is in CSS pixels, which is what
// clip expects.
const metrics = await send("Page.getLayoutMetrics");
const size = metrics.cssContentSize ?? metrics.contentSize;
const { data } = await send("Page.captureScreenshot", {
format: "png",
captureBeyondViewport: true,
clip: { x: 0, y: 0, width: size.width, height: size.height, scale: 1 },
});
const fs = await import("node:fs");
fs.writeFileSync("shot.png", Buffer.from(data, "base64"));
ws.close();
JS
box exec -- 'node shot.mjs' # shot.png is now in the box
The clip is what makes this a full-page capture rather than a viewport one;
--full-page does the same thing. An element-clipped capture is the same call
with that element's box as the clip, and that one has no CLI flag. Node's global
fetch and WebSocket are enough, so nothing has to be installed, but Chromium
must have been started once by a box browser command first.
This is only worth it when the image should stay in the box or you need a
capture the CLI cannot make. Otherwise screenshot -o then files upload is
shorter.
Schedules
Cron on the box, in UTC. Nothing inside the container can register one.
box schedule exec --cron '0 9 * * *' -- npm run backup
box schedule agent --cron '@daily' "summarise yesterday's errors"
box schedule list
box schedule get <schedule-id> # includes run and failure counts
box schedule pause <schedule-id>
box schedule resume <schedule-id>
box schedule update <schedule-id> --cron '0 10 * * *'
box schedule delete <schedule-id>
update changes only what you name, so setting the cron leaves the command alone.
Box configuration
box skills add upstash-redis-js # skills available to the box's agent
box skills list
box skills remove upstash-redis-js
box config model anthropic/claude-sonnet-5
box config init-command set "npm ci" # keep-alive boxes only; runs on start
box config init-command get
box config init-command delete
box config network deny-all # or allow-all, or custom
box config network custom --allow-domain api.example.com
box config harness --command my-agent # a custom agent harness
Account-level settings, which apply to boxes you create later rather than to this one:
box env set KEY VAL # applies to boxes created after this
box env list
box env delete KEY
box env set-all A=1 B=2 # replaces every var, does not merge
box labels add staging # then: box list --label staging
box labels list
box labels remove staging
Both box env set and box create --env take the value as an argument, so a
secret passed either way is visible in ps and lands in shell history. Neither
is a secrets mechanism; keep real credentials out of both and use a token the
box fetches for itself.
Flag reference
The flags the walkthroughs above do not reach. Every command also takes the
global --box, --json and --token.
box create --no-repl --git-user-name N --git-user-email E # commit identity
box create --no-repl --agent-api-key stored # key saved in the console
box create --no-repl --no-use # do not write .box
box init-demo --directory my-demo # scaffold elsewhere
box exec -C /srv/app -- npm test # -C/--cwd: working directory
box run --timeout 600 -q "..." # -q/--quiet: no tool-call logs on stderr
box code --timeout 120 "..." # both take --timeout in seconds
box files read --offset 0 --length 65536 big.log # a slice; 8 MiB per read
box files read --encoding base64 logo.png # binary out
box files write --encoding base64 logo.png - # binary in
box files remove -r build/ # -r required for a directory
box files mkdir -p a/b/c # -p creates missing parents
box files stat --follow link # resolve a final symlink
box git clone --branch main --depth 1 <url> # shallow, single branch
box git clone --github-token $TOKEN <url> # private repository
box git commit -m "msg" --author-name N --author-email E
box git push --branch feature/x # names the branch to push
box public-url 3000 --bearer-token # or --basic-auth; both generate credentials
box use --unset # drop this directory's .box, never a parent's
box schedule agent --cron "0 9 * * *" --model <m> --timeout 300 --webhook-url <url> "..."
box schedule update <id> --timeout 0 # 0 clears the timeout; --prompt, --cron, --model too
box config network custom --allow-domain a.test --allow-cidr 10.0.0.0/8 --deny-cidr 10.1.0.0/16
box config harness --command my-agent --arg --verbose # --arg repeatable, sent before the prompt
-C means the working directory on box exec (--cwd) and the repository
directory on every box git and box schedule subcommand (--folder).
Output
Data goes to stdout, diagnostics to stderr, so piping is safe. --json prints the
result as JSON with no wrapper, on every command that returns data. The ones that
open a REPL or print a shell script (connect, init-demo, completion)
reject it rather than answering an automation caller with a prompt:
box files list --json | jq -r '.[].name'
box get "$(cat .box)" --json
box init-demo and box completion exist but are for people, not agents: one
scaffolds a local demo project, the other prints a shell completion script.