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 { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2
3const client = new CreateosSandboxClient();
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 { CreateosSandboxClient } from "@nodeops-createos/sandbox";
2import Anthropic from "@anthropic-ai/sdk";
3
4const client = new CreateosSandboxClient();
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 { CreateosSandboxClient } from "@nodeops-createos/sandbox";
11import Anthropic from "@anthropic-ai/sdk";
12
13// Both clients read credentials from env automatically.
14const client = new CreateosSandboxClient();
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 Workspace.

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

CreateOS is a unified intelligent workspace where ideas move seamlessly from concept to live deployment, eliminating context-switching across tools, infrastructure, and workflows with the opportunity to monetize ideas immediately on the CreateOS Marketplace.