> ## Documentation Index
> Fetch the complete documentation index at: https://runcrate.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Claude Code on the free tier, GPU tests over MCP

> Run Claude Code on a free-tier model through Runcrate, and let it run your GPU tests on a real GPU through the Runcrate MCP server.

A laptop has no CUDA, so GPU code gets tested in CI that also has none, or not at all. With Claude Code pointed at a free-tier model through Runcrate's Anthropic-compatible API, the agent costs nothing to run within the daily allowance, and the MCP server gives it `run_job`: your tests go to a fresh GPU as files, the machine stops when pytest exits, and the agent reads you the result. The same three tool calls run from CI with the script below.

```mermaid theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
flowchart LR
  U[you: run the GPU tests] --> C[Claude Code<br/>DeepSeek V4.1 Flash, free tier]
  C --> R[run_job<br/>files: tests/*.py]
  R --> G[GPU job: pytest]
  G --> L[get_job_logs]
  L --> J[get_job: exit code, cost]
  J --> C
```

## Build it in your project

Paste this line into your agent (Claude Code, Codex, Cursor, OpenCode, Cline). It sets up your key, then builds the pipeline in your project:

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Set yourself up to use Runcrate's DeepSeek V4.1 Flash: run curl -fsSL "https://runcrate.ai/agents.md?model=deepseek-ai%2FDeepSeek-V4.1-Flash&intent=agent" and follow it. Then add Runcrate's MCP server to yourself: run curl -fsSL "https://runcrate.ai/mcp.md" and follow it.
```

After that, ask in plain words: "Run the tests in tests/ on a Runcrate GPU and tell me if they pass." The agent calls `run_job` with your test files, follows `get_job_logs`, and reads the exit code and cost from `get_job`. The files below do the same from CI, where no agent is signed in.

## The code

One file per language. Both read `RUNCRATE_API_KEY` from the environment; nothing secret is in the code.

```python claude_code_free_tier.py theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
"""Run your GPU tests on a Runcrate GPU from CI, through the same MCP tools Claude Code calls (run_job, get_job_logs, get_job).

pip install requests
RUNCRATE_API_KEY=... python gpu_tests.py tests/            exits with the tests' exit code
RUNCRATE_API_KEY=... python gpu_tests.py tests/ --dry-run  prints the run_job call and sends nothing
An agent signs in to the MCP server with OAuth. This script uses an API key, and today the server then also needs an
X-Project-Id header: any value works (the key already names your workspace) until that is fixed.
"""
import itertools, json, os, sys, time
from pathlib import Path

import requests

MCP = "https://mcp.runcrate.ai/mcp"
H = {"Authorization": f"Bearer {os.environ['RUNCRATE_API_KEY']}", "Content-Type": "application/json",
     "Accept": "application/json, text/event-stream", "X-Project-Id": "key-scoped"}
ids = itertools.count(1)

def rpc(method: str, params: dict | None = None) -> dict:
    for attempt in range(5):
        r = requests.post(MCP, headers=H, json={"jsonrpc": "2.0", "id": next(ids), "method": method, "params": params or {}}, timeout=90)
        if r.status_code in (429, 502, 503, 504):
            time.sleep(2 ** attempt); continue
        r.raise_for_status()
        body = r.json()
        if "error" in body:
            raise RuntimeError(body["error"])
        return body["result"]
    raise RuntimeError(f"{method} kept failing")

def tool(name: str, args: dict) -> dict:
    result = rpc("tools/call", {"name": name, "arguments": args})
    text = "".join(c.get("text", "") for c in result.get("content", []))
    if result.get("isError"):
        raise RuntimeError(f"{name}: {text}")
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return {"text": text}

