Tutorial: build an AI app generator
In this tutorial you will build a small script that takes a plain-English prompt, asks Claude to write a web app, uploads that app into a live VM sandbox, starts it, and hands you a public URL you can open in a browser. That is the SDK's flagship loop: LLM generates → VM runs → ingress serves.
What you'll learn
- Spawning a sandbox with public ingress enabled
- Calling the Anthropic Messages API to generate code
- Uploading a file into the sandbox with
sandbox.files.upload - Backgrounding a server and waiting for it with
waitForPortReady - Resolving a live preview URL with
sandbox.previewUrl - Tearing down cleanly with
sandbox.destroyin afinallyblock
Prerequisites
- Node 20+ or Bun (this tutorial uses
bun) - A createos-sandbox API key and the URL of your control plane
- An Anthropic API key
Estimated time: 20 minutes
At a glance
- Package:
@nodeops-createos/sandbox(npm) - Import:
import { createClient } from "@nodeops-createos/sandbox" - Base URL:
https://api.sb.createos.sh— override withCREATEOS_SANDBOX_BASE_URL - Auth: API key via the
apiKeyoption orCREATEOS_SANDBOX_API_KEY
Step 1 — Set up
Install the two packages you need:
Shell1bun add @nodeops-createos/sandbox @anthropic-ai/sdk
Export your credentials as environment variables. The SDK reads both automatically — you never need to pass them explicitly:
Shell1export CREATEOS_SANDBOX_BASE_URL="https://api.sb.createos.sh"2export CREATEOS_SANDBOX_API_KEY="sk_…"3export ANTHROPIC_API_KEY="sk-ant-…"
Create a file called ai-app-gen.ts and paste in this three-liner to verify
connectivity before writing the real code:
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";23const client = new CreateosSandboxClient();4console.log(await client.whoami());
Run it:
Shell1bun ai-app-gen.ts
Expected output: a JSON object with your user identity — something like
{ id: "usr_…", email: "you@example.com" }. If you see a
CreateosSandboxAuthError, double-check your env vars.
Step 2 — Spawn a sandbox with ingress on
Delete the three-liner and start the real script. The key option here is
ingress_enabled: true — without it the control plane does not provision a
public hostname, and previewUrl has nothing to route to.
TypeScript1import { CreateosSandboxClient } from "@nodeops-createos/sandbox";2import Anthropic from "@anthropic-ai/sdk";34const client = new CreateosSandboxClient();56const sandbox = await client.createSandbox({7 shape: "s-4vcpu-4gb", // comfortable headroom for a Node process8 rootfs: "devbox:1",9 ingress_enabled: true,10});1112// Resolve the preview URL now — the hostname is already provisioned.13// Use scheme: "http" until your ingress domain has a TLS certificate.14const previewUrl = sandbox.previewUrl(3000, { scheme: "http" });1516console.log("sandbox id :", sandbox.id);17console.log("status :", sandbox.status);18console.log("preview URL :", previewUrl);
Expected output:
sandbox id : sb_01…
status : running
preview URL : http://sb_01….your-ingress-domain/
createSandbox blocks until the sandbox reaches running by default, so
sandbox.status will already be "running" here.
Step 3 — Ask Claude to generate the app
Now bring in the Anthropic client. The call below asks Claude for a
self-contained Node HTTP server — no external dependencies — that binds to
0.0.0.0:3000 so the ingress proxy can reach it.
TypeScript1// Reads ANTHROPIC_API_KEY from the environment automatically.2const anthropic = new Anthropic();34const PROMPT =5 "Write a single-file Node.js HTTP server with zero npm dependencies. " +6 "It must bind to 0.0.0.0:3000 and serve an HTML page that shows a " +7 "live clock updating every second. Output only the JavaScript source " +8 "code, no explanation, no markdown fences.";910const response = await anthropic.messages.create({11 // ANTHROPIC_MODEL env var lets you swap models without touching code.12 model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6",13 max_tokens: 2048,14 messages: [{ role: "user", content: PROMPT }],15});1617// The response may contain multiple content blocks; the code is in the18// first text block.19const textBlock = response.content.find((b) => b.type === "text");20if (!textBlock || textBlock.type !== "text") {21 throw new Error("Claude returned no text block");22}23const code = textBlock.text;2425console.log(`generated code: ${code.length} characters`);
Expected output: generated code: 512 characters (length varies).
The model is swappable — any model that follows the Anthropic Messages API
works here. Set ANTHROPIC_MODEL to claude-opus-4-8 or any other id to
compare results without touching the script.
Step 4 — Upload the generated code into the sandbox
sandbox.files.upload takes an absolute guest path and any BodyInit value —
a plain string is fine.
TypeScript1await sandbox.files.upload("/root/app.js", code);23// Confirm the file landed.4const { result } = await sandbox.runCommand("ls", ["-lh", "/root"]);5console.log(result.stdout);
Expected output: a directory listing that includes app.js.
If exit_code is non-zero, something went wrong with the upload or the path;
result.stderr will say what.
Guest paths must be absolute. Parent directories must already exist — use
sandbox.runCommand("mkdir", ["-p", "/some/path"])if you need to create them first. See how-to: files for more.
Step 5 — Run the app
runCommand waits for the process to exit. To keep a server alive you must
background it and redirect its stdio, otherwise the call blocks forever:
TypeScript1await sandbox.runCommand("sh", [2 "-c",3 "nohup setsid node /root/app.js >/tmp/app.log 2>&1 &",4]);56// Block until port 3000 accepts TCP connections inside the VM.7// This fires before the ingress route matters, so it's a reliable gate.8await sandbox.waitForPortReady(3000, { timeoutMs: 15_000 });910console.log("server is listening on :3000");
Expected output: server is listening on :3000 — printed once the port
is bound.
The daemonise pattern is: nohup (ignore SIGHUP) + setsid (new session, no
controlling terminal) + >/tmp/app.log 2>&1 (detach stdio) + & (background
the shell). All four pieces matter. See
how-to: expose a service for a deeper
explanation.
Step 6 — Open the live preview URL
You already have previewUrl from Step 2. Fetch it to confirm the app
responds, then open the URL in a browser:
TypeScript1const res = await fetch(previewUrl);23console.log("preview URL :", previewUrl);4console.log("HTTP status :", res.status);56if (!res.ok) {7 const body = await res.text();8 throw new Error(`app returned HTTP ${res.status}:\n${body}`);9}1011console.log("\nOpen this URL in your browser:");12console.log(previewUrl);
Expected output:
preview URL : http://sb_01….your-ingress-domain/
HTTP status : 200
Open this URL in your browser:
http://sb_01….your-ingress-domain/
Paste the URL into your browser. You should see the live-clock page Claude generated.
Step 7 — Iterate (optional)
The real power of this pattern is that the generate → upload → run → preview loop is repeatable. Ask Claude to add a feature, re-upload the updated file, restart the server, and re-fetch:
TypeScript1const iterateResponse = await anthropic.messages.create({2 model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6",3 max_tokens: 2048,4 messages: [5 { role: "user", content: PROMPT },6 { role: "assistant", content: response.content },7 {8 role: "user",9 content:10 "Good. Now add a visitor counter below the clock. " +11 "It should count how many times the page has been loaded since " +12 "the server started. Keep everything in one file, no deps. " +13 "Output only the updated JavaScript, no markdown fences.",14 },15 ],16});1718const updatedBlock = iterateResponse.content.find((b) => b.type === "text");19if (!updatedBlock || updatedBlock.type !== "text") {20 throw new Error("Claude returned no text block on iteration");21}22const updatedCode = updatedBlock.text;2324// Re-upload and restart.25await sandbox.files.upload("/root/app.js", updatedCode);2627// Kill the old server process, then start the new one.28await sandbox.runCommand("sh", ["-c", "pkill -f 'node /root/app.js' || true"]);29await sandbox.runCommand("sh", [30 "-c",31 "nohup setsid node /root/app.js >/tmp/app.log 2>&1 &",32]);33await sandbox.waitForPortReady(3000, { timeoutMs: 15_000 });3435const res2 = await fetch(previewUrl);36console.log("iteration HTTP status:", res2.status);37console.log("Reload the preview URL to see the visitor counter.");
Each iteration is just another pass through the same loop. You can keep refining until you're satisfied, then tear down.
Step 8 — Tear down
Always destroy the sandbox in a finally block so it is reclaimed even when
earlier steps throw:
TypeScript1} finally {2 await sandbox.destroy().catch((err) => {3 console.error(4 "cleanup: destroy failed:",5 err instanceof Error ? err.message : String(err),6 );7 });8 console.log("sandbox destroyed");9}
The .catch inside finally prevents a destroy failure from masking the
original error.
Complete script
Here is the full script, steps 1–8 assembled into a single runnable file.
Copy it into ai-app-gen.ts and run with bun ai-app-gen.ts.
TypeScript1/**2 * AI app generator — Claude writes a web app, the sandbox runs it, ingress3 * serves it at a live preview URL.4 *5 * Run: bun ai-app-gen.ts6 * Needs: CREATEOS_SANDBOX_BASE_URL + CREATEOS_SANDBOX_API_KEY7 * ANTHROPIC_API_KEY8 * ANTHROPIC_MODEL (optional — defaults to claude-sonnet-4-6)9 */10import { CreateosSandboxClient } from "@nodeops-createos/sandbox";11import Anthropic from "@anthropic-ai/sdk";1213// Both clients read credentials from env automatically.14const client = new CreateosSandboxClient();15const anthropic = new Anthropic();1617// ── Step 2: spawn a sandbox with public ingress ───────────────────────────18const sandbox = await client.createSandbox({19 shape: "s-4vcpu-4gb",20 rootfs: "devbox:1",21 ingress_enabled: true,22});2324// Resolve the preview URL now; the hostname is already provisioned.25const previewUrl = sandbox.previewUrl(3000, { scheme: "http" });2627console.log("sandbox id :", sandbox.id);28console.log("status :", sandbox.status);29console.log("preview URL :", previewUrl);3031try {32 // ── Step 3: ask Claude to generate the app ─────────────────────────────33 const PROMPT =34 "Write a single-file Node.js HTTP server with zero npm dependencies. " +35 "It must bind to 0.0.0.0:3000 and serve an HTML page that shows a " +36 "live clock updating every second. Output only the JavaScript source " +37 "code, no explanation, no markdown fences.";3839 const response = await anthropic.messages.create({40 model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6",41 max_tokens: 2048,42 messages: [{ role: "user", content: PROMPT }],43 });4445 const textBlock = response.content.find((b) => b.type === "text");46 if (!textBlock || textBlock.type !== "text") {47 throw new Error("Claude returned no text block");48 }49 const code = textBlock.text;50 console.log(`generated code: ${code.length} characters`);5152 // ── Step 4: upload the generated code ─────────────────────────────────53 await sandbox.files.upload("/root/app.js", code);5455 const { result: lsResult } = await sandbox.runCommand("ls", ["-lh", "/root"]);56 console.log(lsResult.stdout);5758 // ── Step 5: start the server and wait for it ───────────────────────────59 await sandbox.runCommand("sh", [60 "-c",61 "nohup setsid node /root/app.js >/tmp/app.log 2>&1 &",62 ]);6364 await sandbox.waitForPortReady(3000, { timeoutMs: 15_000 });65 console.log("server is listening on :3000");6667 // ── Step 6: verify via the public preview URL ──────────────────────────68 const res = await fetch(previewUrl);69 console.log("preview URL :", previewUrl);70 console.log("HTTP status :", res.status);7172 if (!res.ok) {73 const body = await res.text();74 throw new Error(`app returned HTTP ${res.status}:\n${body}`);75 }7677 console.log("\nOpen this URL in your browser:");78 console.log(previewUrl);7980 // ── Step 7 (optional): iterate — add a visitor counter ─────────────────81 const iterateResponse = await anthropic.messages.create({82 model: process.env.ANTHROPIC_MODEL ?? "claude-sonnet-4-6",83 max_tokens: 2048,84 messages: [85 { role: "user", content: PROMPT },86 { role: "assistant", content: response.content },87 {88 role: "user",89 content:90 "Good. Now add a visitor counter below the clock. " +91 "It should count how many times the page has been loaded since " +92 "the server started. Keep everything in one file, no deps. " +93 "Output only the updated JavaScript, no markdown fences.",94 },95 ],96 });9798 const updatedBlock = iterateResponse.content.find((b) => b.type === "text");99 if (!updatedBlock || updatedBlock.type !== "text") {100 throw new Error("Claude returned no text block on iteration");101 }102 const updatedCode = updatedBlock.text;103104 await sandbox.files.upload("/root/app.js", updatedCode);105 await sandbox.runCommand("sh", ["-c", "pkill -f 'node /root/app.js' || true"]);106 await sandbox.runCommand("sh", [107 "-c",108 "nohup setsid node /root/app.js >/tmp/app.log 2>&1 &",109 ]);110 await sandbox.waitForPortReady(3000, { timeoutMs: 15_000 });111112 const res2 = await fetch(previewUrl);113 console.log("iteration HTTP status:", res2.status);114 console.log("Reload the preview URL to see the visitor counter.");115} finally {116 // ── Step 8: always destroy ─────────────────────────────────────────────117 await sandbox.destroy().catch((err) => {118 console.error(119 "cleanup: destroy failed:",120 err instanceof Error ? err.message : String(err),121 );122 });123 console.log("sandbox destroyed");124}
What you learned
You built the canonical create → AI-generate → upload → run → preview → destroy loop:
- A sandbox with
ingress_enabled: truegets a public hostname at create time;previewUrl(port)turns that hostname into a clickable URL. - The Anthropic Messages API is just a
fetch— you call it from the same script, extract the text block, and pass the string straight tosandbox.files.upload. runCommand("sh", ["-c", "nohup setsid … &"])is the standard way to background a long-running server inside the VM.waitForPortReadygates your next step on the port actually being bound.- The loop is repeatable — re-upload, restart, re-fetch — so iterative generation works without touching the sandbox plumbing again.
try { … } finally { sandbox.destroy() }ensures the VM is always reclaimed, even when earlier steps throw.
This pattern generalises: swap Claude for any model or codegen pipeline, swap Node for Python or Deno, swap the preview fetch for a Playwright screenshot — the sandbox wiring stays the same.
Next steps
- Quickstart — the 30-second tour if you want a simpler starting point
- How-to: expose a service — deep dive into
ingress_enabled,waitForPortReady, andpreviewUrl - How-to: files — bulk transfers, binary uploads, download artifacts
- Reference: Sandbox — full method signatures for
runCommand,files,previewUrl,waitForPortReady,destroy - Explanation: VM sandboxes — why VMs, isolation model, cold-start latency