← Upstash RedisCONTENT HISTORY

Update to Upstash Redis

Snapshot Sep 30, 2026 · 23:07 UTC · version 1.2.1

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

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

SHA-256: 1bcabd3c58c64984caec3f10158b59643ccbfc797cf290e28d25ca531fc7b16b