def main(folder: str, dry_run: bool) -> int:
    rpc("initialize", {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "gpu-tests", "version": "1"}})
    names = {t["name"] for t in rpc("tools/list")["tools"]}
    assert {"run_job", "get_job_logs", "get_job"} <= names, "the MCP server is missing a job tool"

    # The tests travel as files (up to 1 MB); a bigger project goes as code.git instead.
    files = {str(p.relative_to(folder)): p.read_text() for p in Path(folder).rglob("*.py")}
    args = {"gpu": ["A6000", "L40S", "A4000", "RTX4000Ada"], "template": "ubuntu-train", "max_runtime_minutes": 10,
            "command": "python3 -m pip install -q pytest && python3 -m pytest -q", "files": files,
            "name": "gpu-tests", "tags": {"ci": "gpu-tests"}}
    if dry_run:
        print(json.dumps({"tool": "run_job", "arguments": {**args, "files": {k: f"{len(v)} chars" for k, v in files.items()}}}, indent=2))
        return 0

    t0 = time.time()
    job = tool("run_job", args)
    jid = job.get("id") or job.get("job", {}).get("id")
    print(f"job {jid} on {job.get('gpu_type', '')}")
    after = 0
    while True:  # get_job_logs waits up to 25 s for new output each call
        page = tool("get_job_logs", {"id": jid, "after": after, "follow": True})
        for chunk in page.get("chunks", []):
            print(chunk.get("text", ""), end="", flush=True)
        after = page.get("cursor", after)
        if page.get("done"):
            break
    done = tool("get_job", {"id": jid})
    cost = sum((a.get("cost_usd") or 0) for a in done.get("attempts", []))
    run = {"job": jid, "status": done.get("status"), "exit_code": done.get("exit_code"), "gpu": done.get("gpu_type"),
           "seconds": round(time.time() - t0, 1), "cost_usd": cost}
    Path("run.json").write_text(json.dumps(run, indent=2))
    print(f"\n{json.dumps(run)}")
    return done.get("exit_code") if isinstance(done.get("exit_code"), int) else 1

if __name__ == "__main__":
    sys.exit(main(sys.argv[1], "--dry-run" in sys.argv))
```

```typescript claude-code-free-tier.ts theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
// Run your GPU tests on a Runcrate GPU from CI, through the same MCP tools Claude Code calls (run_job, get_job_logs, get_job).
//
// No dependencies (Node 18+)
// RUNCRATE_API_KEY=... npx tsx gpu-tests.ts tests/            exits with the tests' exit code
// RUNCRATE_API_KEY=... npx tsx gpu-tests.ts tests/ --dry-run  prints the run_job call and sends nothing
// An agent signs in to the MCP server with OAuth. This script uses an API key, and today the server then also needs an
// X-Project-Id header: any value works (the key already names your workspace) until that is fixed.
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";

const MCP = "https://mcp.runcrate.ai/mcp";
const H = {
  Authorization: `Bearer ${process.env.RUNCRATE_API_KEY}`,
  "Content-Type": "application/json",
  Accept: "application/json, text/event-stream",
  "X-Project-Id": "key-scoped",
};
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
let nextId = 1;

type RpcResult = { tools?: { name: string }[]; content?: { text?: string }[]; isError?: boolean };
type Job = { id?: string; job?: { id: string }; gpu_type?: string; status?: string; exit_code?: number | null; attempts?: { cost_usd?: number }[] };
type LogsPage = { chunks?: { text?: string }[]; cursor?: number; done?: boolean };

async function rpc(method: string, params: object = {}): Promise<RpcResult> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const r = await fetch(MCP, { method: "POST", headers: H, body: JSON.stringify({ jsonrpc: "2.0", id: nextId++, method, params }) });
    if ([429, 502, 503, 504].includes(r.status)) {
      await sleep(2 ** attempt * 1000);
      continue;
    }
    if (!r.ok) throw new Error(`${method}: ${r.status} ${await r.text()}`);
    const body = (await r.json()) as { error?: unknown; result: RpcResult };
    if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
    return body.result;
  }
  throw new Error(`${method} kept failing`);
}

/** Call a tool; its text answer is the JSON of T (the shapes above are the fields this script reads). */
async function tool<T>(name: string, args: object): Promise<T> {
  const result = await rpc("tools/call", { name, arguments: args });
  const text = (result.content ?? []).map((c) => c.text ?? "").join("");
  if (result.isError) throw new Error(`${name}: ${text}`);
  return JSON.parse(text) as T;
}

const [folder, ...flags] = process.argv.slice(2);
await rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "gpu-tests", version: "1" } });
const names = new Set((await rpc("tools/list")).tools?.map((t) => t.name));
for (const t of ["run_job", "get_job_logs", "get_job"]) if (!names.has(t)) throw new Error(`the MCP server is missing ${t}`);

