> ## Documentation index
> Fetch the complete index at: https://maritime.sh/llms.txt
> Setup procedure for AI agents (install, sign in, deploy, verify): https://maritime.sh/agents.md
> This page as HTML: https://maritime.sh/docs/claude-code-self-hosted

# Run Claude Code cloud sessions on Sandboxes

Claude Code [self-hosted environments](https://code.claude.com/docs/en/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](https://code.claude.com/docs/en/self-hosted-environments-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 `sandboxes` scope or the `manage` scope. See
  [Keys & scopes](https://maritime.sh/docs/sdk/authentication).
- 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](https://maritime.sh/docs/claude-code-self-hosted#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:

```bash
# 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.

```python
#!/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](https://code.claude.com/docs/en/self-hosted-environments-configuration#the-spawn-runner-hook):

- 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.
- `keepAwake` keeps the sandbox running. A runner makes only outbound calls, and Maritime does not
  count those as activity. `timeLimitSeconds` ends 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.

```python
#!/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](https://code.claude.com/docs/en/self-hosted-environments-deploy#resumed-sessions-lose-unpushed-work).

## Start the orchestrator

Run these commands on the orchestrator host:

```bash
# 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-in `claude` first.

## Check a session

1. Start a session at [claude.ai/code](https://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 line
   `runner 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:

```bash
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 `small` unless 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, add `size` to the create
  body. See [Sandboxes](https://maritime.sh/docs/ephemeral-sandboxes#limits).
- 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](https://maritime.sh/docs/claude-code-self-hosted#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](https://code.claude.com/docs/en/self-hosted-environments) (Anthropic)
- [On-demand runners](https://code.claude.com/docs/en/self-hosted-environments-configuration#on-demand-runners) (Anthropic)
- [Sandboxes](https://maritime.sh/docs/ephemeral-sandboxes)
- [Keys & scopes](https://maritime.sh/docs/sdk/authentication)
