Runtime (withruntime.com) gives the overnight run a Firecracker microVM of its own. The microVM keeps working after you close your laptop, holds none of your keys, and pauses itself when the agent is done. A two-hour run costs about $0.07 in compute. This post gives the complete setup: three secrets, one script that runs inside the sandbox, one launcher, and a morning check. Copy the four pieces, and the first pull request can be waiting for you tomorrow.
Why run Claude Code overnight in a sandbox?
An overnight run needs three things that your laptop cannot provide: a machine that stays on, permission to run any command without asking you, and no data on that machine worth stealing.
Claude Code in headless mode (claude -p) does real work with no one
watching. It does so only if every tool is approved in advance. On your laptop,
that means approving Bash for a process that reads issue text, dependency
output and web pages while you sleep. In a sandbox, approving everything is the
safe choice, because the sandbox is the boundary. The agent can run any command
inside it and reach nothing outside it.
The Claude Code integration page covers a supervised run where your script waits for the result. This post covers the other pattern: your script starts the run and exits, and the result is a pull request.
What does the setup look like?
The setup has four pieces. Each one is small, and only the first is done once:
| Piece | Where it runs | What it does |
|---|---|---|
| Secrets | Once, from your terminal | Stores the Anthropic and GitHub credentials where no sandbox can read them |
run.sh |
Inside the sandbox | Installs Claude Code, runs the task, pushes, opens the pull request |
| Launcher | Your machine, cron or CI | Creates the sandbox, starts run.sh in the background, exits |
| Morning check | Your machine | Lists last night's runs and their pull requests |
The agent's process lives in the sandbox, not in your launcher, so closing the terminal, losing Wi-Fi or rebooting changes nothing.
Step 1: How do you give it keys it cannot leak?
Store each credential as a Runtime secret, bound to the one host that needs it. Every sandbox in the account then sees a placeholder in that variable. The host's proxy puts the real value into HTTPS requests to that host only (secrets sandboxes never see).
Terminal# Claude Code reads ANTHROPIC_API_KEY; the sandbox gets a placeholder.printf %s "$ANTHROPIC_API_KEY" | npx withruntime secrets set ANTHROPIC_API_KEY --host api.anthropic.comGitHub needs two forms of the same fine-grained token. Limit the token to the
repositories the agent works on, with "Contents" and "Pull requests" write
access. git push sends it as HTTP Basic credentials to github.com. The REST
API takes it as a bearer token on api.github.com. With header, the proxy
sets the header itself, so neither git nor curl ever holds a token:
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const token = process.env.GITHUB_TOKEN ?? "";await runtime.secrets.set("GIT_GITHUB_AUTH", { value: Buffer.from(`x-access-token:${token}`).toString("base64"), hosts: ["github.com"], header: "Authorization", format: "Basic {value}",});await runtime.secrets.set("GITHUB_API_AUTH", { value: token, hosts: ["api.github.com"], header: "Authorization", format: "Bearer {value}",});Pythonimport base64import osfrom withruntime import Runtimeruntime = Runtime()token = os.environ["GITHUB_TOKEN"]runtime.secrets.set("GIT_GITHUB_AUTH", value=base64.b64encode(f"x-access-token:{token}".encode()).decode(), hosts=["github.com"], header="Authorization", format="Basic {value}")runtime.secrets.set("GITHUB_API_AUTH", value=token, hosts=["api.github.com"], header="Authorization", format="Bearer {value}")An injection that tells the agent to print its environment at 3 a.m. prints only placeholders. How prompt injection becomes code execution explains why this matters more than any filter on the model.
Step 2: What runs inside the sandbox?
run.sh does the whole night's work and writes everything to a log. Save it
next to your launcher:
Terminal#!/usr/bin/env bash# run.sh REPO BASE TASK_FILE: runs inside the sandbox, unattended.set -euo pipefailexec >>/workspace/agent.log 2>&1REPO="$1" BASE="$2" TASK_FILE="$3"BRANCH="agent/overnight-$(date -u +%Y%m%d-%H%M)"TITLE="$(head -1 "$TASK_FILE")"echo "== $(date -u +%FT%TZ) start $REPO"npm install -g --prefix /workspace/.local @anthropic-ai/claude-codegit clone --depth 50 --branch "$BASE" "https://github.com/$REPO.git" /workspace/appcd /workspace/appgit switch -c "$BRANCH"# The agent: headless, every tool approved, the transcript kept one event per line.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude --bare -p "$(cat "$TASK_FILE")" \ --allowedTools "Bash,Read,Edit,Write" \ --output-format stream-json --verbose > /workspace/transcript.jsonlif [ -z "$(git status --porcelain)" ]; then echo "== no changes"; exit 0; figit add -Agit -c user.name="Overnight Agent" -c user.email=agent@example.com commit -m "$TITLE"git push -u origin "$BRANCH"# A draft pull request through the API secret: no token in this script or this machine.jq -n --arg base "$BASE" --arg head "$BRANCH" --arg title "$TITLE" --rawfile task "$TASK_FILE" \ '{base: $base, head: $head, title: $title, draft: true, body: ("Opened overnight by Claude Code in a Runtime sandbox.\n\n## Task\n\n" + $task)}' | curl -sf -X POST "https://api.github.com/repos/$REPO/pulls" \ -H "Accept: application/vnd.github+json" --data-binary @- | jq -r .html_url > /workspace/pr-url.txtecho "== $(date -u +%FT%TZ) opened $(cat /workspace/pr-url.txt)"What each part is for:
exec >>/workspace/agent.logsends every line of output, Claude Code's progress included, to one file you can read in the morning.--prefix /workspace/.localinstallsclaudeinto a directory that is first on the sandbox'sPATH. Nosudois needed.--bareskips local configuration and readsANTHROPIC_API_KEYfrom the environment, which holds the placeholder.--allowedToolsapproves the tools in advance, since nobody is there to answer a prompt.stream-json --verbosekeeps the full transcript, one JSON event per line, so you can see why the agent did what it did.draft: trueopens the pull request as a draft. Nothing merges, and no reviewer is pinged until you have looked.
Write the task as a short Markdown file. Its first line is the title. Ask for the tests to run and pass. The agent treats that as part of the job.
Step 3: How do you start it and walk away?
The launcher creates a sandbox, copies in the script and the task, starts the script as a background process, and exits:
TypeScriptimport { readFile } from "node:fs/promises";import { Sandbox } from "withruntime";const repo = "acme/app"; // owner/name on GitHubconst night = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");const sbx = await Sandbox.create({ name: `overnight-${night}`, labels: { kind: "overnight" }, diskMiB: 8192, idlePauseSeconds: 900, // pause 15 quiet minutes after the agent is done network: { internet: true, allow: ["api.anthropic.com", "registry.npmjs.org", "github.com", "api.github.com"], },});await sbx.files.write("/workspace/run.sh", await readFile("run.sh", "utf8"), { mode: 0o755 });await sbx.files.write("/workspace/task.md", await readFile("task.md", "utf8"));const agent = await sbx.spawn(["/workspace/run.sh", repo, "main", "/workspace/task.md"]);console.log(`Started ${sbx.info.name} (${sbx.id}), process ${agent.id}. Close the laptop.`);Pythonimport datetimefrom withruntime import Sandboxrepo = "acme/app" # owner/name on GitHubnight = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d%H%M")sbx = Sandbox.create( name=f"overnight-{night}", labels={"kind": "overnight"}, disk_mib=8192, idle_pause_seconds=900, # pause 15 quiet minutes after the agent is done network={"internet": True, "allow": ["api.anthropic.com", "registry.npmjs.org", "github.com", "api.github.com"]},)with open("run.sh") as script, open("task.md") as task: sbx.files.write("/workspace/run.sh", script.read(), mode=0o755) sbx.files.write("/workspace/task.md", task.read())agent = sbx.spawn(["/workspace/run.sh", repo, "main", "/workspace/task.md"])print(f"Started {sbx.info['name']} ({sbx.id}), process {agent.id}. Close the laptop.")Two settings handle the night:
idlePauseSeconds: 900. A long model call can look quiet from outside the sandbox. The sandbox therefore waits fifteen idle minutes before it pauses. Once the agent is done, it pauses and keeps its files, memory and processes.- A daily spending limit. Set one on the key the launcher uses. Then no number of runs, stuck or not, can spend more in a day than you chose (set a daily spending limit).
The allow list covers the model, npm and GitHub. Add your project's own
registries, such as pypi.org and files.pythonhosted.org, if the tests
install anything. To start a run every weeknight, call the launcher from cron
on any machine that is on, or from a scheduled workflow
(run sandboxes from GitHub Actions).
Step 4: What do you check in the morning?
The pull request is on GitHub, but the sandbox still has the log and the transcript. List last night's runs and what each produced:
TypeScriptimport { Runtime } from "withruntime";const runtime = new Runtime();const page = await runtime.sandboxes.list({ labels: { kind: "overnight" }, state: ["running", "paused"],});let count = 0;for await (const sbx of page) { count += 1; const opened = await sbx.files.exists("/workspace/pr-url.txt"); const pr = opened ? (await sbx.files.readText("/workspace/pr-url.txt")).trim() : "no pull request"; console.log(`${sbx.info.name}: ${sbx.state}, ${pr}`);}console.log(`${count} overnight runs`);Pythonfrom withruntime import Runtimeruntime = Runtime()count = 0for sbx in runtime.sandboxes.list(labels={"kind": "overnight"}, state=["running", "paused"]): count += 1 opened = sbx.files.exists("/workspace/pr-url.txt") pr = sbx.files.read_text("/workspace/pr-url.txt").strip() if opened else "no pull request" print(f"{sbx.info['name']}: {sbx.state}, {pr}")print(f"{count} overnight runs")Reading a file wakes a paused sandbox. A run with no pull request has its
answer in /workspace/agent.log: a failed install, tests that did not pass,
or "no changes".
If the review asks for changes, ask the same agent in the same checkout. The sandbox kept everything, so Claude Code can continue its own session:
Terminalnpx withruntime sandboxes exec <sandbox-id> --cwd /workspace/app --timeout 3600 -- \ claude --bare -p --continue "Address the review: keep the old function name as an alias." \ --allowedTools "Bash,Read,Edit,Write"Delete the sandbox when the pull request merges.
What does a night cost?
You pay Anthropic for the tokens, and you pay Runtime for the machine. Take a two-hour run on 2 vCPU and 4 GiB, in which the commands use 900 CPU-seconds. That is fifteen minutes of one core, between model calls:
- CPU
- 900 CPU-seconds / 3,600 × $0.025
- $0.0063
- Memory
- 2 hours × 4 GiB × $0.0075
- $0.0600
- Quiet 15 minutes before the pause, at $0.03125 an hour
- $0.0078
- Total
- $0.0741
After that, the sandbox is paused. A paused sandbox is billed only for storage: $0.08 per decimal GB per 30-day month, for what it alone stores (pricing). Twenty-two weeknights of such runs cost $1.63 in compute. The model tokens will be most of the bill.
In short
- Run Claude Code headless in a sandbox, where approving every tool is safe because the sandbox is the boundary.
- Store the Anthropic and GitHub credentials as Runtime secrets. The agent uses them and never holds them.
run.shdoes the work and opens a draft pull request. The launcher starts it withspawnand exits.- A fifteen-minute idle pause and a daily spending limit handle the night.
- In the morning, the pull request is on GitHub and the sandbox still holds the log, the transcript and the checkout.
Run it on Runtime
Runtime costs 42% to 88% less than fourteen other sandbox providers for an agent that mostly waits on a model (compare costs). It runs Claude Code's npm package unchanged on Ubuntu 24.04 with Node.js 24, and it bills $0.025 per vCPU-hour of CPU actually used. Every account gets 100 free hours of a 1 GB machine every month, without a credit card. Sign in or follow get started. Then point the launcher at a small task tonight.