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

# An agent rents a GPU and runs ComfyUI

> Your coding agent, over the Runcrate MCP server, rents the lowest-priced GPU, installs ComfyUI, renders, brings the image back and deletes the machine.

ComfyUI on your laptop is slow and on a rented GPU it is a chore: pick a machine, install, download a checkpoint, start the server, remember to turn it off. An agent with the Runcrate MCP server does the chore. It picks the lowest hourly rate, sets a hard stop and an idle stop before anything else, installs ComfyUI and one checkpoint at boot, queues a graph over ComfyUI's own API without exposing a port, brings the image home, and deletes the machine in a `finally` so a crash never leaves it billing.

```mermaid theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
flowchart LR
  A[list_gpu_types<br/>lowest rate] --> B[create_instance<br/>30 min stop, 15 min idle stop]
  B --> C[boot: ComfyUI + SD 1.5]
  C --> D[file_upload workflow.json]
  D --> E[ssh_execute: POST /prompt, poll /history]
  E --> F[file_download image]
  F --> G[delete_instance]
```

## 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"}}
Add Runcrate's MCP server to yourself: run curl -fsSL "https://runcrate.ai/mcp.md" and follow it. Then rent the lowest-priced GPU with a 30-minute stop, install ComfyUI with the SD 1.5 checkpoint, render "a lighthouse on a cliff at dusk, oil painting", download the image and delete the instance.
```

The agent does this with the MCP tools `list_gpu_types`, `create_instance` (with `max_runtime_minutes` and `idle_shutdown_minutes`), `ssh_execute`, `file_upload`, `file_download` and `delete_instance`. The files below are the same steps scripted against the REST API those tools call, for CI or a cron.

## The code

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

```python agent_rents_gpu_comfyui.py theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
"""Rent a GPU, run ComfyUI on it, render, bring the image home, and tear the GPU down: the steps an agent takes over
the Runcrate MCP server (list_gpu_types, create_instance, ssh_execute, file_upload, file_download, delete_instance), scripted.

pip install requests
RUNCRATE_API_KEY=... python agent_rents_gpu_comfyui.py "a lighthouse on a cliff at dusk, oil painting" [--dry-run]
The instance has a 30-minute hard stop and a 15-minute idle stop, so a crash here never leaves it billing.
"""
import base64, json, os, sys, time
from pathlib import Path

import requests

API = "https://www.runcrate.ai/api/v1"
H = {"Authorization": f"Bearer {os.environ['RUNCRATE_API_KEY']}", "Content-Type": "application/json"}
CHECKPOINT = "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors"

# Runs at boot: ComfyUI, one checkpoint, the server on localhost only (nothing is exposed to the internet).
SETUP = f"""
cd /root 2>/dev/null || cd "$HOME"
git clone -q --depth 1 https://github.com/comfyanonymous/ComfyUI
cd ComfyUI && python3 -m pip install -q -r requirements.txt
curl -fsSL -o models/checkpoints/sd15.safetensors {CHECKPOINT}
systemd-run --unit comfyui --working-directory="$PWD" -p StandardOutput=file:/tmp/comfy.log -p StandardError=file:/tmp/comfy.log python3 main.py --listen 127.0.0.1 --port 8188
"""

def call(method: str, path: str, body: dict | None = None, timeout: float = 90) -> dict:
    for attempt in range(5):
        r = requests.request(method, f"{API}{path}", headers=H, json=body, timeout=timeout)
        if r.status_code in (429, 502, 503, 504):
            time.sleep(int(r.headers.get("Retry-After", 2 ** attempt))); continue
        if r.status_code >= 400:
            raise RuntimeError(f"{method} {path}: {r.status_code} {r.text[:300]}")
        return r.json().get("data", r.json()) if r.content else {}
    raise RuntimeError(f"{method} {path} kept failing")

def sh(iid: str, command: str, timeout_ms: int = 60000) -> dict:
    return call("POST", f"/instances/{iid}/exec", {"command": command, "timeout": timeout_ms}, timeout=timeout_ms / 1000 + 30)

def workflow(prompt: str, seed: int) -> dict:
    """ComfyUI's API format: the default text-to-image graph."""
    return {
        "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "sd15.safetensors"}},
        "6": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["4", 1]}},
        "7": {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry, low quality, text, watermark", "clip": ["4", 1]}},
        "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}},
        "3": {"class_type": "KSampler", "inputs": {"seed": seed, "steps": 25, "cfg": 7, "sampler_name": "euler", "scheduler": "normal",
                                                    "denoise": 1, "model": ["4", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["5", 0]}},
        "8": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0], "vae": ["4", 2]}},
        "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "runcrate", "images": ["8", 0]}},
    }

