Sandboxes
Run Claude Code cloud sessions on Sandboxes
Claude Code self-hosted environments run the cloud sessions your team starts at claude.ai/code on machines you provide. This guide gives each session its own Maritime sandbox. A sandbox starts when a session is queued and ends after the session goes idle. In our test, Claude was working 4.4 seconds after the session was queued.
Before you start
You need these four things:
- A Claude Team or Enterprise organization with Allow self-hosted environments turned on, and the environment key of a self-hosted environment. Anthropic's quickstart shows where to find both.
- A Maritime plan or the free trial. Without one, every sandbox create returns 402
plan_required. - A Maritime API key with the
sandboxesscope or themanagescope. See Keys & scopes. - An always-on Linux host for the orchestrator, with Claude Code 2.1.299 or later and Python 3. It can be a Maritime agent (see Run the orchestrator on Maritime).
How the pieces fit
The orchestrator is the claude self-hosted-runner orchestrator process. It holds the environment key and asks Anthropic for queued sessions. For each queued session, it runs your spawn-runner hook with a single-use work order. The hook creates a sandbox from the claude-runner snapshot and starts a runner in it with that work order. The runner takes the session and runs Claude Code. After the session has been idle for 15 minutes, the runner exits, and the reap-runners script ends the sandbox.
The environment key and your Maritime key stay on the orchestrator host. A sandbox receives only its work order, which registers one runner and then expires.
Build the runner snapshot
Install Claude Code once in a sandbox, and save that sandbox as the snapshot claude-runner. Each runner sandbox starts from the snapshot in about 2 seconds. Run these commands on any machine with curl and jq, with MARITIME_API_KEY set:
# 1. Start a build sandbox from the base template
SBX=$(curl -sS -X POST https://api.maritime.sh/v1/sandboxes \
-H "Authorization: Bearer $MARITIME_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "claude-runner-build", "from": "base", "timeLimitSeconds": 1800}')
ID=$(echo "$SBX" | jq -r .id)
# 2. Install Claude Code in it
curl -sS -X POST "$(echo "$SBX" | jq -r .dataPlaneUrl)/commands" \
-H "X-Maritime-Token: $(echo "$SBX" | jq -r .secret)" \
-H "Content-Type: application/json" \
-d '{"cmd": "curl -fsSL https://claude.ai/install.sh | bash -s 2.1.317 && git --version && ~/.local/bin/claude --version", "timeoutSeconds": 600}' \
| jq -r '.exitCode, .stdout'
# 3. Save it as the claude-runner snapshot, then end the build sandbox
curl -sS -X POST "https://api.maritime.sh/v1/sandboxes/$ID/save" \
-H "Authorization: Bearer $MARITIME_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "claude-runner"}'
curl -sS -X DELETE "https://api.maritime.sh/v1/sandboxes/$ID" \
-H "Authorization: Bearer $MARITIME_API_KEY"The install step prints 0, the git version, and 2.1.317 (Claude Code). The save call returns the snapshot with its name and storedBytes. To move to a newer Claude Code, delete the snapshot with DELETE https://api.maritime.sh/v1/snapshots/claude-runner, then repeat these steps with the new version number.
Add the spawn-runner hook
Save this file as /etc/claude/hooks/spawn-runner on the orchestrator host. It uses only the Python standard library.
#!/usr/bin/env python3
"""spawn-runner: start one Claude Code runner in a new Maritime sandbox."""
import json
import os
import re
import sys
import urllib.error
import urllib.request
API = "https://api.maritime.sh/v1"
SNAPSHOT = "claude-runner"
TIME_LIMIT_SECONDS = 4 * 60 * 60
RUNNER = [
"/home/user/.local/bin/claude", "self-hosted-runner",
"--capacity", "1",
"--base-dir", "/home/user/runner-work",
"--release-idle-session-min", "15",
"--kill-session-after-min", "230",
]
RETRYABLE = {408, 409, 425, 429}
def call(method, url, headers, body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(url, data=data, method=method)
request.add_header("Content-Type", "application/json")
for name, value in headers.items():
request.add_header(name, value)
with urllib.request.urlopen(request, timeout=45) as response:
return json.loads(response.read() or b"{}")
def main():
order = os.environ["CLAUDE_RUNNER_ORDER_ID"]
session = os.environ.get("CLAUDE_RUNNER_SESSION_ID", "")
with open(os.environ["CLAUDE_RUNNER_WORK_ORDER_FILE"]) as handle:
work_order = handle.read().strip()
name = ("ccr-" + re.sub(r"[^a-z0-9-]+", "-", order.lower()).strip("-"))[:63].rstrip("-")
command = RUNNER if session else RUNNER + ["--exit-if-unused-min", "30"]
auth = {"Authorization": "Bearer " + os.environ["MARITIME_API_KEY"]}
try:
sandbox = call("POST", API + "/sandboxes", {**auth, "Idempotency-Key": "create-" + order}, {
"name": name,
"from": SNAPSHOT,
"keepAwake": True,
"timeLimitSeconds": TIME_LIMIT_SECONDS,
"env": {"SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET": work_order},
"metadata": {"role": "claude-runner", "session": session},
})
call("POST", sandbox["dataPlaneUrl"] + "/commands",
{"X-Maritime-Token": sandbox["secret"], "Idempotency-Key": "run-" + order},
{"cmd": command, "background": True, "cwd": "/home/user"})
except urllib.error.HTTPError as error:
print(f"maritime: HTTP {error.code} {error.read()[:300].decode(errors='replace')}", file=sys.stderr)
return 1 if error.code in RETRYABLE or error.code >= 500 else 2
except OSError as error:
print(f"maritime: {error}", file=sys.stderr)
return 1
print(f"runner for {session or 'a standby slot'} started in {sandbox['id']}")
return 0
sys.exit(main())The hook follows Anthropic's spawn-runner contract:
- The orchestrator deletes the work order file when the hook exits. The hook therefore passes the work order to the sandbox in
SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET. - The sandbox name and both idempotency keys come from
CLAUDE_RUNNER_ORDER_ID. A request that arrives twice starts one sandbox. keepAwakekeeps the sandbox running. A runner makes only outbound calls, and Maritime does not count those as activity.timeLimitSecondsends the sandbox after 4 hours in every case.- The exit code tells the orchestrator what to do next. 0 means the runner started. 1 means try again. 2 means stop and mark the session Failed with the hook's error, for example
plan_required.
Add the reaper
A runner exits after its session has been idle for 15 minutes. Its sandbox keeps running, because keepAwake is on. This script ends each runner sandbox whose runner has exited. Save it as /etc/claude/reap-runners and run it every minute.
#!/usr/bin/env python3
"""reap-runners: end runner sandboxes whose runner has exited. Run it every minute."""
import json
import os
import urllib.request
API = "https://api.maritime.sh/v1"
AUTH = {"Authorization": "Bearer " + os.environ["MARITIME_API_KEY"]}
def call(method, path):
request = urllib.request.Request(API + path, method=method, headers=AUTH)
with urllib.request.urlopen(request, timeout=45) as response:
return json.loads(response.read() or b"{}")
for sandbox in call("GET", "/sandboxes?state=running&limit=100")["data"]:
if sandbox.get("metadata", {}).get("role") != "claude-runner":
continue
commands = call("GET", f"/sandboxes/{sandbox['id']}/commands")["data"]
runners = [c for c in commands if "self-hosted-runner" in json.dumps(c.get("cmd"))]
if runners and all(c["state"] != "running" for c in runners):
call("DELETE", f"/sandboxes/{sandbox['id']}")
print("ended", sandbox["id"], sandbox["name"])When the user sends that session another message, the orchestrator asks for a new runner, and the session continues in a new sandbox. Work the session did not push to its repository is lost. See Anthropic's notes on resumed sessions.
Start the orchestrator
Run these commands on the orchestrator host:
# As root: store the environment key. Paste it, press Enter, then Ctrl-D.
mkdir -p /etc/claude/hooks
(umask 077 && cat > /etc/claude/environment-secret)
# Put spawn-runner in /etc/claude/hooks and reap-runners in /etc/claude
chmod 755 /etc/claude/hooks/spawn-runner /etc/claude/reap-runners
export MARITIME_API_KEY=mk_...
# Run the reaper every minute, then start the orchestrator
(while true; do /etc/claude/reap-runners; sleep 60; done) &
claude self-hosted-runner orchestrator \
--environment-secret-file /etc/claude/environment-secret \
--hooks-dir /etc/claude/hooks \
--shutdown-drain-sec 70 \
--expected-spawn-seconds 180The orchestrator logs environment_id=ccpool_... when it connects. Run it under a process supervisor such as systemd, with a stop timeout of at least 85 seconds.
Run the orchestrator on Maritime
To host the orchestrator on Maritime, deploy it as an agent from a Dockerfile that installs Claude Code and Python. Turn idle sleep off with --always-on, because the orchestrator must keep asking Anthropic for work. Set the environment key and MARITIME_API_KEY as secret env vars. Start Claude Code by its full install path, such as /root/.local/bin/claude. The agent's PATH finds an older built-in claude first.
Check a session
- Start a session at claude.ai/code. Pick your environment in the environment picker and a repository the runner can clone.
- Read the orchestrator log. It shows
dispatching 1 hint(s), then the hook's linerunner for session_... started in sbx-.... - At claude.ai/code, Claude starts working on the session.
To see which runner sandboxes are running, list them:
curl -sS -H "Authorization: Bearer $MARITIME_API_KEY" \
"https://api.maritime.sh/v1/sandboxes?state=running&limit=100" \
| jq -c '.data[] | select(.metadata.role == "claude-runner") | {id, name, createdAt}'Costs and limits
- A runner sandbox is
smallunless you change the hook: 2 vCPU, 4 GB of memory, and 12 GB of disk, at $0.022 an hour while it runs. To give runners more room, addsizeto the create body. See Sandboxes. - A sandbox runs for the session's active time, plus 15 idle minutes, plus up to one minute until the reaper ends it.
- The runner ends a session after 230 minutes. The sandbox's time limit, 4 hours, ends the sandbox after that.
- Each running sandbox counts toward your plan's machines awake at once. At the limit, a create returns 429
concurrency_limit. The hook exits 1, and the orchestrator offers the session again.
If a session does not start
| What you see | Cause | Fix |
|---|---|---|
The session shows Failed with plan_required | The account has no plan or trial. | Start a plan or the free trial. |
The session shows Failed with scope_missing | The key lacks the sandboxes scope. | Use a key with the sandboxes or manage scope. |
The session shows Failed with snapshot_not_found | The account has no claude-runner snapshot. | Build the runner snapshot. |
| The session stays Queued, and the orchestrator log has no hook line | The orchestrator is stopped, or it uses another environment's key. | Check the environment_id line in the orchestrator log. |
Related
- Self-hosted environments (Anthropic)
- On-demand runners (Anthropic)
- Sandboxes
- Keys & scopes