// The tests travel as files (up to 1 MB); a bigger project goes as code.git instead.
const files: Record<string, string> = {};
for (const f of (readdirSync(folder, { recursive: true }) as string[]).filter((f) => f.endsWith(".py"))) {
  files[relative(folder, join(folder, f))] = readFileSync(join(folder, f), "utf8");
}
const args = {
  gpu: ["A6000", "L40S", "A4000", "RTX4000Ada"],
  template: "ubuntu-train",
  max_runtime_minutes: 10,
  command: "python3 -m pip install -q pytest && python3 -m pytest -q",
  files,
  name: "gpu-tests",
  tags: { ci: "gpu-tests" },
};
if (flags.includes("--dry-run")) {
  const shown = Object.fromEntries(Object.entries(files).map(([k, v]) => [k, `${v.length} chars`]));
  console.log(JSON.stringify({ tool: "run_job", arguments: { ...args, files: shown } }, null, 2));
  process.exit(0);
}

const t0 = Date.now();
const job = await tool<Job>("run_job", args);
const jid = job.id ?? job.job?.id ?? "";
console.log(`job ${jid} on ${job.gpu_type ?? ""}`);
let after = 0;
for (;;) {
  // get_job_logs waits up to 25 s for new output each call
  const page = await tool<LogsPage>("get_job_logs", { id: jid, after, follow: true });
  for (const chunk of page.chunks ?? []) process.stdout.write(chunk.text ?? "");
  after = page.cursor ?? after;
  if (page.done) break;
}
const done = await tool<Job>("get_job", { id: jid });
const cost = (done.attempts ?? []).reduce((s, a) => s + (a.cost_usd ?? 0), 0);
const run = { job: jid, status: done.status, exit_code: done.exit_code, gpu: done.gpu_type, seconds: (Date.now() - t0) / 1000, cost_usd: cost };
writeFileSync("run.json", JSON.stringify(run, null, 2));
console.log(`\n${JSON.stringify(run)}`);
process.exit(typeof done.exit_code === "number" ? done.exit_code : 1);
```

## What the proof run cost

Claude Code 2.1.280, headless (`claude -p`), on DeepSeek V4.1 Flash through Runcrate, with the Runcrate MCP server, asked to run a three-test CUDA suite (`torch.cuda.is_available`, a 1024x1024 matmul against the CPU, bf16 autocast) on a GPU. Measured on 2026-10-23 against `api.runcrate.ai`; costs are what the account was billed, read from each response's `X-LLM-Cost` header or `usage.cost`.

| Step | Model or tool | Time | Cost |
| - | - | - | - |
| 15 agent turns: read the tests, run\_job, follow logs, get\_job | deepseek-ai/DeepSeek-V4.1-Flash (free tier) | 536.5 s in all | \$0.0000 |
| Wait for a machine and its driver | A6000 job (ubuntu-train) | about 4.5 min | in the job cost |
| pytest, 3 passed | on the GPU | 34 s (5 s of tests) | in the job cost |
| The GPU job, as get\_job reported it | RTX A6000, \$0.74/hr listed | | \$0.03 |
| Total billed | | about 9 min | \$0.03 |

Claude Code printed `total_cost_usd: 1.35` for the session: that is its own estimate at Anthropic's prices for a model it does not recognise, not what was billed. The model calls ran on the free tier. The two CI files were run with `--dry-run` (they connect to the MCP server, list its tools and print the `run_job` call) so the suite ran once, from the agent.

## Scale it

* Every pull request: run the script in CI with an API key; it exits with pytest's exit code.
* A matrix: `count` or `params` on `run_job` runs the suite on several GPUs or CUDA versions as one group.
* A bigger project: send `code.git` with a repository URL instead of `files` (1 MB in all).

## Gotchas found in the proof run

* Claude Code logs `unrecognized_model` for a Runcrate model id and prices the session as if it were Anthropic's; read the cost from `get_job` and your Runcrate usage instead.
* Claude Code assumes a 200K context for a model it does not know; set `CLAUDE_CODE_MAX_CONTEXT_TOKENS` to the model's window (1,048,576 for DeepSeek V4.1 Flash).
* Set `ANTHROPIC_DEFAULT_HAIKU_MODEL` and `ANTHROPIC_SMALL_FAST_MODEL` too, or Claude Code's background calls ask for a model the API does not have.
* With an API key instead of the OAuth sign-in, the MCP server today also needs an `X-Project-Id` header; any value works, as the key already names your workspace.
* `ubuntu-train` has PyTorch for CUDA 12.8 but not pytest: the command installs it first.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.