On Runtime, a CI job pays for the CPU its tests use, not for the time a runner holds a VM. A 2 vCPU, 4 GiB sandbox costs $0.03125 an hour while it waits. With both CPUs busy, it costs $0.08 an hour. Each job gets a fresh Firecracker microVM with its own kernel. So a test that runs untrusted or agent-written code cannot reach the runner.
1. Make a key for CI
A runner has no browser, so it uses a key. Make one from your own terminal, and pipe it directly into the repository's secrets with the GitHub CLI:
Terminalnpx withruntime keys create --name ci --daily-limit 25 | gh secret set RUNTIME_API_KEYThe command prints a link and a code. An owner, admin or developer of the
account opens the link, checks the code, reviews the key's name, access and
limit, and chooses Create key. The key is then printed once, alone, on
standard output. So it goes to gh secret set and nowhere else. Runtime keeps
only a hash of the key.
--daily-limit 25 caps what this key may commit in any 24 hours, at $25. A job
past the cap gets spending_limit_reached and is not charged.
2. Add the workflow
YAML# .github/workflows/sandbox-tests.ymlname: sandbox-testson: [push, pull_request]jobs: test: runs-on: ubuntu-latest timeout-minutes: 20 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - name: Run the suite in a sandbox env: RUNTIME_API_KEY: ${{ secrets.RUNTIME_API_KEY }} run: npx withruntime sandboxes run --vcpu 2 -- python3 -c 'print(sum([125, 250, 375]))'sandbox run creates a sandbox, runs the command, prints its output, and stops
the sandbox. It exits with the command's own exit code, so a failing test fails
the step. The CLI needs Node 22.12 or later, which setup-node pins.
3. Ship your code into the sandbox
For a real suite, use a short script that copies the checkout into the sandbox
and runs it. Call the script from a step with npx tsx ci/sandbox.ts or
python3 ci/sandbox.py, with the same env block:
TypeScriptimport { Runtime } from "withruntime";// Queue for up to ten minutes when many jobs start at once.const runtime = new Runtime({ waitForCapacityMs: 600_000 });await using sbx = await runtime.sandboxes.create({ timeoutSeconds: 1200, onLeaseEnd: "stop" });await sbx.files.upload(".", "/workspace/repo");const run = await sbx.exec("cd repo && pip install -r requirements.txt && python3 -m pytest -q", { timeoutMs: 900_000, // the default is 60 seconds onStdout: (text) => process.stdout.write(text), onStderr: (text) => process.stderr.write(text),});process.exitCode = run.exitCode ?? 1;Pythonimport sysfrom withruntime import Runtimewith Runtime(wait_for_capacity=600) as runtime: with runtime.sandboxes.create(timeout_seconds=1200, on_lease_end="stop") as sbx: sbx.files.upload(".", "/workspace/repo") run = sbx.exec( "cd repo && pip install -r requirements.txt && python3 -m pytest -q", timeout_ms=900_000, # the default is 60 seconds ) print(run.stdout, run.stderr) sys.exit(0 if run.exit_code == 0 else 1)await using and with delete the sandbox when the block ends, even after an
exception. So a failed job leaves nothing running.
Settings that matter in CI
| Setting | What it does in a workflow |
|---|---|
RUNTIME_API_KEY |
Overrides any saved connection; the only credential a runner needs |
CI (set by GitHub) |
A missing key is an error instead of a browser approval |
--daily-limit on keys create |
The most the key may commit in 24 hours |
--read-only on keys create |
A key that reads everything and changes nothing, for status checks |
waitForCapacityMs / wait_for_capacity |
How long a create queues for room; two minutes by default |
timeoutSeconds, onLeaseEnd: "stop" |
A hard end for a sandbox a crashed job never stopped |
--no-internet, --allow <host> |
Network rules for the sandbox, set on the host |
A paid account runs 100 sandboxes at the same time. An account without credit runs four. A burst of matrix jobs past either limit waits in the SDK instead of failing.
Mistakes and how Runtime handles them
- Pull requests from forks. GitHub passes no secrets, except
GITHUB_TOKEN, to a workflow triggered from a forked repository. The step then has no key. BecauseCIis set, the CLI fails immediately with a missing-key error, rather than waiting on a login link. - Putting the key in the command line. The key would appear in the log and
the process list. Pass it only through
env, as above. The SDKs and CLI readRUNTIME_API_KEYby themselves. - Losing the key. It is printed once. Revoke it at API keys, and make another. No key can create, list or revoke keys.
- A suite that runs past a minute.
exectimes out after 60 seconds by default. It returnstimedOut: truewith the output so far. So the scripts above raisetimeoutMsto 15 minutes. The maximum is 24 hours. - A retried job creating a second sandbox. Every SDK write carries an idempotency key. Timeouts, 429 and 503 responses are retried with that key. So a retried create never makes two sandboxes.
- Someone leaves the team. Keys they made are revoked when their membership ends. Create the CI key as a long-standing member, or make a new one.
Related
- The runtime CLI for every
keys createoption - How to call the REST API with curl for steps with no Node
- Agent evals and SWE-bench for large parallel runs
- How to turn off sandbox internet
Sources
Checked 26 October 2026.
- Using secrets in GitHub Actions
- Workflow syntax for GitHub Actions
- Events that trigger workflows
- actions/setup-node
Facts on this page were checked on 26 October 2026.