upstash-box-py について
upstash-box-pyは、サンドボックス化されたクラウドコンテナを作成・操作するPython SDKです。隔離環境でコマンドやGit作業、エージェントを実行する開発者向けで、同期・非同期APIに加え、ブラウザー操作、スケジュール、スナップショットを備えています。
主な機能
- 01サンドボックスボックスの作成と管理
- 02シェルコマンドとコードの実行
- 03ファイルとGitリポジトリの管理
- 04構成可能なハーネスでのエージェント実行
- 05コマンドとエージェントプロンプトのスケジュール
- 06ヘッドレスChromiumの操作
活用シーン
- 01隔離コンテナでのビルドとテスト
- 02ワークスペースを対象とするエージェントタスク
- 03コマンドやプロンプトの定期実行
- 04ブラウザー操作とデータ抽出の自動化
MCP 対応
ボックス設定で `mcp_servers` を指定できます。ブラウザーモードでは chrome-devtools MCP がコーディングエージェントに接続されます。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ upstash/ skills --skill upstash-box-pyインストール後、Claude Code で/upstash-box-pyを入力して読み込まれたか確認します。
詳細
upstash-box Python SDK
AI エージェント、シェル、ファイルシステム、git、cron スケジュール、およびオプションのヘッドレスブラウザを内蔵したサンドボックス化されたクラウドコンテナです。
TypeScript SDK (@upstash/box)(upstash-box-js スキル)を snake_case の名前でミラーしています。意図的な差異は「注意点」に記載しています。
インストールとセットアップ
pip install upstash-box
UPSTASH_BOX_API_KEY 環境変数を設定するか、コンストラクタに api_key を渡してください。
SDK には同期版の Box(以下の例で使用)と非同期版の AsyncBox(box = await AsyncBox.create(...)、await box.agent.run(...))の両方が含まれています。非同期版のサーフェスは await と async for を除いて同一です。
すべてのリクエストに匿名のテレメトリヘッダーが送信されます。オプトアウトするには UPSTASH_DISABLE_TELEMETRY 環境変数を使用してください。
Box のライフサイクル
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). An empty or blank id list raises BoxError —
# it is never read as "everything". Omit snapshot_ids entirely to delete all.
Box.delete_boxes(box_ids=["box_1", "box_2"]) # JS static `delete` is `delete_boxes` here
Box.delete_snapshots(snapshot_ids=["snap_1"])
Box.delete_snapshots() # delete all
アカウントレベルの環境変数
作成するすべての box に注入されます。
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")
エージェントの実行
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 ..."}},
)
エージェントオプション(ハーネスごと)
options はハーネスに転送されます。受け入れられるキーは box が実行するハーネスによって異なります。Python ではキーは snake_case です。SDK はトップレベルのキーを各ハーネスのバックエンドのケーシング(Claude Code / OpenCode → camelCase、Codex → snake_case)に変換します。ネストされた dict 内のキーはそのまま送信されます。
# 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
JS のジェネリックな AgentOptions<TProvider> とは異なり、Python では options をハーネスで絞り込みません。型はすべての形状の union に生の dict を加えたものになります。
ハーネスとモデル
harness は必須です。モデルの enum: ClaudeCode、OpenAICodex、OpenCodeModel、CursorModel、OpenRouterModel、VercelModel — またはプロバイダープレフィックス付きの任意の文字列。
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
カスタムハーネス
管理されたハーネスの代わりに、box 内で独自のエージェントプロセスを実行します。
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 のフィールド
すべての run(エージェント、コマンド、コード)は 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
シェル実行
# 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)
...
ライブセッション
exec.session() はまだ実行中のプロセスへの WebSocket を開きます — stdin、ストリーミングされる stdout/stderr、PTY リサイズ、シグナルに対応します。これは httpx では対応していない唯一の機能です。websockets 依存関係は遅延インポートされ、セッションが開かれたときのみ読み込まれます。Box、AsyncBox、および両方のエフェメラルクライアントで利用可能です。
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)
セッションはプロセスを所有します: close()(または with ブロックからの離脱)、接続の切断、プログラムの終了のいずれかでコマンドは強制終了され、セッションを再接続することはできません。何かを完了まで実行したい場合は wait() を使用してください。
AsyncBox ではすべてのハンドルメソッドはコルーチンです(await session.write(...)、await session.wait()、async with await box.exec.session(...) as s:)。また async コールバックは await されます。そちらの wait() はタイムアウトを取りません。同期クライアントではコールバックはバックグラウンドのリーダースレッド上で実行されます — コールバックは短く保ち、その中から wait() を呼び出さないでください。待機する終了フレームは、ブロックしているまさにそのスレッドに到着するためです。
ファイルシステム
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 / 作業ディレクトリ
SDK は cwd をクライアント側で追跡します。すべての操作(exec、ファイル、git、エージェント)はそれを基準に実行されます。
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
クローンは box の分離されたコンテナ内に配置され、呼び出し元のマシンには決して配置されません。クローンされたコードは、何かがそれを実行するまではデータです — 信頼できないリポジトリは信頼できない入力として扱い、そのビルドやテストを実行する前に制限的な network_policy(下記参照)と組み合わせてください。
clone を除くすべての git 呼び出しは box の現在のディレクトリで実行されるため、先にクローンへ cd してください。ワークスペースのルートにはリポジトリが存在せず、status は空で返ります。これはクリーンなツリーとして解釈されます。
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, warning)
issue = box.git.create_issue(title="Flaky test in CI", body="...")
# issue: Issue(url, number, title, warning)
# Attach images or videos to a PR or issue. Paths are relative to the box's
# working directory; alt text for an image is written as "shot.png#alt text"
# (a video takes none). Reference an attachment from the body as
#  and GitHub rewrites it to the uploaded asset.
box.git.create_pr(
title="Fix the tower layout",
body="\n",
attach=["before.png#before", "after.png#after"],
)
# `warning` is set when `gh` exited non-zero but still returned a URL: the item
# exists while an attachment failed to upload, or the PR was already open.
# 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"])
スケジュール
box 上の cron タスク — シェルコマンドまたはエージェントプロンプト。Box と EphemeralBox で利用可能です。Cron は 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)
スナップショット
# 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)
ブラウザ
ヘッドレス Chromium を操作するには browser=True を指定して box を作成します。タブ管理は box.browser 上にあり、すべてのページ操作はタブハンドル上にあります。extract / observe / act(instruction) は AI 搭載で課金対象です。act(action) はすでに解決済みのアクションを LLM 呼び出しなし、トークン消費なしで再生します。
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")
マルチステップのブラウザゴール
tab.run() — 自律的なマルチステップブラウザエージェント — は 削除されました。基盤となる
BrowserRunResult / BrowserRunStep 型も同様です(Stagehand v4 で基盤の
エージェントプリミティブが廃止されました)。ブラウザが公開するのは observe、act、extract のみになりました。
3 つの代替手段があります:
1. 独自のループを回す — observe でステップを一度解決し、act(action) で
それらを再生することで、モデルをホットパスから遠ざけます。extract が停止チェックです。
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. ゴールをインボックスエージェントに任せる — browser=True は
chrome-devtools MCP(Chromium はすでに 127.0.0.1:9222 でウォームアップ済み)をボックスの
コーディングエージェントに自動接続するので、box.agent.run(prompt=...) がブラウザ自体を操作し、
完了するまで反復します。最初に tab.create() を呼ぶ必要はありません。これはブラウザ AI の従量課金ではなく
コーディングエージェントのモデルトークンとして課金され、エージェントハーネスとキーが必要です。
3. CDP 経由で接続する — フローが完全に決定論的な場合は、Playwright で
box.browser.cdp_url() を介して接続します。
EphemeralBox
軽量で短命なボックス(最大 3 日間)。exec、files、schedule、
cd、ネットワークポリシー、スナップショットをサポートします。agent、git、skills、labels
名前空間、ブラウザ、パブリック URL はありません。
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(...)`).
パブリック URL
ボックスのポートを、オプションの認証付きでパブリック URL として公開します。
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)
スキル
Context7 レジストリからエージェントスキルをインストールします。形式: owner/repo/skill-name。
インストールされたスキルはボックスのエージェントへの指示になるため、依存関係と同じように、 信頼できるオーナーのスキルを固定してください。スキルは任意の URL からではなく、ボックス作成時に レジストリから解決され、ボックスのコンテナ内でのみ実行されます。
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 = box.labels.add("prod") # returns the updated set
box.labels.remove("beta")
current = box.labels.list()
prod_boxes = Box.list(label="prod")
ネットワークポリシーとアウトバウンドヘッダー
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 サーバー
MCP サーバーをボックスエージェントに接続します。接続されたサーバーはエージェントが呼び出せる
ツールを提供するため、自分で管理または信頼するサーバーを使用してください。また、エージェントが
信頼できない入力も処理する場合は、network_policy を制限的に保ってください。
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": "..."}},
],
)
エラーと SSH
from upstash_box import BoxError
try:
box.agent.run(prompt="...")
except BoxError as e:
print(e, e.status_code)
ボックスに直接シェル接続します(Box API キーが SSH パスワードです):
ssh <box-id>@us-east-1.box.upstash.com
非同期クライアント
非同期クライアントは同期 API を完全にミラーリングしています — 呼び出しに await を使い、ストリーミングには async for を使います。
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())
多数の AsyncBox.create(...) / box.agent.run(...) 呼び出しに対する asyncio.gather は、ボックスを並列で実行します。
注意点
- 公開 API のオプションキーは Python では snake_case です:
api_key、user_name、network_policy、response_schema、max_retries、on_tool_use、attach_headers、そしてエージェントのoptions(max_turns、max_budget_usdなど)。 - エージェント設定は
harnessを取ります(非推奨のprovider/runnerではありません)—harnessは必須です。 response_schemaは Pydantic のBaseModelサブクラス(型付きインスタンスを返す)または生の JSON スキーマdict(dictを返す)を受け付けます。ブラウザのschemaも同じ契約に従います。- デフォルトの作業ディレクトリは
/workspace/homeで、/homeや/ではありません。 box.cd()はクライアント側のトラッキングです — パスが存在することを検証しますが、box のシェル cwd は変更しません。すべての SDK メソッドはこれを自動的に使用します。EphemeralBoxはagent、git、skills、labels名前空間、ブラウザ、パブリック URL をサポートしません — これらには完全なBoxを使用してください(scheduleとスナップショットはサポートします)。run.exit_codeはエージェント実行ではNoneで、exec コマンドでのみ利用可能です。run.resultは成功時は stdout、失敗時は stderr です — 終了コード 0 で stderr にのみ書き込むコマンドは""を返します。それにはrun.stderrを読んでください。files.download(folder=...)は box 内の パスを取ります。出力はローカルの./<basename>に保存されます。files.read()はlengthが指定されたときのみスライスします —offset=だけではファイル全体を読み込み、length=0では何も読み込みません。files.stat()はデフォルトで lstat です:follow=Trueを渡さない限り、シンボリックリンクはtype="symlink"を報告します。files.remove()はディレクトリに対してrecursive=Trueが必要で、files.mkdir()はネストされたパスに対してparents=Trueが必要です。files.rename(from_path, to_path)は位置引数を取ります(fromは Python のキーワードです)。JS ではrename(from, to)と書きます。exec.session()のハンドルがプロセスを所有します —close()または接続の切断でコマンドが強制終了され、セッションは再接続できません。tty=Trueは stderr を stdout にマージするため、PTY セッションではon_stderrが発火しません。- 同期版の
session.wait(timeout=...)には非同期版がありません(await handle.wait()は終了までブロックします)。タイムアウトが経過するとTimeoutErrorを発生させます。 box.browserはbrowser=Trueで作成された box が必要です。tab.run()は 存在しません — 自律ブラウザエージェントは削除されました。observe+act(action)+extractを自分でループさせるか、ゴールを box 内エージェントに渡すか、cdp_url()経由で Playwright を操作してください。tab.act(action)(observe()の結果を再生する)はトークンを消費せず、モデルプロバイダーのキーも不要です。文字列を渡すact(instruction)のみが課金対象です。get_init_command/set_init_command/delete_init_commandは、box がkeep_alive=Trueで作成されていない限り例外を発生させます。- JS の静的
Box.delete({boxIds})は、インスタンスのdelete()との衝突を避けるため、ここではBox.delete_boxes(box_ids=...)です。 Box.delete_boxes(box_ids=[])とBox.delete_snapshots(snapshot_ids=[])はBoxErrorを発生させます — 空のリストは決して「すべて削除」とは解釈されません。そのためには ID なしでBox.delete_snapshots()を呼び出してください。box.delete()は不可逆です — 状態が必要な場合は先にスナップショットを取得してください。- Git 操作では、プライベートリポジトリと PR のために box 設定の
git.tokenが必要です。 Box.from_snapshot()は新しい box を作成します — 元の box は変更しません。作成ボディ全体を再利用するため、browser/skills/mcp_serversは転送されます(JS のBox.fromSnapshot()はこれらを破棄します)。EphemeralBoxにはupdate_network_policyがありません — 作成時にnetwork_policyを設定してください。- すべての
timeout値は ミリ秒 です(JS SDK と一致)、デフォルトは600000。 - ストリームから早期に抜ける場合は、
stream.close()/await stream.aclose()を呼び出して、実行がdetachedとしてマークされるようにしてください。 - 完了したらトランスポートを閉じてください:
box.delete()がそれを閉じます。またはwith box:/box.close()を使用してください(AsyncBoxの場合はasync with/await box.aclose())。