@upstash/upstash-box-py
Work with any Upstash product, SDK, or tool, including serverless Redis (caching, sessions, leaderboards, key-value storage), Ratelimit (rate limiting and throttling), QStash (message queue, cron schedules, background jobs), Workflow (durable long-running functions), Vector (vector database for embeddings, semantic search, and RAG), Search (full-text and semantic search), Box (sandboxed cloud containers for AI agents, TypeScript/JavaScript and Python), Blob (serverless S3-compatible file storage with direct browser uploads), the Upstash CLI, and no-signup scratch Redis for agents. Use when the user mentions Upstash or needs any of these capabilities in a serverless, edge, or Node.js app.
| name | upstash-box-py |
| description | Work with the upstash-box Python SDK for sandboxed cloud containers with AI agents, shell, filesystem, git, cron schedules, snapshots, and a headless browser. Use when building with Upstash Box in Python, creating a sandbox or isolated environment to run untrusted or agent-generated code, running AI coding agents in containers, giving an agent a cloud dev environment with a shell and repository, browser automation from a box, scheduling recurring jobs inside a box, saving and restoring snapshots, or orchestrating parallel boxes. |
| license | MIT |
upstash-box Python SDK
Sandboxed cloud containers with built-in AI agents, shell, filesystem, git, cron schedules, and an optional headless browser.
Mirrors the @upstash/box TypeScript SDK (upstash-box-js skill) with
snake_case names; the intentional differences are listed under Gotchas.
Install & Setup
pip install upstash-box
Set UPSTASH_BOX_API_KEY env var or pass api_key to constructors.
The SDK ships both a synchronous Box (used in the examples below) and an
asynchronous AsyncBox (box = await AsyncBox.create(...), await box.agent.run(...)).
The async surface is identical with await and async for.
Anonymous telemetry headers are sent with every request; opt out with the
UPSTASH_DISABLE_TELEMETRY env var.
Box Lifecycle
import os
from upstash_box import Box, Agent, ClaudeCode, BoxApiKey
# Create with agent + git + env vars
box = Box.create(
name="my-box",
runtime="node", # "node" | "python" | "golang" | "ruby" | "rust" (+ "-alpine" variants)
size="small", # "small" (2 CPU/4GB) | "medium" (4/8) | "large" (8/16)
labels=["beta", "x-team"], # max 5, <=20 chars each
keep_alive=True, # don't idle-pause the box
init_command="npm install && npm run dev", # keep-alive boxes only
browser=True, # provision headless Chromium for box.browser
agent={
"harness": Agent.CLAUDE_CODE, # Agent.CODEX | Agent.OPEN_CODE | Agent.CURSOR | Agent.CUSTOM
"model": ClaudeCode.SONNET_4_5, # or a plain string "anthropic/claude-sonnet-4-5"
# api_key options:
# omit → server decides which key to use
# BoxApiKey.UPSTASH_KEY → use Upstash-provided LLM key
# BoxApiKey.STORED_KEY → use key previously stored via Upstash Console
# "sk-..." → direct API key string
"api_key": BoxApiKey.UPSTASH_KEY,
},
git={ # all fields optional
"token": os.environ["GITHUB_TOKEN"], # or link your GitHub account via Upstash Console
"user_name": "Bot",
"user_email": "[email protected]",
},
env={"DATABASE_URL": "..."},
skills=["upstash/qstash-js/qstash-js"], # owner/repo/skill-name
timeout=600_000, # request timeout in ms
debug=False,
)
# Reconnect, list, delete, pause/resume
# Box.get / Box.get_by_name take api_key, base_url, git_token, timeout, debug
same = Box.get(box.id, git_token="ghp_...") # git_token, not git={...}, when reconnecting
by_name = Box.get_by_name("my-box")
all_boxes = Box.list()
beta = Box.list(label="beta") # filter by label
box.pause() # raises on keep-alive boxes — they are never idle-paused
box.resume()
box.delete() # irreversible
status = box.get_status()["status"]
box.id, box.size, box.keep_alive, box.cwd, box.network_policy
# Init command (keep-alive boxes only — raises otherwise)
box.set_init_command("npm run dev")
script = box.get_init_command()
box.delete_init_command()
# Bulk delete (classmethods, by ID)
Box.delete_boxes(box_ids=["box_1", "box_2"]) # JS static `delete` is `delete_boxes` here
Box.delete_snapshots(snapshot_ids=["snap_1"]) # omit ids → delete all
Account-level env vars
Injected into every box you create.
Box.set_env("API_TOKEN", "secret")
env = Box.list_env() # values are masked
Box.set_all_env({"A": "1", "B": "2"}) # full replace — unlisted keys are removed
Box.delete_env("API_TOKEN")
Agent Runs
from pydantic import BaseModel
# Structured output with a Pydantic model (or a raw JSON-schema dict)
class Finding(BaseModel):
severity: str # "high" | "medium" | "low"
file: str
issue: str
class Review(BaseModel):
verdict: str # "approved" | "changes_requested"
findings: list[Finding]
run = box.agent.run(
prompt="Review the code for security issues",
response_schema=Review,
timeout=120_000,
max_retries=2,
options={"max_turns": 20, "max_budget_usd": 1.0, "effort": "high"}, # harness-specific
on_tool_use=lambda tool: print(tool["name"], tool["input"]),
on_tool_result=lambda result: print(result["tool_call_id"], result["output"]),
)
run.status # "running" | "completed" | "failed" | "cancelled" | "detached"
run.result # typed from schema (a Review instance)
run.cost # RunCost(input_tokens, output_tokens, cached_input_tokens, compute_ms, total_usd)
# Attach files to a prompt (max 10 files, 10 MB each)
box.agent.run(prompt="Describe this", files=["./screenshot.png"])
box.agent.run(
prompt="Describe this",
files=[{"data": b64, "media_type": "image/png", "filename": "shot.png"}],
)
# Streaming — chunks are typed dataclasses discriminated on `.type`
stream = box.agent.stream(prompt="Build a REST API")
for chunk in stream:
if chunk.type == "text-delta":
print(chunk.text, end="")
elif chunk.type == "reasoning":
print(chunk.text, end="")
elif chunk.type == "tool-call":
print(chunk.tool_name, chunk.input)
elif chunk.type == "tool-result":
print(chunk.output)
elif chunk.type == "finish":
print(chunk.usage.input_tokens, chunk.usage.cached_input_tokens, chunk.session_id)
# also: StartChunk(run_id) | StatsChunk(cpu_ns, memory_peak_bytes) | UnknownChunk(event, data)
# FinishChunk also carries .output (the final text)
# stream() takes the same prompt/files/options/timeout/on_tool_use/on_tool_result as run().
# It has no response_schema, max_retries, or webhook — use run() for those.
# Fire-and-forget with webhook
box.agent.run(
prompt="Run tests",
webhook={"url": "https://example.com/hook", "headers": {"Authorization": "Bearer ..."}},
)
Agent options (per harness)
options is forwarded to the harness — the accepted keys depend on which one
the box runs. Keys are snake_case in Python; the SDK converts top-level keys
to each harness's backend casing (Claude Code / OpenCode → camelCase, Codex →
snake_case). Keys inside nested dicts are sent verbatim.
# Agent.CLAUDE_CODE → ClaudeCodeAgentOptions
{
"max_turns": 20,
"max_budget_usd": 1.0,
"effort": "high", # "low" | "medium" | "high" | "max"
# nested dicts are forwarded verbatim — keep `budgetTokens` camelCase here
"thinking": {"type": "adaptive"}, # or {"type": "enabled", "budgetTokens": 8000} / {"type": "disabled"}
"disallowed_tools": ["Bash"],
"agents": {"reviewer": {...}}, # custom subagent definitions
"prompt_suggestions": False,
"fallback_model": "anthropic/claude-sonnet-4-5",
"system_prompt": "You are a release engineer.",
}
# Agent.CODEX → CodexAgentOptions
{
"model_reasoning_effort": "high", # "none" | "minimal" | "low" | "medium" | "high" | "xhigh"
"model_reasoning_summary": "concise", # "auto" | "concise" | "detailed" | "none"
"personality": "pragmatic", # "friendly" | "pragmatic" | "none"
"web_search": "live", # or True / False
}
# Agent.OPEN_CODE → OpenCodeAgentOptions
{
"reasoning_effort": "high", # "low" | "medium" | "high"
"text_verbosity": "low", # "low" | "medium" | "high"
"reasoning_summary": "auto", # "auto" | "concise" | "detailed" | "none"
"thinking": {"type": "enabled", "budgetTokens": 8000}, # Anthropic-backed models
}
# Agent.CURSOR → free-form dict
Unlike the JS generic AgentOptions<TProvider>, Python does not narrow
options by harness — the type is the union of all shapes plus a raw dict.
Harness & model
harness is required. Model enums: ClaudeCode, OpenAICodex, OpenCodeModel,
CursorModel, OpenRouterModel, VercelModel — or any provider-prefixed string.
from upstash_box import ClaudeCode, OpenAICodex, OpenCodeModel, CursorModel, OpenRouterModel, VercelModel
ClaudeCode.FABLE_5_1 # "anthropic/claude-fable-5-1"
ClaudeCode.OPUS_5 # "anthropic/claude-opus-5"
ClaudeCode.SONNET_5 # "anthropic/claude-sonnet-5"
OpenAICodex.GPT_6_ASTRA # "openai/gpt-6-astra"
OpenAICodex.GPT_5_6 # "openai/gpt-5.6"
OpenCodeModel.CLAUDE_OPUS_5 # "opencode/claude-opus-5"
CursorModel.COMPOSER_2_5 # "cursor/composer-2.5"
OpenRouterModel.CLAUDE_OPUS_5 # "openrouter/anthropic/claude-opus-5"
VercelModel.GPT_5_5 # "vercel/openai/gpt-5.5"
# Read / change the box's harness + model at runtime
box.model_config # {"harness": ..., "model": ...}
box.configure_model("anthropic/claude-opus-4-8")
# Which harness a bare model string implies (prefix-based)
from upstash_box import infer_default_provider
infer_default_provider("openai/gpt-5.6") # Agent.CODEX
infer_default_provider("cursor/default") # Agent.CURSOR
Custom harness
Run your own agent process inside the box instead of a managed harness.
import asyncio
from upstash_box import Agent, Box, CustomHarnessDone, run_custom_harness
box = Box.create(
agent={
"harness": Agent.CUSTOM,
"model": "my-agent", # label forwarded to the process
# command: name on PATH, or an absolute path under /workspace/home or /home/boxuser
"custom_harness": {
"command": "python",
"args": ["/workspace/home/agent.py"],
"protocol": "box-sse-v1", # default
},
},
)
box.configure_custom_harness({"command": "python", "args": ["/workspace/home/agent2.py"]})
# Inside the box, agent.py emits box-sse-v1 events. The backend appends
# `-p <prompt> --model <model> --stream` (+ `--session <id>` when resuming).
# ctx: CustomHarnessContext(prompt, model, stream, args, session_id)
async def handler(ctx, emit):
emit.text("working...")
emit.reasoning("thinking out loud") # -> `thinking` event
emit.tool({"tool_call_id": "1", "name": "Bash", "input": {"command": "ls"}})
emit.tool_result({"tool_call_id": "1", "output": "file.txt"})
emit.emit("custom-event", {"any": "payload"}) # raw escape hatch
# emit.error("boom") to fail the run
return CustomHarnessDone(
output="done",
input_tokens=10,
output_tokens=5,
cached_input_tokens=0,
total_cost_usd=0.01,
session_id=ctx.session_id,
) # returning a plain string is shorthand for CustomHarnessDone(output=...)
asyncio.run(run_custom_harness(handler)) # run_custom_harness is async; handler may be sync or async
Run Fields
Every run (agent, command, or code) returns a Run:
run = box.exec.command("npm test")
run.id # run ID
run.status # "completed" | "failed" | ...
run.result # stdout on success, stderr on failure (or typed result with response_schema)
run.stdout # raw stdout (command/code runs)
run.stderr # raw stderr (command/code runs)
run.exit_code # int | None (None for agent runs)
run.cost # RunCost(input_tokens, output_tokens, cached_input_tokens, compute_ms, total_usd)
run.cancel() # cancel a running run
logs = run.logs() # [RunLog(timestamp, level, message)]
# Box-level history
entries = box.logs(limit=100, offset=0) # [LogEntry(timestamp, level, source, message)]
runs = box.list_runs() # backend run records, newest first
Shell Execution
# Run commands
run = box.exec.command("echo hello && ls -la")
# Run code snippets — lang: "js" | "ts" | "python"
run2 = box.exec.code(code="print(1 + 1)", lang="python", timeout=10_000)
# Streaming shell / code
stream = box.exec.stream("npm run build")
stream2 = box.exec.stream_code(code="print('hi')", lang="python")
for chunk in stream:
# chunk: ExecOutputChunk(type="output", data) | ExecExitChunk(type="exit", exit_code, cpu_ns)
...
Live sessions
exec.session() opens a WebSocket to a process that is still running — stdin,
streamed stdout/stderr, PTY resize, and signals. It is the one feature not carried
by httpx; the websockets dependency is imported lazily, only when a session
opens. Available on Box, AsyncBox, and both ephemeral clients.
session = box.exec.session(
cmd="sort", # run via `bash -lc`; argv=["sort"] runs the program with
# no shell and takes precedence over cmd
cwd="/workspace/home", # defaults to the box's tracked cwd
env=["LOG_LEVEL=debug"], # KEY=VALUE entries overlaid on the box environment
on_stdout=lambda b: print(b.decode(), end=""), # bytes
on_stderr=lambda b: print(b.decode(), end=""), # separate stream unless tty
)
session.pid # in-box PID, always non-zero
session.exec_id # server-side exec id
session.write("banana\napple\n")
session.end_stdin() # EOF — a command that reads to EOF now exits by itself
exit_code = session.wait() # -1 if torn down while still running; wait(timeout=5) raises TimeoutError
session.close() # hang up; also kills the process
# Interactive programs / TUIs — tty allocates a real PTY, merging stderr into stdout
with box.exec.session(argv=["bash", "-i"], tty=True, rows=40, cols=120) as shell:
shell.resize(50, 160)
shell.kill("INT") # allowlist: TERM KILL INT HUP TSTP QUIT USR1 USR2 (default TERM)
shell.terminate(5000) # server-side SIGTERM, then SIGKILL after the grace (first call wins)
The session owns the process: close() (or leaving the with block), a dropped
connection, or the program exiting all kill the command, and a session cannot be
reattached. Use wait() to run something to completion.
On AsyncBox every handle method is a coroutine (await session.write(...),
await session.wait(), async with await box.exec.session(...) as s:) and an
async callback is awaited; wait() there takes no timeout. In the sync client
the callbacks run on a background reader
thread — keep them short, and never call wait() from inside one, since the exit
frame it waits for arrives on the very thread it is blocking.
Filesystem
box.files.write(path="/workspace/home/app.py", content="print('hi')")
content = box.files.read("/workspace/home/app.py")
entries = box.files.list("/workspace/home") # [FileEntry(name, path, size, is_dir, mod_time)]
# Binary files — use encoding="base64" for read and write
box.files.write(path="/workspace/home/image.png", content=base64_string, encoding="base64")
b64 = box.files.read("/workspace/home/image.png", encoding="base64")
# Bounded byte-range read — the *presence* of `length` selects the range, so
# length=0 reads zero bytes rather than the whole file. Server caps it at 8 MiB.
head = box.files.read("/workspace/home/big.log", length=64 * 1024)
chunk = box.files.read("/workspace/home/big.log", offset=1024, length=512)
# Metadata — defaults to lstat, so a symlink reports type "symlink"
stat = box.files.stat("/workspace/home/app.py")
# stat: FileStat(type="file" | "directory" | "symlink" | "other", size, mod_time, inode, version)
# `version` is an opaque freshness token (inode + mtime + size) for optimistic-concurrency
# guards — compare it for equality, never parse it.
target = box.files.stat("/workspace/home/link", follow=True) # dereference
# Directories, moves, deletes
box.files.mkdir("build/cache", parents=True) # parents mirrors `mkdir -p`
box.files.rename("draft.md", "docs/final.md") # positional (from_path, to_path)
box.files.remove("build/cache", recursive=True) # recursive required for a directory
# Upload local files
box.files.upload([{"path": "./local/file.txt", "destination": "/workspace/home/file.txt"}])
# Download — `folder` is a path INSIDE the box; files land in ./<basename>
box.files.download(folder="src") # → ./src
box.files.download() # whole cwd → ./workspace
cd / Working Directory
The SDK tracks cwd client-side. All operations (exec, files, git, agent) run relative to it.
box.cwd # current working directory (starts at /workspace/home)
box.cd("my-repo") # relative to current cwd
box.cd("/workspace/home/other") # absolute path
Git
Clones land inside the box's isolated container, never on the caller's machine. Cloned
code is data until something runs it — treat an untrusted repo as untrusted input, and
pair it with a restrictive network_policy (see below) before running its build or tests.
Every git call except clone runs in the box's current directory, so cd into the
clone first. At the workspace root there is no repository, and status comes back
empty, which reads as a clean tree.
box.git.clone(repo="github.com/org/repo", branch="main")
box.git.clone(repo="github.com/org/repo", depth=1) # shallow clone
box.git.clone(repo="github.com/org/repo", folder="my-app") # destination
box.cd("repo") # the clone lands in a directory named after the repo
status = box.git.status()
diff = box.git.diff()
result = box.git.commit( # GitCommitResult(sha, message)
message="fix: resolve bug",
author_name="Jane Doe", # optional per-commit override
author_email="[email protected]",
)
box.git.push(branch="feature/fix")
box.git.checkout(branch="release/v2")
pr = box.git.create_pr(title="Fix bug", body="...", base="main")
# pr: PullRequest(url, number, title, base)
# Update the box-wide git identity
cfg = box.git.update_config(user_name="Bot", user_email="[email protected]")
# cfg: GitConfigResult(git_user_name, git_user_email)
# Arbitrary git commands — returns the output string only. Unlike the JS SDK, which
# returns { output, exit_code }, Python drops the status, so a failure (exit 128 when
# the cwd is not a repository) is indistinguishable from success — check the output.
output = box.git.exec(args=["log", "--oneline", "-5"])
Schedules
Cron tasks on a box — shell commands or agent prompts. Available on Box and EphemeralBox. Cron is UTC.
exec_schedule = box.schedule.exec(
cron="* * * * *",
command=["bash", "-c", "date >> /workspace/home/cron.log"],
folder="/workspace/home", # optional cwd override
webhook_url="https://example.com/hook",
webhook_headers={"Authorization": "Bearer ..."},
)
agent_schedule = box.schedule.agent(
cron="0 9 * * *",
prompt="Run the test suite and fix any failures",
folder="/workspace/home/repo", # optional cwd override
model="anthropic/claude-sonnet-5", # optional override
options={"max_budget_usd": 1.0, "effort": "high"},
timeout=300_000,
webhook_url="https://example.com/hook",
webhook_headers={"Authorization": "Bearer ..."},
)
schedules = box.schedule.list()
one = box.schedule.get(agent_schedule.id)
# Partial update — omitted args keep their value, "" / [] / {} clear a field,
# options=None clears agent options. The schedule's type cannot change.
# Updatable: cron, command, prompt, folder, model, options, timeout,
# webhook_url, webhook_headers
box.schedule.update(agent_schedule.id, cron="0 18 * * *", webhook_url="")
box.schedule.pause(agent_schedule.id)
box.schedule.resume(agent_schedule.id)
box.schedule.delete(agent_schedule.id)
Snapshots
# Snapshot — checkpoint workspace state
snap = box.snapshot(name="after-setup")
# snap: Snapshot(id, name, box_id, size_bytes, status, created_at)
# from_snapshot takes the same BoxConfig kwargs as create (shared request body):
# name, labels, size, keep_alive, init_command, runtime, browser, agent, git, env,
# attach_headers, network_policy, skills, mcp_servers. Note the JS SDK's
# Box.fromSnapshot() drops browser / skills / mcpServers — Python forwards them.
restored = Box.from_snapshot(
snap.id,
size="medium",
keep_alive=True,
# the git identity is forwarded, not just the token
git={"token": os.environ["GITHUB_TOKEN"], "user_name": "Bot", "user_email": "[email protected]"},
env={"DATABASE_URL": "..."},
)
snaps = box.list_snapshots()
box.delete_snapshot(snap.id)
Browser
Create the box with browser=True to drive a headless Chromium. Tab management
lives on box.browser; every page operation lives on the tab handle.
extract / observe / act(instruction) are AI-powered and metered;
act(action) replays an already-resolved action with no LLM call and no tokens.
from pydantic import BaseModel
box = Box.create(browser=True, agent={"harness": Agent.CLAUDE_CODE, "model": ClaudeCode.SONNET_4_5})
# Tabs
tab = box.browser.tab.create("https://example.com", wait_until="load", timeout=30_000)
tabs = box.browser.list_tabs()
again = box.browser.get_tab(tab.id) # no network call
tab.id, tab.url, tab.title # handle metadata, no network call
# Page operations
content = tab.goto("https://news.ycombinator.com") # BrowserContent(title, url, text, links)
current = tab.content()
png = tab.screenshot() # bytes
b64 = tab.screenshot(encoding="base64", full_page=True)
# AI operations (metered) — schema is a Pydantic model or a raw JSON-schema dict.
# extract / observe / act take an optional model= override, defaulting to the box's
# model (or anthropic/claude-sonnet-4-5 when it has none).
class Story(BaseModel):
title: str
points: int
data = tab.extract("Top story title and points", Story, model="anthropic/claude-sonnet-4-5")
# observe → actionable elements, each carrying a replayable method + arguments
elements = tab.observe("What can I click?", model="openai/gpt-5.6").elements
# elements: [BrowserObserveElement(description, selector, url, method, arguments)]
acted = tab.act("Click the first headline")
# BrowserActResult(success, message, action_description, actions, cache_status,
# input_tokens, output_tokens)
# Replay a pre-resolved action — no LLM call, no tokens, no model provider key.
# Pass a BrowserObserveElement or BrowserActAction instead of a string; `model` is
# ignored in this form, and an action without a `selector` raises BoxError.
tab.act(elements[0])
tab.act(acted.actions[0])
# Live view + raw CDP
live_url = tab.live_view_url() # view-only screencast page/iframe
cdp_url = box.browser.cdp_url() # wss://…?token=… — no extra auth wiring
tab.close()
# Drive the same browser from Playwright (pip install playwright)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
remote = p.chromium.connect_over_cdp(cdp_url)
context = remote.contexts[0] if remote.contexts else remote.new_context()
page = context.pages[0] if context.pages else context.new_page()
page.goto("https://example.com")
# Session recordings (HLS playback URL + MP4 download, chapter markers).
# One active recording per box; captures all tabs and follows the foreground.
# Auto-stops after max_duration_seconds or ~3 minutes of no on-screen activity.
handle = box.browser.recordings.start(max_duration_seconds=600) # default & max 600
recording = handle.stop()
# or stop whatever is recording on the box, without a handle:
# recording = box.browser.recordings.stop()
# BrowserRecording(id, box_id, status, started_at, ended_at, duration_ms, size_bytes,
# mp4_size_bytes, segment_count, markers, stopped_reason,
# max_duration_seconds, expires_at, playlist_url)
# markers: BrowserRecordingMarker(type="tab_switch", at_ms, end_ms, label, tab_id)
# expires_at is epoch ms (videos retained 14 days); playlist_url is API-served — fetch it
# with an `X-Box-Api-Key: <api_key>` header (hls.js / Safari / ffplay).
all_recordings = box.browser.recordings.list()
one_recording = box.browser.recordings.get(recording.id)
# Download the video to a local file — returns the path written.
# Defaults to ./box-recording-<id>.mp4 (.ts for recordings captured before MP4 support).
file = box.browser.recordings.download(recording.id)
box.browser.recordings.download(recording.id, path="./out/demo.mp4")
Multi-step browser goals
tab.run() — the autonomous multi-step browser agent — was removed, along with
the BrowserRunResult / BrowserRunStep types (Stagehand v4 dropped the underlying
agent primitive). The browser now exposes observe, act, and extract only.
Three replacements:
1. Drive your own loop — resolve steps once with observe, then replay them
with act(action) so the model stays out of the hot path; extract is the stop check.
class Product(BaseModel):
title: str
price: str
elements = tab.observe("the product links in the listing").elements
actions = [e for e in elements if e.selector]
for action in actions[:5]:
tab.goto(START) # deterministic reset, no browser-AI tokens
tab.act(action) # replay the resolved click: no LLM, no tokens
item = tab.extract("title and price", Product)
2. Hand the goal to the in-box agent — browser=True auto-wires the
chrome-devtools MCP (Chromium already warmed on 127.0.0.1:9222) into the box's
coding agent, so box.agent.run(prompt=...) drives the browser itself and iterates
until done. No tab.create() needed first. This bills coding-agent model tokens
rather than browser-AI metering, and needs an agent harness + key.
3. Connect over CDP with Playwright via box.browser.cdp_url() when the flow is
fully deterministic.
EphemeralBox
Lightweight, short-lived boxes (max 3 days). Supports exec, files, schedule,
cd, network policy, and snapshots. No agent, git, skills, labels
namespace, browser, or public URLs.
from upstash_box import EphemeralBox
ebox = EphemeralBox.create(
name="scratch-box",
runtime="python",
size="small",
ttl=3600, # seconds, max 259200 (3 days), default 259200
env={"API_KEY": "..."},
labels=["scratch"], # settable at create time; filter via Box.list(label=...)
network_policy={"mode": "deny-all"},
attach_headers={"api.stripe.com": {"Authorization": "Bearer sk_live_..."}},
)
ebox.network_policy
ebox.expires_at # unix timestamp when auto-deleted
ebox.exec.command("python -c 'print(1+1)'")
ebox.exec.code(code="print('hi')", lang="python")
ebox.exec.session(argv=["bash", "-i"], tty=True) # whole exec namespace, session included
ebox.files.write(path="/workspace/home/data.json", content="{}")
ebox.files.stat("/workspace/home/data.json") # whole files namespace, stat/mkdir/rename/remove included
ebox.schedule.exec(cron="* * * * *", command=["bash", "-c", "date"])
ebox.cd("subdir")
snap = ebox.snapshot(name="checkpoint")
ebox.list_snapshots()
ebox.delete_snapshot(snap.id)
status = ebox.get_status()["status"]
ebox.delete()
# Restore from snapshot
ebox2 = EphemeralBox.from_snapshot(snap.id, ttl=7200)
# Statics: EphemeralBox.delete_boxes(box_ids=[...]) / EphemeralBox.delete_snapshots(...)
# are the Box ones. EphemeralBox.get_by_name() returns a full `Box`, not an
# `EphemeralBox` (quirk mirrored from the JS SDK).
# `AsyncEphemeralBox` is the async variant (`await AsyncEphemeralBox.create(...)`).
Public URLs
Expose box ports as public URLs with optional auth.
public_url = box.get_public_url(3000)
# public_url: PublicURL(url="https://{id}-3000.preview.box.upstash.com", port)
authed = box.get_public_url(3000, bearer_token=True)
# authed: PublicURL(url, port, token)
basic = box.get_public_url(3000, basic_auth=True)
# basic: PublicURL(url, port, username, password)
result = box.list_public_urls() # {"public_urls": [PublicURL, ...]}
box.delete_public_url(3000)
Skills
Install agent skills from the Context7 registry. Format: owner/repo/skill-name.
An installed skill becomes instructions for the box's agent, so pin skills to owners you trust the same way you would a dependency. Skills resolve from the registry at box creation, not from arbitrary URLs, and they only ever run inside the box's container.
box = Box.create(skills=["upstash/qstash-js/qstash-js"])
box.skills.add("upstash/workflow-js/workflow-js")
enabled = box.skills.list()
box.skills.remove("upstash/workflow-js/workflow-js")
Labels
labels = box.labels.add("prod") # returns the updated set
box.labels.remove("beta")
current = box.labels.list()
prod_boxes = Box.list(label="prod")
Network Policy & Outbound Headers
box = Box.create(
# mode: "allow-all" (default) | "deny-all" | "custom"
# custom takes any of allowed_domains / allowed_cidrs / denied_cidrs
network_policy={
"mode": "custom",
"allowed_domains": ["api.example.com"],
"allowed_cidrs": ["203.0.113.0/24"],
"denied_cidrs": ["10.0.0.0/8"],
},
# Inject secret headers into matching outbound HTTPS requests (write-only, never read back)
attach_headers={
"api.stripe.com": {"Authorization": "Bearer sk_live_..."},
"*.example.com": {"X-Custom-Token": "secret123"},
},
)
box.network_policy
box.update_network_policy({"mode": "deny-all"})
MCP Servers
Attach MCP servers to the box agent. An attached server supplies tools the agent can call,
so use servers you control or trust — and keep network_policy restrictive when the agent
also handles untrusted input.
box = Box.create(
agent={"harness": Agent.CLAUDE_CODE, "model": ClaudeCode.SONNET_4_5},
mcp_servers=[
{"name": "fs", "package": "@modelcontextprotocol/server-filesystem"},
{"name": "custom", "url": "<your-mcp-server-url>", "headers": {"Authorization": "..."}},
],
)
Errors & SSH
from upstash_box import BoxError
try:
box.agent.run(prompt="...")
except BoxError as e:
print(e, e.status_code)
Shell into a box directly (Box API key is the SSH password):
ssh <box-id>@us-east-1.box.upstash.com
Async client
The async client mirrors the sync API exactly — await the calls and use async for to stream.
import asyncio
from upstash_box import AsyncBox, Agent
async def main():
box = await AsyncBox.create(runtime="node", agent={"harness": Agent.CLAUDE_CODE})
async with box:
run = await box.agent.run(prompt="Set up a Next.js project")
print(run.result)
stream = await box.agent.stream(prompt="Build a REST API")
async for chunk in stream:
print(chunk)
await box.delete()
asyncio.run(main())
asyncio.gather over many AsyncBox.create(...) / box.agent.run(...) calls runs boxes in parallel.
Gotchas
- Public API option keys are snake_case in Python:
api_key,user_name,network_policy,response_schema,max_retries,on_tool_use,attach_headers, and agentoptionslikemax_turns,max_budget_usd. - Agent config takes
harness(not the deprecatedprovider/runner) —harnessis required. response_schemaaccepts a PydanticBaseModelsubclass (returns a typed instance) or a raw JSON-schemadict(returns adict). Browserschemafollows the same contract.- Default working directory is
/workspace/home, not/homeor/. box.cd()is client-side tracking — it validates the path exists but doesn't change the box's shell cwd. All SDK methods use it automatically.EphemeralBoxdoes NOT supportagent,git,skills, thelabelsnamespace, the browser, or public URLs — use fullBoxfor those (it does supportscheduleand snapshots).run.exit_codeisNonefor agent runs, only available for exec commands.run.resultis stdout on success and stderr on failure — a command that exits 0 writing only to stderr yields""; readrun.stderrfor it.files.download(folder=...)takes a path inside the box; output lands in./<basename>locally.files.read()slices only whenlengthis given —offset=alone reads the whole file, andlength=0reads nothing.files.stat()is an lstat by default: a symlink reportstype="symlink"unless you passfollow=True.files.remove()needsrecursive=Truefor a directory, andfiles.mkdir()needsparents=Truefor nested paths.files.rename(from_path, to_path)takes positional arguments (fromis a Python keyword); JS spells itrename(from, to).exec.session()handles own the process —close()or a dropped connection kills the command, and sessions cannot be reattached.tty=Truemerges stderr into stdout, soon_stderrnever fires for a PTY session.- The sync
session.wait(timeout=...)has no async counterpart (await handle.wait()blocks until exit); it raisesTimeoutErrorwhen the timeout elapses. box.browserrequires a box created withbrowser=True.- There is no
tab.run()— the autonomous browser agent was removed. Loopobserve+act(action)+extractyourself, hand the goal to the in-box agent, or drive Playwright overcdp_url(). tab.act(action)(replaying anobserve()result) costs no tokens and needs no model provider key; onlyact(instruction)with a string is metered.get_init_command/set_init_command/delete_init_commandraise unless the box was created withkeep_alive=True.- The JS static
Box.delete({boxIds})isBox.delete_boxes(box_ids=...)here, to avoid clashing with the instancedelete(). box.delete()is irreversible — snapshot first if you need the state.- Git operations require
git.tokenin the box config for private repos and PRs. Box.from_snapshot()creates a new box — it does not modify the original. It reuses the full create body, sobrowser/skills/mcp_serversare forwarded (the JSBox.fromSnapshot()drops those).EphemeralBoxhas noupdate_network_policy— setnetwork_policyat create time.- All
timeoutvalues are in milliseconds (matching the JS SDK), default600000. - When breaking out of a stream early, call
stream.close()/await stream.aclose()so the run is markeddetached. - Close the transport when done:
box.delete()closes it, or usewith box:/box.close()(async with/await box.aclose()forAsyncBox).
Loading...
Select a file to preview
Analyzing security...
Checking scan reports and verification data.
Bill of Materials
Everything this skill can do — files, network, commands, and more.