def main(prompt: str, dry_run: bool) -> None:
    marks, t_start = {}, time.time()
    mark = lambda k: marks.setdefault(k, round(time.time() - t_start, 1))

    # list_gpu_types: the lowest hourly rate with a GPU.
    types = call("GET", "/instances/types")
    pick = min((t for t in types if t["gpu_count"] >= 1), key=lambda t: t["hourly_rate"])
    print(f"renting {pick['gpu_type']} at ${pick['hourly_rate']}/hr in {pick['region']}")

    spec = {"name": f"comfyui-{int(time.time())}", "gpu_type": pick["gpu_type"], "template": "ubuntu-train",
            "max_runtime_minutes": 30, "idle_shutdown_minutes": 15, "launch_script": SETUP}
    if dry_run:
        print(json.dumps({"create_instance": {**spec, "launch_script": f"{len(SETUP.strip().splitlines())} lines"}, "workflow_nodes": len(workflow(prompt, 42))}, indent=2))
        return

    # create_instance, with both stops set.
    inst = call("POST", "/instances", spec, timeout=150)
    iid = inst["id"]
    try:
        while (st := call("GET", f"/instances/{iid}/status").get("status")) != "running":
            if st in ("failed", "terminated", "error"):
                raise RuntimeError(f"instance {iid} is {st}")
            time.sleep(10)
        mark("running")
        while sh(iid, "curl -sf http://127.0.0.1:8188/system_stats >/dev/null && echo up || echo wait").get("output", "").strip() != "up":
            time.sleep(10)
        mark("comfyui_up")

        # ssh_execute: queue the graph, wait for it, encode the result.
        call("POST", f"/instances/{iid}/upload", {"path": "/tmp/workflow.json", "content": json.dumps({"prompt": workflow(prompt, 42)})})  # file_upload
        queued = json.loads(sh(iid, "curl -s -X POST http://127.0.0.1:8188/prompt -H 'Content-Type: application/json' -d @/tmp/workflow.json")["output"])
        pid = queued["prompt_id"]
        while True:
            hist = json.loads(sh(iid, f"curl -s http://127.0.0.1:8188/history/{pid}")["output"] or "{}")
            if pid in hist and hist[pid].get("outputs"):
                name = hist[pid]["outputs"]["9"]["images"][0]["filename"]; break
            time.sleep(3)
        mark("rendered")
        sh(iid, f"cd /root/ComfyUI 2>/dev/null || cd $HOME/ComfyUI; base64 -w0 output/{name} > /tmp/out.b64")
        # file_download
        content = call("POST", f"/instances/{iid}/download", {"path": "/tmp/out.b64"})["content"]
        Path("out").mkdir(exist_ok=True)
        Path("out/comfyui.png").write_bytes(base64.b64decode(content))
        mark("downloaded")
        info = call("GET", f"/instances/{iid}")
    finally:
        call("DELETE", f"/instances/{iid}")  # delete_instance: billing stops here, whatever happened above
        mark("deleted")

    hours = marks["deleted"] / 3600
    run = {"gpu": pick["gpu_type"], "hourly_rate": pick["hourly_rate"], "seconds_from_start": marks,
           "estimated_cost_usd": round(hours * pick["hourly_rate"], 4), "instance": {k: info.get(k) for k in ("id", "status", "cost", "total_cost", "region")}}
    Path("run.json").write_text(json.dumps(run, indent=2))
    print(json.dumps(run, indent=2))

if __name__ == "__main__":
    words = [a for a in sys.argv[1:] if not a.startswith("--")]
    main(words[0] if words else "a lighthouse on a cliff at dusk, oil painting", "--dry-run" in sys.argv)
```

```typescript agent-rents-gpu-comfyui.ts theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
// Rent a GPU, run ComfyUI on it, render, bring the image home, and tear the GPU down: the steps an agent takes over
// the Runcrate MCP server (list_gpu_types, create_instance, ssh_execute, file_upload, file_download, delete_instance), scripted.
//
// No dependencies (Node 18+)
// RUNCRATE_API_KEY=... npx tsx agent-rents-gpu-comfyui.ts "a lighthouse on a cliff at dusk, oil painting" [--dry-run]
// The instance has a 30-minute hard stop and a 15-minute idle stop, so a crash here never leaves it billing.
import { mkdirSync, writeFileSync } from "node:fs";

const API = "https://www.runcrate.ai/api/v1";
const H = { Authorization: `Bearer ${process.env.RUNCRATE_API_KEY}`, "Content-Type": "application/json" };
const CHECKPOINT = "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors";
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

