# Upstash Box CLI

> 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.

- Skill: `upstash-skills/upstash-box-cli` (Agent Skill)
- Install (CLI): `npx skillmds add upstash-skills/upstash-box-cli`
- Raw SKILL.md: https://api.skillmd.com/api/skills/upstash-skills/upstash-box-cli/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: upstash (https://skillmd.com/u/upstash-skills)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/upstash-skills/upstash-box-cli

---


`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 <id>`, 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 <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:

```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 <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.

```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 <box-id>@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'
<!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`.

```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 <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.

```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 <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:

```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 <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

```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 <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:

```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.

