Docs: Claude Code on Sandboxes | Maritime

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:

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

/etc/claude/hooks/spawn-runner

#!/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:

Add the reaper

A runner exits after its session has been idle for 15 minutes. Its sandbox keeps running, becausekeepAwake is on. This script ends each runner sandbox whose runner has exited. Save it as/etc/claude/reap-runners and run it every minute.

/etc/claude/reap-runners

#!/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 180

The 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-inclaude first.

Check a session

  1. Start a session at claude.ai/code. Pick your environment in the environment picker and a repository the runner can clone.
  2. Read the orchestrator log. It shows dispatching 1 hint(s), then the hook's linerunner for session_... started in sbx-....
  3. 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

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.