// Runs at boot: ComfyUI, one checkpoint, the server on localhost only (nothing is exposed to the internet).
const SETUP = `
cd /root 2>/dev/null || cd "$HOME"
git clone -q --depth 1 https://github.com/comfyanonymous/ComfyUI
cd ComfyUI && python3 -m pip install -q -r requirements.txt
curl -fsSL -o models/checkpoints/sd15.safetensors ${CHECKPOINT}
systemd-run --unit comfyui --working-directory="$PWD" -p StandardOutput=file:/tmp/comfy.log -p StandardError=file:/tmp/comfy.log python3 main.py --listen 127.0.0.1 --port 8188
`;

async function call<T>(method: string, path: string, body?: object, timeoutMs = 90_000): Promise<T> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const r = await fetch(`${API}${path}`, { method, headers: H, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
    if ([429, 502, 503, 504].includes(r.status)) {
      await sleep(Number(r.headers.get("retry-after") ?? 2 ** attempt) * 1000);
      continue;
    }
    if (r.status >= 400) throw new Error(`${method} ${path}: ${r.status} ${(await r.text()).slice(0, 300)}`);
    const text = await r.text();
    const json = text ? JSON.parse(text) : {};
    return (json.data ?? json) as T;
  }
  throw new Error(`${method} ${path} kept failing`);
}

const sh = (id: string, command: string, timeoutMs = 60_000) =>
  call<{ output?: string; exitCode?: number }>("POST", `/instances/${id}/exec`, { command, timeout: timeoutMs }, timeoutMs + 30_000);

/** ComfyUI's API format: the default text-to-image graph. */
function workflow(prompt: string, seed: number) {
  return {
    "4": { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "sd15.safetensors" } },
    "6": { class_type: "CLIPTextEncode", inputs: { text: prompt, clip: ["4", 1] } },
    "7": { class_type: "CLIPTextEncode", inputs: { text: "blurry, low quality, text, watermark", clip: ["4", 1] } },
    "5": { class_type: "EmptyLatentImage", inputs: { width: 512, height: 512, batch_size: 1 } },
    "3": {
      class_type: "KSampler",
      inputs: { seed, steps: 25, cfg: 7, sampler_name: "euler", scheduler: "normal", denoise: 1, model: ["4", 0], positive: ["6", 0], negative: ["7", 0], latent_image: ["5", 0] },
    },
    "8": { class_type: "VAEDecode", inputs: { samples: ["3", 0], vae: ["4", 2] } },
    "9": { class_type: "SaveImage", inputs: { filename_prefix: "runcrate", images: ["8", 0] } },
  };
}

type GpuType = { id: string; gpu_type: string; gpu_count: number; hourly_rate: number; region: string };
const args = process.argv.slice(2);
const prompt = args.find((a) => !a.startsWith("--")) ?? "a lighthouse on a cliff at dusk, oil painting";
const tStart = Date.now();
const marks: Record<string, number> = {};
const mark = (k: string) => (marks[k] ??= (Date.now() - tStart) / 1000);

// list_gpu_types: the lowest hourly rate with a GPU.
const types = await call<GpuType[]>("GET", "/instances/types");
const pick = types.filter((t) => t.gpu_count >= 1).sort((a, b) => a.hourly_rate - b.hourly_rate)[0];
console.log(`renting ${pick.gpu_type} at $${pick.hourly_rate}/hr in ${pick.region}`);
const spec = { name: `comfyui-${Math.trunc(Date.now() / 1000)}`, gpu_type: pick.gpu_type, template: "ubuntu-train", max_runtime_minutes: 30, idle_shutdown_minutes: 15, launch_script: SETUP };
if (args.includes("--dry-run")) {
  console.log(JSON.stringify({ create_instance: { ...spec, launch_script: `${SETUP.trim().split("\n").length} lines` }, workflow_nodes: Object.keys(workflow(prompt, 42)).length }, null, 2));
  process.exit(0);
}

