Build an app
orun.work builds apps from a description. You chat with the Work Agent: it asks a few questions, proposes a plan — a starting point, one to five milestones (a simple site is one), where the code lives and where it runs — and once you approve, it creates the app (a workspace, a repository, a task per milestone), builds it milestone by milestone, and puts it live.
orun.work and the Orunbase console are one product on two hosts. They have the same pages at the same paths, and you sign in once, the same way. orun.work puts the chat first: your chats down the side, and the Work Agent's chat beside the app's Progress, Stage, Work, Jobs and Overview. app.orunbase.com puts the pages first, with the chat in the agent panel. Open this page in orun.work (⌘K) and Orunbase (in orun.work's tab row) move between them on the same page.
Both are clients of the apps API. This page uses two others: the orun-cloud CLI, and a script.
From the CLI
You need the orun-cloud CLI, logged in, with your account as the active organization:
orun-cloud login
orun-cloud org use org_… # your account: org_…, ws_… or its slug
Start a chat:
orun-cloud apps new "a todo app for my team, with due dates"
The CLI starts the chat and follows it in the terminal: the Work Agent's replies as they are written, a line whenever progress moves (→ Building: 1 of 3 — Lists), one line per action the build takes (· Wrote src/routes/index.tsx, · $ pnpm test), and the versions it saves. When the Work Agent asks, the card is asked right there:
Started a chat with the Work Agent: a todo app for my team, with due dates (ach_5b1f…)
Follow it in orun.work: https://orun.work/chats/ach_5b1f…
→ Understanding your idea
Work Agent: A shared todo app — two quick questions first.
? A couple of questions
Who uses it?
1) Just me
2) My team — Shared lists
3) Anyone with the link
Your pick, your own words, or Enter to skip: 2
Which extras? (pick one or more)
1) Due dates
2) Tags
3) Reminders
Your picks (e.g. 1,3), your own words, or Enter to skip: 1,3
✓ Answered
→ Planning your app
? Here's the plan
App: Team todos
Starting point: TanStack Start — A full-stack React starter with auth and a database
Milestones:
1. Lists — Shared todo lists
✓ A list can be created and shared
…
Code lives in: orun.work's GitHub (managed)
Where it runs: acme-prod (cloudflare)
[a] Approve [c] Change it [Enter] Later: a
- Question cards: type an option's number — several, comma-separated, when it says pick one or more — or answer in your own words.
- The plan:
aapproves (then asks where the code lives and where it runs, defaulting to the card's choices),casks for a change in your words. - Connection, approval, attach and repository cards are asked the same way. Notices are printed.
- Enter leaves a card for later; answer it in orun.work.
Ctrl-C stops following — the Work Agent keeps going. The command ends by itself once the app is built.
| Flag | |
|---|---|
--account=REF | The account, instead of the active organization (--workspace / --org work too) |
--title=TITLE | Name the chat |
--app=ws_… | Work on an existing workspace instead of making a new app (the Work Agent asks first) |
--detach | Start the chat, print its URL, don't follow it |
--output=json | Print the chat, then each stream frame as a JSON line, until a card needs you or the app is built |
Put boolean flags after the idea: apps new "a todo app" --detach.
Then:
orun-cloud apps list # your account's apps: runs, preview, live
orun-cloud apps preview ws_ABCD1234 # the preview URL (and opens it), or "code only"
orun-cloud apps open ws_ABCD1234 # the app's chat in orun.work
apps open and apps preview open a browser when there is one and print the URL either way (--no-browser to only print). The CLI picks https://orun.work or https://stage.orun.work from the API it is logged in to; --web-url overrides it.
From a script
A script can do everything orun.work does. This one starts a chat, answers every card the Work Agent opens — the first option of each question, Approve with the plan's defaults, the first connection offered — and waits for the app.live webhook.
1. An account API key
In the console, open your account's settings and orun.work keys, then create a key with the role owner (approving a plan creates the app's workspace, which needs it). Copy the secret (sk_…) — it is shown once. A key with scope apps reaches the apps API and nothing else.
2. A webhook endpoint
Subscribe an HTTPS endpoint you control to app.* on your account — every app.* event, app.live included, is delivered to the account's endpoints. Do it with your own CLI login, since the apps key can't manage webhooks:
orun-cloud webhook create https://hooks.example.com/orun-work --event='app.*'
orun-cloud webhook secrets rotate whe_… # reveals the signing secret (whsec_…) once
For a local run, expose the script's port with a tunnel (cloudflared tunnel --url http://localhost:8787, for instance) and register the tunnel's URL.
3. Where it runs
To go live, the app needs somewhere to run: a Cloudflare connection on your account (Integrations in the console). Without one, the Work Agent builds the app code only — the code is in its repository, nothing is deployed, and app.live never comes.
4. The script
// build-an-app.ts — Node ≥ 20 with @saas/sdk and @saas/webhook-verifier.
// ORUN_ACCOUNT=org_… ORUN_APPS_KEY=sk_… ORUN_WEBHOOK_SECRET=whsec_… npx tsx build-an-app.ts "a todo app"
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import {
ConflictError,
OrunCloud,
isAppStreamCard,
type AppCard,
type AppConnectionCardPayload,
type AppNoticeCardPayload,
type AppPlanCardPayload,
type AppQuestionCardPayload,
} from "@saas/sdk";
import { verifyWebhookSignature } from "@saas/webhook-verifier";
const account = process.env["ORUN_ACCOUNT"]!;
const webhookSecret = process.env["ORUN_WEBHOOK_SECRET"]!;
const idea = process.argv[2] ?? "A booking page for my yoga studio, with a waitlist";
const client = new OrunCloud({
baseUrl: "https://api.orunbase.com",
auth: { kind: "bearer", token: process.env["ORUN_APPS_KEY"]! },
});
interface LiveData {
chatId: string;
appRef: string;
url: string;
urls: { preview?: string; live: string };
}
// 1. Listen for app.live. Deliveries are signed: verify before trusting one.
let chatId: string | null = null;
let resolveLive: (data: LiveData) => void = () => {};
const live = new Promise<LiveData>((resolve) => (resolveLive = resolve));
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => (body += chunk.toString("utf8")));
req.on("end", () => {
void (async () => {
const verified = await verifyWebhookSignature({ secret: webhookSecret, body, headers: req.headers });
if (!verified.ok) {
res.writeHead(401).end(verified.reason);
return;
}
res.writeHead(200).end("ok");
const event = JSON.parse(body) as { id: string; type: string; data: LiveData };
console.log(`webhook ${event.type}`);
if (event.type === "app.live" && event.data.chatId === chatId) resolveLive(event.data);
})();
});
});
server.listen(8787);
// 2. How this script answers each kind of card. Yours can ask a person.
function decide(card: AppCard): { action: string; values?: Record<string, unknown> } | null {
switch (card.kind) {
case "question": {
const { questions } = card.payload as unknown as AppQuestionCardPayload;
const answers = questions.map((q) => ({ questionId: q.id, optionIds: [q.options[0]!.id] }));
return { action: "answer", values: { answers } };
}
case "plan": {
const plan = card.payload as unknown as AppPlanCardPayload;
const runsOn = plan.runsOn?.default ?? plan.runsOn?.candidates[0]?.connectionId;
if (!runsOn) throw new Error(`Nowhere to run it: connect ${plan.runsOn?.provider ?? "a provider"} first`);
console.log(`approving "${plan.appName ?? card.title}": ${plan.milestones.map((m) => m.name).join(" → ")}`);
return { action: "approve", values: { repository: plan.repository.default, runsOn } };
}
case "connection": {
const { candidates } = card.payload as unknown as AppConnectionCardPayload;
return candidates[0] ? { action: "use", values: { connectionId: candidates[0].connectionId } } : null;
}
case "approval":
return { action: "approve" }; // a build session asking to run something
case "repository":
return { action: "use_managed" }; // GitHub refused: use orun.work's
case "notice": {
const notice = card.payload as unknown as AppNoticeCardPayload;
if (notice.level === "blocked") throw new Error(`${card.title}: ${notice.text}`); // out of credits, a limit
return { action: "dismiss" };
}
default:
return null;
}
}
// 3. Start the chat, then follow it and answer its cards until app.live.
const { chat } = await client.apps.chats.create(account, { message: idea }, { idempotencyKey: randomUUID() });
chatId = chat.id;
console.log(`chat ${chat.id}: https://orun.work/chats/${chat.id}`);
const stop = new AbortController();
void live.then(() => stop.abort());
let headline = "";
for await (const event of client.apps.chats.events(account, chat.id, { signal: stop.signal })) {
if (event.type === "STATE_SNAPSHOT" || event.type === "STATE_DELTA") {
const progress = event.type === "STATE_SNAPSHOT" ? event.snapshot?.["progress"] : event.ops?.[0]?.value;
const next = (progress as { headline?: string } | undefined)?.headline ?? headline;
if (next !== headline) console.log(`→ ${(headline = next)}`);
}
if (!isAppStreamCard(event) || event.value.state !== "open") continue;
const answer = decide(event.value);
if (!answer) continue;
try {
await client.apps.chats.cards.answer(account, chat.id, event.value.id, answer);
console.log(`answered the ${event.value.kind} card: ${event.value.title}`);
} catch (err) {
// A replayed frame of a card that is already answered: nothing to do.
if (err instanceof ConflictError && err.reason === "card_closed") continue;
throw err;
}
}
const { appRef, urls } = await live;
console.log(`${appRef} is live at ${urls.live}${urls.preview ? ` (preview: ${urls.preview})` : ""}`);
server.close();
What happens:
chats.createstarts the chat (202); the Work Agent's first turn begins at once.chats.eventsfollows the chat's stream from the start. If the connection drops it reconnects on its own from the last frame it delivered, so no card is missed.- Each
CUSTOM name=cardframe withstate: "open"is answered withcards.answer. Approving the plan creates the app: its workspace, its repository, its tasks — and, because a connection was picked, the consent to deploy there. - The first build runs both stages, scaffold and live. When the live deploy answers,
app.livearrives at the endpoint and the script stops following. The Work Agent carries on with the remaining milestones; each merge redeploys.
Instead of the webhook, a script can also poll GET …/apps/{appRef} (client.apps.get) until live.url is set: chat.appRef names the app once it exists.
Next
- Apps API — every route, the stream's events, the cards and their actions, the errors, the webhooks.
- Verify deliveries — the signing scheme.
- TypeScript SDK · CLI