Run the Claude Code self-hosted runner on Prized
Updated
Set up a Claude Code self-hosted environment on Prized (Claude Team and Enterprise, beta): cloud sessions you start at claude.ai/code, in the Claude app or with claude --cloud run on machines you control, through Anthropic's orchestrator and a spawn-runner hook on a box, or a runner on the box itself.
On this page8
Claude Code cloud sessions run on a VM Anthropic starts for each one, unless your organization sends them to a self-hosted environment: machines you run that take sessions from Anthropic over outbound HTTPS (Anthropic's docs). On Prized, a box takes them.
It runs Anthropic's orchestrator, and each session gets a fresh sandbox of its own, deleted once the session goes idle. The environment key never leaves the box, and the sandbox holds no GitHub token. Or the box runs the sessions itself, with its own tools and sign-ins (Sessions on the box).
Before you start
- Claude Team or Enterprise. Self-hosted environments are a public beta there, off by default: with cloud sessions on for the organization, an Owner turns on Allow self-hosted environments on the Cloud environments admin page. They are not available with Zero Data Retention or the HIPAA configuration. On Pro, Max or Ultra, run Claude Code on a box with Remote Control instead (Keep Claude Code running).
- GitHub. An Owner turns on the GitHub connector, and each person connects their own GitHub account at claude.ai, as for any cloud session.
- A box. Sign up and start one (Quickstart). Its Claude Code needs version 2.1.309 or later: check with
claude --versionon the box, and if it's older, runclaude updatethere.
1. Create the environment
On the Cloud environments page, under Self-hosted environments, select New, name it, select Create, then Copy environment key. claude.ai shows the key once, and it expires after 365 days. This needs an Owner, who can hand you the key.
2. Put the key on the box
On the box (prized ssh), run this, paste the key, press Enter, then Ctrl-D:
mkdir -p ~/.config/claude-runner/hooks && chmod 700 ~/.config/claude-runner
(umask 077 && cat > ~/.config/claude-runner/environment-secret)3. Give each session a sandbox
Make a Prized API key with Sandboxes: Write on API keys, with a long lifetime (keys default to 90 days, and every session fails once it expires), and save it on the box the same way:
(umask 077 && cat > ~/.config/claude-runner/prized-api-key)Paste this on the box. It saves the hook that Anthropic's orchestrator runs once for each session that needs a machine: it creates a sandbox, copies in the session's single-use work order, starts claude self-hosted-runner there, and returns.
cat > ~/.config/claude-runner/hooks/spawn-runner <<'HOOK'
#!/usr/bin/env bash
# Each Claude Code cloud session in a Prized sandbox of its own. https://prized.dev/docs/claude-code-self-hosted-runner
# spawn-runner serve starts the watch below, then Anthropic's orchestrator, which runs this file once per session
# spawn-runner (from the orchestrator) creates the session's sandbox and starts its runner there
# spawn-runner watch deletes each sandbox once its runner has exited
set -euo pipefail
PERMANENT=; permanent() { printf '%s\n' "$*" >&2; PERMANENT=1; exit 2; }
trap 'rc=$?; [ "$rc" -eq 0 ] || [ -n "${PERMANENT:-}" ] || exit 1' EXIT
DIR=${PRIZED_CLAUDE_DIR:-$HOME/.config/claude-runner}
API=${PRIZED_API_URL:-https://api.prized.dev/api/v1}
TEMPLATE=${PRIZED_TEMPLATE:-prized-agent-v1} VCPU=${PRIZED_VCPU:-} MEMORY_GB=${PRIZED_MEMORY_GB:-} # unset: the template's
IDLE_MIN=${CLAUDE_IDLE_MIN:-30} MAX_MIN=${CLAUDE_MAX_MIN:-480}
command -v curl > /dev/null && command -v jq > /dev/null || permanent "spawn-runner needs curl and jq on this machine"
mins() { [[ $1 =~ ^0*([1-9][0-9]{0,4})$ ]] && [ "${BASH_REMATCH[1]}" -le 10080 ]; } # 1 to 10080 minutes
[[ $VCPU =~ ^[0-9]{0,3}$ && $MEMORY_GB =~ ^[0-9]{0,4}$ ]] && mins "$IDLE_MIN" && mins "$MAX_MIN" ||
permanent "PRIZED_VCPU and PRIZED_MEMORY_GB take whole numbers; CLAUDE_IDLE_MIN and CLAUDE_MAX_MIN 1 to 10080 minutes"
IDLE_MIN=$((10#$IDLE_MIN)) MAX_MIN=$((10#$MAX_MIN))
if [ -n "${CLAUDE_CODE_VERSION:-}" ]; then VERSION=$CLAUDE_CODE_VERSION
[[ $VERSION =~ ^([0-9]+\.[0-9]+\.[0-9]+|latest|stable)$ ]] || permanent "CLAUDE_CODE_VERSION: a version such as 2.1.312, or latest"
else VERSION=$(timeout 5 claude --version 2>/dev/null | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' || true); fi # the box's
[ -s "$DIR/prized-api-key" ] || permanent "no Prized API key in $DIR/prized-api-key"
AUTH="Authorization: Bearer $(tr -d '[:space:]' < "$DIR/prized-api-key")"
DEADLINE='' RETRY=2 RESUME=true # spawn sets DEADLINE so every call ends before the orchestrator's 60 s hook timeout
api() { # curl with the key off the command line
local t=15 left=45 r=$RETRY h=()
if [ -n "$DEADLINE" ]; then left=$((DEADLINE - SECONDS)); [ "$left" -gt 2 ] || { echo "out of time" >&2; return 28; }; [ "$left" -gt 15 ] || { t=$left r=0; }; fi
[ "$RESUME" = true ] || h=(-H "Prized-Auto-Resume: false")
curl -sS --max-time "$t" --retry "$r" --retry-max-time $((left - t)) "${h[@]}" -H @<(echo "$AUTH") "$@"
}
run_in() { # run_in TOOLBOX COMMAND: run a command in the sandbox, print its stdout
local out; out=$(api -f -X POST "$1/process/execute" -H 'Content-Type: application/json' --data-raw "$(jq -nc --arg c "$2" '{command: $c, timeout: 10}')") ||
{ echo "a command in the sandbox did not answer" >&2; return 1; }
[ "$(jq -r .exitCode <<<"$out")" = 0 ] || { echo "in the sandbox: $(jq -r .stderr <<<"$out" | tail -c 300)" >&2; return 1; }
jq -r .stdout <<<"$out"
}
why() { jq -r '[.error, .reason, .field, .message] | map(select(. != null) | tostring) | join(": ")' <<<"$1" 2>/dev/null || true; }
put() { api -f -o /dev/null -X POST "$1/files/upload?path=/home/prized/.claude-runner/$2" --data-binary "@$3" || { echo "uploading $2 failed" >&2; return 1; }; }
spawn() {
local order=${CLAUDE_RUNNER_ORDER_ID:-} session=${CLAUDE_RUNNER_SESSION_ID:-} wo=${CLAUDE_RUNNER_WORK_ORDER_FILE:-}
local key out code toolbox flags runtime ends now margin home
DEADLINE=$((SECONDS + 40))
[[ $order =~ ^[A-Za-z0-9._-]{1,253}$ ]] || permanent "unexpected CLAUDE_RUNNER_ORDER_ID"
key=cc-$order; [ ${#key} -le 128 ] || key=cc-$(printf '%s' "$order" | sha256sum | cut -c1-64)
[ -s "$wo" ] && [ -r "$wo" ] || { echo "no work order in CLAUDE_RUNNER_WORK_ORDER_FILE" >&2; exit 1; }
home=
if [ -d "$DIR/sandbox-home" ]; then home=$(realpath "$DIR/sandbox-home")
case $(realpath "$DIR")/ in "$home"/*) permanent "sandbox-home must not hold $DIR, with its keys" ;; esac
fi
tmp=$(mktemp -d) made=
# On a failure or a kill, delete the sandbox this delivery made: the orchestrator offers the session again.
trap 'rc=$?; rm -rf "$tmp" || true; [ "$rc" -eq 0 ] || [ -z "$made" ] || curl -sS -o /dev/null --max-time 5 -X DELETE \
-H @<(echo "$AUTH") "$API/sandboxes/$made" || true; [ "$rc" -eq 0 ] || [ -n "${PERMANENT:-}" ] || exit 1' EXIT
trap 'exit 1' TERM INT HUP
# Its run time: the session's limit plus an hour, within what the workspace allows (60 minutes on the free tier).
code=$(api -o "$tmp/templates.json" -w '%{http_code}' "$API/sandbox-templates")
case $code in 200) ;; 401|403) permanent "Prized refused the API key ($code): $(why "$(cat "$tmp/templates.json")")" ;;
*) echo "Prized answered $code" >&2; exit 1 ;; esac
runtime=$(jq -r --arg t "$TEMPLATE" '.templates[] | select(.id == $t) | .defaultMaxRuntimeMin // empty' "$tmp/templates.json")
[[ $runtime =~ ^[0-9]+$ ]] || permanent "Prized has no template $TEMPLATE"
[ "$runtime" -le $((MAX_MIN + 60)) ] || runtime=$((MAX_MIN + 60))
# One sandbox per order: a redelivered order sends the same Idempotency-Key and body, and gets the first sandbox back.
code=$(api -X POST "$API/sandboxes?wait=running" -H 'Content-Type: application/json' -H "Idempotency-Key: $key" \
-o "$tmp/create.json" -w '%{http_code}' --data-raw "$(jq -nc --arg t "$TEMPLATE" --arg v "$VCPU" --arg m "$MEMORY_GB" \
--arg o "$order" --arg s "$session" --argjson r "$runtime" '{template: $t, autoDeleteMin: 60, maxRuntimeMin: $r,
labels: ({"managed-by": "claude-orchestrator", "claude-order": $o} + if $s == "" then {} else {"claude-session": $s} end)}
+ if $v + $m == "" then {} else {resources: ((if $v == "" then {} else {vcpu: ($v | tonumber)} end)
+ if $m == "" then {} else {memoryMb: ($m | tonumber * 1024)} end)} end')")
out=$(cat "$tmp/create.json")
if [ "$code" = 409 ] && [ "$(jq -r .error <<<"$out" 2>/dev/null)" = idempotency_conflict ]; then # settings changed since
local found; found=$(api -f -G "$API/sandboxes" --data-urlencode "label=claude-order=$order" | jq -c '{sandbox: .sandboxes[0]}')
if [ "$(jq -r .sandbox.id <<<"$found")" != null ]; then code=200 out=$found; fi
fi
case $code in
200) ;; # a redelivery: carry on only if its sandbox is still there and its runner not started
201) made=$(jq -er .sandbox.id <<<"$out") ;;
400|401|403|404|422) permanent "Prized refused the sandbox ($code): $(why "$out")" ;; # a retry gets the same answer
409) [[ $(why "$out") != template_inactive* ]] || permanent "Prized: $(why "$out")"
echo "Prized answered 409: $(why "$out")" >&2; exit 1 ;;
*) echo "Prized answered $code: $(why "$out")" >&2; exit 1 ;;
esac
sbx=$(jq -er .sandbox.id <<<"$out") toolbox=$(jq -er .sandbox.toolboxBase <<<"$out")
[[ $sbx =~ ^sbx_[A-Za-z0-9]+$ && $toolbox =~ ^https?://[A-Za-z0-9.:-]+/[A-Za-z0-9/_.-]+$ ]] || { echo "unexpected sandbox from Prized" >&2; exit 1; }
if [ "$code" = 200 ]; then
[[ $(api -f "$API/sandboxes/$sbx" 2>/dev/null | jq -r .sandbox.state) =~ ^(starting|running)$ ]] ||
{ echo "this order's sandbox is gone; the session gets a new order" >&2; exit 1; }
[ "$(run_in "$toolbox" '[ -d ~/.claude-runner/started ] && echo started || true')" != started ] ||
{ echo "runner for ${session:-a standby slot} already started in $sbx" >&2; exit 0; }
fi
flags="--capacity 1 --use-anthropic-git-proxy --configure-git --base-dir ~/work/claude --health-port 0 --client-label $sbx"
flags+=" --release-idle-session-min $IDLE_MIN --kill-session-after-min $MAX_MIN --exit-if-unused-min 30"
flags+=" --push-outcome-on-release --log-file ~/.claude-runner/runner.log" # a resumed session starts from its pushed branch
# Release the session before the sandbox's run time ends, rather than lose it when the sandbox is deleted.
ends=$(jq -r '.sandbox.expiresAt // empty' <<<"$out") now=$(date +%s)
if [ -n "$ends" ] && ends=$(date -d "$ends" +%s 2>/dev/null); then
margin=$(((ends - now) / 4)); [ "$margin" -le 600 ] || margin=600
[ "$margin" -lt 60 ] || flags+=" --retire-at $((ends - margin))"
fi
# The orchestrator deletes the work order's file once this hook exits, so it goes into the sandbox now.
put "$toolbox" work-order "$wo"
cat > "$tmp/run.sh" <<EOF
cd ~ && echo \$\$ > ~/.claude-runner/pid.new && mv ~/.claude-runner/pid.new ~/.claude-runner/pid
if [ -n "$VERSION" ] && [ "\$(claude --version | cut -d' ' -f1)" != "$VERSION" ]; then
curl -fsSL https://claude.ai/install.sh | bash -s $VERSION > ~/.claude-runner/install.log 2>&1
fi
claude=claude; if [ -x ~/.local/bin/claude ]; then claude=~/.local/bin/claude; fi
\$claude self-hosted-runner --environment-secret-file ~/.claude-runner/work-order $flags
echo \$? > ~/.claude-runner/done
EOF
put "$toolbox" run.sh "$tmp/run.sh"
if [ -n "$home" ]; then
tar -C "$home" -czf "$tmp/home.tgz" . || { echo "could not pack $DIR/sandbox-home" >&2; exit 1; }
put "$toolbox" home.tgz "$tmp/home.tgz"
fi
run_in "$toolbox" 'cd ~/.claude-runner || exit 1; mkdir started 2>/dev/null || exit 0 # a redelivered order starts nothing twice
if chmod 600 work-order && { [ ! -f home.tgz ] || tar -xzf home.tgz -C ~; } && setsid -f bash run.sh < /dev/null > run.log 2>&1
then exit 0; else rmdir started; exit 1; fi' > /dev/null
echo "runner for ${session:-a standby slot} starting in $sbx" >&2
}
watch() { # every 30 s: delete each sandbox whose runner has exited, or that has outlived its session
trap - EXIT; RETRY=0 RESUME=false # a sandbox that does not answer is asked again next round; a paused one stays paused
local list id state reason toolbox created status age
while :; do
list=$(api -f -G "$API/sandboxes" --data-urlencode "label=managed-by=claude-orchestrator" 2>/dev/null) ||
{ echo "could not list the sandboxes; trying again in ${WATCH_EVERY:-30} s"; list=; }
jq -r '.sandboxes[]? | [.id, .state, (.errorReason // "-"), (.toolboxBase // "-"), (.createdAt // "-")] | join("|")' <<<"$list" 2>/dev/null |
while IFS='|' read -r id state reason toolbox created; do
[[ $id =~ ^sbx_[A-Za-z0-9]+$ ]] || continue
created=$(date -d "$created" +%s 2>/dev/null) || continue
age=$(($(date +%s) - created)) status=
case $state in
running) status=$(run_in "$toolbox" 'cd ~/.claude-runner 2>/dev/null || { echo starting; exit; }
p=$(timeout 2 head -c 20 pid 2>/dev/null)
if [ -e done ]; then echo exited; elif ! [[ $p =~ ^[1-9][0-9]*$ ]]; then echo starting
elif [ -d /proc/$p ]; then echo running; else echo exited; fi' 2>/dev/null) || status=unknown ;;
error) [[ $reason == *runtime_degraded* ]] || status=exited ;; # degraded may still be running: give it time
esac
# Never started in 10 minutes, or outlived the session's limit by an hour: its runner is not coming back.
if { [ "$status" = starting ] && [ "$age" -gt 600 ]; } || [ "$age" -gt $(((MAX_MIN + 60) * 60)) ]; then status=exited; fi
[ "$status" = exited ] || continue
mkdir -p "$DIR/logs"
run_in "$toolbox" 'cd ~/.claude-runner && for f in run.log install.log runner.log done; do if [ -f $f ]; then echo "== $f"; timeout 2 tail -n 200 $f; fi; done' > "$DIR/logs/$id.log" 2>/dev/null || true
if api -f -o /dev/null -X DELETE "$API/sandboxes/$id" 2>/dev/null; then echo "deleted $id, its runner has exited (log: $DIR/logs/$id.log)"; fi
done || true
sleep "${WATCH_EVERY:-30}"
done
}
serve() { # systemd's ExecStart: hold the box awake while the orchestrator runs, start the watch, then the orchestrator
trap - EXIT
mkdir -p ~/.prized/keep-awake && echo $$ > ~/.prized/keep-awake/claude-orchestrator
while :; do "$0" watch || echo "the watch stopped ($?); starting it again"; sleep 30; done &
exec claude self-hosted-runner orchestrator --environment-secret-file "$DIR/environment-secret" --hooks-dir "$(dirname "$0")" \
--health-port 0 --shutdown-drain-sec 70
}
case ${1:-} in serve) serve ;; watch) watch ;; *) spawn ;; esac
HOOK
chmod +x ~/.config/claude-runner/hooks/spawn-runnerThen start it as a service:
sudo tee /etc/systemd/system/claude-orchestrator.service >/dev/null <<UNIT
[Unit]
Description=Claude Code orchestrator, a Prized sandbox per session
Wants=network-online.target
After=network-online.target prized-storage.service
RequiresMountsFor=$HOME
StartLimitIntervalSec=0
[Service]
User=$USER
WorkingDirectory=$HOME
ExecStart=/bin/bash -lc 'exec ~/.config/claude-runner/hooks/spawn-runner serve'
Restart=always
RestartSec=30
KillMode=mixed
TimeoutStopSec=85
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now claude-orchestrator
journalctl -u claude-orchestrator -fserve starts a watch that deletes each sandbox once its runner has exited, then claude self-hosted-runner orchestrator. The log shows environment_id=ccpool_…, then runner for session_… starting in sbx_… as each session comes in. While the orchestrator runs, an updated box stays awake, though quiet hours and a pause deadline still pause it (Auto-pause). A Nano is enough for it.
What each session gets
- A clean machine. A
prized-agent-v1sandbox: Ubuntu 24.04, 4 vCPU, 8 GB of memory, and 10 GB of disk, with Git, Node.js 22 and Python 3 (Templates). The hook installs the box's version of Claude Code there first, so the runner is up about 15 seconds after the orchestrator gets the request, then clones the repository. - Git through Anthropic. The runner uses Anthropic-managed git and commits with the identity Anthropic's own sessions use: clones and branch pushes go through Anthropic with the GitHub account of whoever started the session. Repositories on github.com only, and only the ones picked for the session, so a private submodule or git dependency gets no credential.
- Released after 30 idle minutes. The runner exits, the sandbox is deleted, and the next message starts a fresh one from the branch the session pushed; anything Claude did not push is gone. So add Anthropic's Stop hook, which reminds Claude to push before it stops: its
settings.jsonand its script, made executable, go in~/, the script under.config/ claude-runner/ sandbox-home/ .claude/ hooks/. Before relying on resumes, restrict who can pushclaude/*branches: a resumed session fetches that branch without checking who pushed it. - Your setup. Everything in
~/on the box is copied into each sandbox's home: Claude Code settings, hooks, skills,.config/ claude-runner/ sandbox-home/ CLAUDE.md. Sessions can read all of it, so keep keys out. A.gitconfigthere is replaced, since Anthropic-managed git writes its own. - Only its own credential. The environment key and your Prized key stay on the box; a sandbox gets only its session's work order, which registers one runner once.
- Open outbound internet. A sandbox can reach any host, since sandboxes have no egress allow lists yet (Not yet), so sessions don't get the default-deny egress Anthropic's hardening guide asks for.
- A bounded life. The watch deletes a sandbox once its runner exits, keeping the runner's last log lines in
~/, and none runs past the session's limit plus an hour..config/ claude-runner/ logs/
To change the defaults, add Environment= lines under [Service], then sudo systemctl daemon-reload && sudo systemctl restart claude-orchestrator: PRIZED_VCPU and PRIZED_MEMORY_GB for the sandbox's shape, CLAUDE_IDLE_MIN (30) and CLAUDE_MAX_MIN (480, the longest a session runs before it is released), and CLAUDE_CODE_VERSION to pin one. A session is also released up to 10 minutes before its sandbox's run time ends: 9 hours by default, 60 minutes on the free tier, where a sandbox also has 2 vCPU and 4 GB at most. Anyone in your Claude organization can pick the environment, and each of their sessions is a sandbox billed per second to your Prized workspace (Billing).
Sessions on the box
To run sessions on the box itself, with its files, tools and sign-ins, start a runner there instead. Its git uses the box's own sign-in: prized setup copies your GitHub sign-in (Credentials), or run gh auth login on the box; either way, then run gh auth setup-git there. Replace you@company.com with the email you sign in to claude.ai with, then run:
sudo tee /etc/systemd/system/claude-runner.service >/dev/null <<UNIT
[Unit]
Description=Claude Code self-hosted runner
Wants=network-online.target
After=network-online.target prized-storage.service
RequiresMountsFor=$HOME
StartLimitIntervalSec=0
[Service]
User=$USER
WorkingDirectory=$HOME
ExecStart=/bin/bash -lc 'exec ~/.local/bin/claude self-hosted-runner --environment-secret-file ~/.config/claude-runner/environment-secret --base-dir ~/claude-runner --capacity 2 --lock-to-account you@company.com --push-outcome-on-release --health-port 0 --log-file ~/claude-runner.log'
Restart=always
RestartSec=30
KillMode=mixed
TimeoutStopSec=130
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now claude-runner
journalctl -u claude-runner -fThe log shows Registered: runner_id=… once the box has joined, and the service starts again at boot. The runner takes only your sessions, so everyone else's wait for a runner of their own; runs two at once, each in its own worktree under ~/claude-runner (about 4 GB of memory each), and a finished session keeps its slot until you archive it; and pushes each session's commits before a restart (restrict who can push claude/* branches first). The runner never releases a session for sitting idle, so turn auto-pause off from your machine: prized box auto-pause BOX off. Sessions run as you, with every credential on the box, and can read the environment key. Every member of your Claude organization can pick the environment, so keep it for this box, and make a new key if a session may have read it.
4. Start a session
At claude.ai/code or in the Claude app, pick your environment in the environment picker, then a repository, and send the task. From a terminal, run /remote-env once in Claude Code to make it your default, then claude --cloud "your task". Cloud scheduled tasks can run there too. Sessions use your organization's Claude Code usage as usual.
Troubleshooting
- A session stays Queued or shows Failed: read the service's log (
journalctl -u claude-orchestrator). A failed session's error is the hook's message, such asPrized refused the sandbox (422): invalid_resources: vcpufor a shape the workspace can't run; an Owner can expand the session in the environment's Activity tab. Fix the cause, then send the session a message, or an Owner selects Retry. - A session's own log: once its sandbox is deleted, the last lines of its runner's log are in
~/on the box. While it runs,.config/ claude-runner/ logs/ prized sandbox lsshows its sandbox, labelledclaude-session=session_….
PollSpawnHints rejected (HTTP 401)from the orchestrator, orRegisterRunner auth failed — environment secret invalid or revokedfrom a runner on the box: the environment key is wrong or revoked. An Owner creates a new one on the environment's Configuration tab; write it to~/, then restart the service..config/ claude-runner/ environment-secret could not read Username for 'https://github.com'on the box: the box has no git sign-in; see Sessions on the box.