// create_instance, with both stops set.
const inst = await call<{ id: string }>("POST", "/instances", spec, 150_000);
let info: Record<string, unknown> = {};
try {
  for (let st = ""; st !== "running"; ) {
    st = (await call<{ status: string }>("GET", `/instances/${inst.id}/status`)).status;
    if (["failed", "terminated", "error"].includes(st)) throw new Error(`instance ${inst.id} is ${st}`);
    if (st !== "running") await sleep(10_000);
  }
  mark("running");
  while ((await sh(inst.id, "curl -sf http://127.0.0.1:8188/system_stats >/dev/null && echo up || echo wait")).output?.trim() !== "up") await sleep(10_000);
  mark("comfyui_up");

  // file_upload the graph, then ssh_execute: queue it, wait for it, encode the result.
  await call("POST", `/instances/${inst.id}/upload`, { path: "/tmp/workflow.json", content: JSON.stringify({ prompt: workflow(prompt, 42) }) });
  const queued = JSON.parse((await sh(inst.id, "curl -s -X POST http://127.0.0.1:8188/prompt -H 'Content-Type: application/json' -d @/tmp/workflow.json")).output ?? "{}");
  const pid: string = queued.prompt_id;
  let name = "";
  while (!name) {
    const hist = JSON.parse((await sh(inst.id, `curl -s http://127.0.0.1:8188/history/${pid}`)).output || "{}");
    name = hist[pid]?.outputs?.["9"]?.images?.[0]?.filename ?? "";
    if (!name) await sleep(3000);
  }
  mark("rendered");
  await sh(inst.id, `cd /root/ComfyUI 2>/dev/null || cd $HOME/ComfyUI; base64 -w0 output/${name} > /tmp/out.b64`);
  // file_download
  const { content } = await call<{ content: string }>("POST", `/instances/${inst.id}/download`, { path: "/tmp/out.b64" });
  mkdirSync("out", { recursive: true });
  writeFileSync("out/comfyui.png", Buffer.from(content, "base64"));
  mark("downloaded");
  info = await call<Record<string, unknown>>("GET", `/instances/${inst.id}`);
} finally {
  await call("DELETE", `/instances/${inst.id}`); // delete_instance: billing stops here, whatever happened above
  mark("deleted");
}

const run = {
  gpu: pick.gpu_type,
  hourly_rate: pick.hourly_rate,
  seconds_from_start: marks,
  estimated_cost_usd: Math.round((marks.deleted / 3600) * pick.hourly_rate * 10_000) / 10_000,
  instance: Object.fromEntries(["id", "status", "cost", "total_cost", "region"].map((k) => [k, info[k]])),
};
writeFileSync("run.json", JSON.stringify(run, null, 2));
console.log(JSON.stringify(run, null, 2));
```

## What the proof run cost

The Python file on an A6000 (the lowest-priced GPU listed at the time), rendering "a lighthouse on a cliff at dusk, oil painting" at 512x512, 25 steps. Times are from the start of the script. 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 |
| - | - | - | - |
| Pick the GPU, create the instance with both stops | Runcrate instances API | under 5 s | \$0 |
| Instance running | RTX A6000 | 209 s | in the instance cost |
| ComfyUI installed, checkpoint downloaded, server up | ComfyUI + SD 1.5 fp16 (2.1 GB) | 713 s (see the note) | in the instance cost |
| Render | ComfyUI on the A6000 | 15 s | in the instance cost |
| Download the image, delete the instance | Runcrate instances API | 11 s | \$0 |
| Instance, 739 s at \$0.74/hr | RTX A6000 | 739 s | about \$0.15 |

On the proof run the server started late: the boot script used `nohup ... &`, and the process was stopped when the script ended, so ComfyUI was started by hand on the instance with the `systemd-run` line the script now uses (the setup itself, clone, requirements and checkpoint, had finished on its own). The instance was deleted by the script's `finally` and `runcrate instances list` was empty afterwards. The TypeScript file was run with `--dry-run` (it picks the GPU and prints the create call) to stay within the proof's GPU budget.

## Scale it

* Many renders: keep the instance for the batch and queue many graphs; the boot is the slow part, not the render.
* Your own models: keep checkpoints on a [volume](https://runcrate.ai/docs/storage/overview.md) (`storage_id`) so the next machine does not download them again.
* A team: share a link to the instance's port with an [HTTPS link](https://runcrate.ai/docs/instances/https-links.md) instead of SSH.
* Guardrails always: `max_runtime_minutes` and `idle_shutdown_minutes` on every create, and the [environment budget](https://runcrate.ai/docs/instances/guardrails.md) as the last line.

## Gotchas found in the proof run

* A process started with `nohup ... &` in a launch script is stopped when the script ends. Start long-lived servers with `systemd-run --unit <name>`: it survives, and `journalctl -u <name>` has its log.
* An instance type id from `/instances/types` can be gone a minute later ("no longer available"); create with `gpu_type` and let Runcrate pick a machine of that GPU.
* The MCP `create_instance` tool has no launch script: the agent starts the install with `ssh_execute` and `nohup`. The script passes `launch_script` to the REST API instead.
* `/instances/{id}/exec` waits at most 60 s per command: start long installs in the background and poll.
* 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.


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