--- name: upstash-box-cli description: Drive an Upstash Box (a remote sandboxed workspace) from the terminal with the `box` CLI. Use when asked to run commands, edit files, clone repos, run builds or tests, publish a public URL, browse or screenshot a page, open a pull request or issue with a screenshot attached, schedule recurring work, run an AI agent, or do any work inside a box rather than on this machine. license: MIT metadata: author: Upstash homepage: https://upstash.com --- `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 ```bash 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. ```bash export UPSTASH_BOX_API_KEY=box_... ``` ## Selecting a box Resolution order is `--box `, then `$BOX_ID`, then the nearest `.box` file (searched upward). Create one and pin it to the working directory: ```bash 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 # 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: ```bash 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: ```bash 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: ```bash 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: ```bash 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. ```bash box snapshot # snapshot this box, prints the id box snapshot list box from-snapshot --no-repl # restore into a new box, pinned box snapshot delete ``` ## Running commands Put the remote command after `--`, or its flags are parsed as `box`'s own. ```bash 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: ```bash ssh @us-east-1.box.upstash.com ``` Commands run as `boxuser`, so a global npm install needs sudo, which is passwordless: ```bash 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: ```bash 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: ```bash 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. ```bash box create --no-repl --runtime node --keep-alive # writes .box; stays up for the URL box files write index.html - <<'HTML' Snake 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`. ```bash 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. ```bash 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: ```bash 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. ![what I saw](./shot.png)' \ --attach 'shot.png#the empty result list' ``` A `![alt](./shot.png)` 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: ```bash 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 ```bash box status runs # id, type, status, duration, cost box status logs --limit 50 box cancel # 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. ```bash 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: ```bash box browser recordings start --max-seconds 120 box browser recordings stop box browser recordings list box browser recordings get box browser recordings download -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 ` 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: ```bash 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. ```bash 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. ```bash box schedule exec --cron '0 9 * * *' -- npm run backup box schedule agent --cron '@daily' "summarise yesterday's errors" box schedule list box schedule get # includes run and failure counts box schedule pause box schedule resume box schedule update --cron '0 10 * * *' box schedule delete ``` `update` changes only what you name, so setting the cron leaves the command alone. ## Box configuration ```bash 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: ```bash 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`. ```bash 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 # shallow, single branch box git clone --github-token $TOKEN # 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 --timeout 300 --webhook-url "..." box schedule update --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: ```bash 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.