NodeOps
UK

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.destroy in a finally block

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 with CREATEOS_SANDBOX_BASE_URL)
  • Auth: API key via the apiKey option or CREATEOS_SANDBOX_API_KEY

Step 1: Set up

Install the two packages you need:

Shell
1bun 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:

Shell
1export 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:

TypeScript
1import { createClient } from "@nodeops-createos/sandbox";
2
3const client = createClient();
4console.log(await client.whoami());

Run it:

Shell
1bun 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.

TypeScript
1import { createClient } from "@nodeops-createos/sandbox";
2import Anthropic from "@anthropic-ai/sdk";
3
4const client = createClient();
5
6const sandbox = await client.createSandbox({
7 shape: "s-4vcpu-4gb", // comfortable headroom for a Node process
8 rootfs: "devbox:1",
9 ingress_enabled: true,
10});
11
12// 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" });
15
16console.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.

TypeScript
1// Reads ANTHROPIC_API_KEY from the environment automatically.
2const anthropic = new Anthropic();
3
4const 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.";
9
10const 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});
16
17// The response may contain multiple content blocks; the code is in the
18// 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;
24
25console.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.

TypeScript
1await sandbox.files.upload("/root/app.js", code);
2
3// 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:

TypeScript
1await sandbox.runCommand("sh", [
2 "-c",
3 "nohup setsid node /root/app.js >/tmp/app.log 2>&1 &",
4]);
5
6// 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 });
9
10console.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:

TypeScript
1const res = await fetch(previewUrl);
2
3console.log("preview URL :", previewUrl);
4console.log("HTTP status :", res.status);
5
6if (!res.ok) {
7 const body = await res.text();
8 throw new Error(`app returned HTTP ${res.status}:\n${body}`);
9}
10
11console.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:

TypeScript
1const 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});
17
18const 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;
23
24// Re-upload and restart.
25await sandbox.files.upload("/root/app.js", updatedCode);
26
27// 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 });
34
35const 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:

TypeScript
1} 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.

TypeScript
1/**
2 * AI app generator — Claude writes a web app, the sandbox runs it, ingress
3 * serves it at a live preview URL.
4 *
5 * Run: bun ai-app-gen.ts
6 * Needs: CREATEOS_SANDBOX_BASE_URL + CREATEOS_SANDBOX_API_KEY
7 * ANTHROPIC_API_KEY
8 * ANTHROPIC_MODEL (optional — defaults to claude-sonnet-4-6)
9 */
10import { createClient } from "@nodeops-createos/sandbox";
11import Anthropic from "@anthropic-ai/sdk";
12
13// Both clients read credentials from env automatically.
14const client = createClient();
15const anthropic = new Anthropic();
16
17// ── 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});
23
24// Resolve the preview URL now; the hostname is already provisioned.
25const previewUrl = sandbox.previewUrl(3000, { scheme: "http" });
26
27console.log("sandbox id :", sandbox.id);
28console.log("status :", sandbox.status);
29console.log("preview URL :", previewUrl);
30
31try {
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.";
38
39 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 });
44
45 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`);
51
52 // ── Step 4: upload the generated code ─────────────────────────────────
53 await sandbox.files.upload("/root/app.js", code);
54
55 const { result: lsResult } = await sandbox.runCommand("ls", ["-lh", "/root"]);
56 console.log(lsResult.stdout);
57
58 // ── 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 ]);
63
64 await sandbox.waitForPortReady(3000, { timeoutMs: 15_000 });
65 console.log("server is listening on :3000");
66
67 // ── 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);
71
72 if (!res.ok) {
73 const body = await res.text();
74 throw new Error(`app returned HTTP ${res.status}:\n${body}`);
75 }
76
77 console.log("\nOpen this URL in your browser:");
78 console.log(previewUrl);
79
80 // ── 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 });
97
98 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;
103
104 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 });
111
112 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:

  1. A sandbox with ingress_enabled: true gets a public hostname at create time; previewUrl(port) turns that hostname into a clickable URL.
  2. The Anthropic Messages API is just a fetch: you call it from the same script, extract the text block, and pass the string straight to sandbox.files.upload.
  3. runCommand("sh", ["-c", "nohup setsid … &"]) is the standard way to background a long-running server inside the VM. waitForPortReady gates your next step on the port actually being bound.
  4. The loop is repeatable (re-upload, restart, re-fetch), so iterative generation works without touching the sandbox plumbing again.
  5. 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

100,000+ Builders. One Platform.

Get product updates, builder stories, and early access to features that help you ship faster.

NodeOps is the agentic operating system for production AI. CreateOS is its flagship product.