An agent chat that writes Lakebed capsules, and runs the full dev cycle — build,
test, lint, deploy — inside a single Cloudflare Worker isolate. Describe what
you want; it edits server/index.ts and client/index.tsx, checks its work with
a shell, and deploys to lakebed.
Live: https://app-builder.pshippy-245.workers.dev
you ─▶ muse-spark-1.3 on zen, or a Go model on go (BYOK, browser localStorage)
│ tools: bash read_file write_file
▼
Durable Object ──▶ just-bash ──▶ build · tests · lint · deploy
files · chat ──▶ QuickJS (WASM) ──▶ capsule server + stub DB
one JS runtime │
one shell └──▶ lakebed anonymous API ──▶ live *.lakebed.app
| File | Role |
|---|---|
src/agent.ts |
Shared agent contract: gateway/model catalog, system prompt, SSE events, session API |
src/pi-harness.ts |
PiHarness glue: open Pi over DO SQLite, transcript migration + sync, event mapping |
src/bash.ts |
just-bash backend: one shell per session, custom build/tests/lint/deploy |
src/capsule.ts |
Capsule runtime: lakebed/server stub + in-memory DB, build/test in QuickJS |
src/lakebed.ts |
In-isolate deploy: mini-bundler, artifact assembly, lakebed API POST |
src/session.ts |
One Durable Object per session — files, chat history, QuickJS + bash, PiHarness + turn loop |
src/sandbox.ts |
Compile, execute in QuickJS, capture logs, enforce limits |
src/index.ts |
Thin entry: Env, AppSession re-export, fetch dispatcher over src/routes/* |
src/server/auth.ts |
Cookies/session, login gates, resolveAuth, sessionJson, route context |
src/server/page.ts |
appDoc + page() assembly (static head + src/client/* script chunks) |
src/routes/ |
One handler per API group: files, chats, apps, chat, github-auth, github-repos, share, git |
src/client/ |
Browser UI chunks (state, transcript, history, pane, settings-a/b, attachments, agent, tabs) |
spikes/wasi-host/ |
Proof that a wasm32-wasip1 module runs in a Worker |
docs/ts-rust-integration.md |
Plan for full tsc diagnostics via wasm |
The agent loop is Pi (pi-durable PiHarness hosted in the Durable Object): it owns the transcript, tool rounds, retries, and crash recovery, with the capsule workspace (bash/read_file/write_file) as its tools. The QuickJS runtime lives in the same Durable Object because that has to survive between requests.
// server/index.ts
import { capsule, mutation, query, table, string, userId } from "lakebed/server";
export default capsule({
schema: { todos: table({ text: string(), ownerId: userId() }).index("by_owner", ["ownerId"]) },
queries: {
todos: query(async (ctx) => {
const { userId } = ctx.auth.requireIdentity();
return ctx.db.todos.withIndex("by_owner", (q) => q.eq("ownerId", userId)).collect();
}),
},
mutations: {
addTodo: mutation(async (ctx, text: string) => ctx.db.todos.insert({ text, ownerId: ctx.auth.requireIdentity().userId })),
},
});// client/index.tsx
import { createClient } from "lakebed/client";
import type app from "../server/index";
const client = createClient<typeof app>();
export function App() {
const todos = client.useQuery("todos"); // undefined until first result
/* ... */
}Auth is BYOK: enter the API key in the composer box. It is kept in the browser's
localStorage and sent with each chat request; the Worker never stores it.
OPENCODE_ZEN_KEY remains as a server-side fallback secret. LAKEBED_TOKEN is
an optional Worker secret for owned lakebed deploys — without it, deploy
publishes anonymous preview deploys, which expire (currently ~7 days).
npm install
npx wrangler secret put OPENCODE_ZEN_KEY # optional fallback
npx wrangler secret put LAKEBED_TOKEN # optional, owned deploys
npx wrangler secret put GITHUB_CLIENT_ID # GitHub OAuth (save/open repos)
npx wrangler secret put GITHUB_CLIENT_SECRET # GitHub OAuth
./deploy.sh
npm run check # syntax-checks the inline UI script against the dev serverThe GitHub tab logs in with OAuth, then either creates a repo from the
current files or opens (clones) an owner/repo to edit here. Git runs
server-side in the session's Durable Object via
just-git; tokens stay in DO
storage. Repos with no runnable capsule still edit, commit, push, and pull —
Preview may show a build error or no output. Setup: docs/github-setup.md.
Cloudflare Workers blocks V8's code generation:
eval() ✗ blocked
new Function ✗ blocked
WebAssembly.compile ✗ blocked
WebAssembly.compileStreaming ✗ blocked
That is a Spectre mitigation. It closes the usual "transpile TypeScript, then eval it in the
same isolate" path, which is why most Worker setups deploy a new isolate per version instead.
The ban is on V8's codegen, not on running untrusted JavaScript. Workers explicitly permits
WebAssembly.instantiate() with a pre-compiled module — that is not string-to-native
compilation, it is just calling code that was compiled before deploy.
So ship a JavaScript engine as WebAssembly:
┌─ one V8 isolate ────────────────────────────────────┐
│ │
│ quickjs.wasm (492 KB, pre-compiled, instantiated) │
│ │ has its own eval() that V8 never sees │
│ ▼ │
│ sucrase ── TypeScript ──▶ JavaScript text │
│ │ │
│ ▼ │
│ QuickJS.evalCode(js) ──▶ runs, mutates globals │
│ ▲ │
│ └── swap the source, same runtime, same context│
└──────────────────────────────────────────────────────┘
Editing the app re-runs evalCode in the same runtime. No new isolate, no wrangler deploy,
no Worker Loader. Globals from the previous run are still there.
| File | Role |
|---|---|
src/sandbox.ts |
The QuickJS harness: compile, execute, capture, guard |
src/session.ts |
One Durable Object per session, holding one QuickJS runtime |
src/index.ts |
Worker entry: thin fetch dispatcher (routing in src/routes/*, UI in src/client/*) |
Each session maps to a Durable Object, so the runtime and its context survive across requests and across isolate restarts of the outer Worker.
The agent works in a shell, not bespoke file tools. bash runs just-bash
commands against /app; read_file/write_file move whole files without
quoting pain:
| Command | What it does, all in the isolate |
|---|---|
build |
Validate the capsule, execute server/index.ts in QuickJS against a stub lakebed/server with an in-memory DB, smoke-run every query |
tests |
Run *.test.ts in the isolate (describe/it/expect, __testCtx for DB access) |
lint |
Parse, import, and anonymous-deploy checks, then real tsc diagnostics via ts_rust.wasm |
deploy |
Assemble the lakebed artifact and POST it (anonymous, or owned with LAKEBED_TOKEN) |
Standard shell commands (ls, cat, grep, sed, jq, pipes) work too —
that is the point of just-bash: one shell tool instead of N bespoke ones.
console.log/warn/error from builds and tests is captured per run.
Local capsule state is in-memory and resets on rebuild — the same contract as
lakebed dev.
Verified in wrangler dev against real workerd, plus real anonymous deploys:
| Behaviour | Result |
|---|---|
TypeScript + JSX (sucrase, disableESTransforms) |
✅ |
just-bash shell (ls, pipes, jq, custom commands) |
✅ 56 ms round-trip |
build: capsule executes, every query smoke-run |
✅ tables + rows in ~60 ms |
tests: describe/it/expect vs stub DB |
✅ insert → withIndex → collect |
lint: parse, imports, anonymous-deploy rules |
✅ predicts deploy acceptance |
deploy: artifact accepted, app serves |
✅ queries + mutations live in browser |
await on already-settled values |
✅ |
console.* capture |
✅ |
| Runtime errors with real stack traces | ✅ |
| Runaway loop | ✅ stopped at ~2s, context survives |
| Allocation bomb | ✅ stopped at 64 MB, context survives |
await on real I/O or a timer |
❌ reported as Unsupported |
test as a custom command name |
❌ POSIX builtin wins — the command is tests |
The VM is synchronous — @cf-wasm/quickjs ships only a RELEASE_SYNC build, no Asyncify
variant. So await works only for values that settle immediately. Pending promises are detected
and reported rather than hanging.
1. Date.now() is frozen, so wall-clock timeouts cannot work. The Workers security model
locks Date.now() to the time of the last I/O so code cannot measure its own runtime. An
interrupt handler is host-side JavaScript, so Date.now() - startedAt > 2000 never becomes
true and a runaway loop kills the isolate. Fixed by counting interrupt polls instead —
deterministic, ~4,200 ticks/second measured, budgeted at 8,000 (~2s).
2. The per-eval shouldInterrupt option is silently ignored. ctx.evalCode(code, file, { shouldInterrupt }) does nothing in this binding. rt.setInterruptHandler(fn) works. Same for
memoryLimitBytes per eval — use rt.setMemoryLimit(bytes).
3. ctx.dump() returns undefined on a handle from getPromiseState(). Even when the
promise is fulfilled, dumping state.value yields nothing. Reading a plain global works fine.
The harness therefore passes results back through globals (__out, __err, __done) rather
than through a settled promise.
One-time prerequisite. Every Workers deploy needs a workers.dev subdomain registered on the
account, even when you attach a custom domain — the API returns 10063 without one. No scoped
API token can create it: POST /accounts/:id/workers/subdomain answers 10405 "Method not allowed for this authentication scheme". Open the Workers page once and it is created:
https://dash.cloudflare.com/<ACCOUNT_ID>/workers/onboarding
Then:
./deploy.shIt reads CLOUDFLARE_WORKER_API_TOKEN from the environment or ~/shared_config. The token needs
Workers Scripts: Edit. Wrangler creates the AppSession Durable Object namespace on first
deploy, so nothing needs setting up by hand.
Ctrl/Cmd+Enter re-runs from the browser.
lakebed build uses esbuild (native binary), which cannot run in a Worker. The
isolate instead assembles the deploy artifact with pure TS and POSTs it to the
lakebed API — verified end to end against real anonymous deploys:
- Server bundle: relative files inlined, sucrase-transformed, with the real
lakebed/serverruntime (pinnedlakebed@0.0.39files, hash-verified at deploy time) vendored in. Deploys are rejected if a vendor hash drifts. - Client bundle: JSX compiled to
h(), browser externals rewritten to pinned esm.sh URLs (preact@10.28.0,lakebed@0.0.39client). One shared preact instance — two copies break hooks state. - Schema, endpoints, and auth are extracted by executing the server entry in
QuickJS and serialized in the exact artifact shape; the control plane
re-validates, so a local
lintpredicts acceptance.
Constraints this implies: capsules may only import relative files, lakebed/*,
and preact; top-level names must not collide across inlined files; Tailwind
classes render unstyled (no CSS compiler in the isolate — the platform injects
its own browser build); server code must already satisfy the anonymous rules
(no while, eval, dynamic import(), server fetch, or globalThis).
- Real type checking.
lintruns the capsule throughts_rust.wasm(the TypeScript compiler aswasm32-wasip1, vendored atsrc/ts_rust.wasm) withstrict+noEmitsemantics: wrong types fail loudly instead of surfacing asundefinedthree layers down.deployis gated on it. Each check pays a fresh 4.7 MB instantiation peaking near 69 MiB, so checks serialize per session and stay off the fastbuildpath. Parity against the native compiler:npm run typecheck:parity(10/10 fixtures). Rebuild command and binary hash:docs/ts-rust-integration.md. - No npm installs. Capsules import relative files,
lakebed/*, andpreact. - Interpreted, so slow. QuickJS-in-Wasm is roughly 10–50x slower than native V8. Fine for this workload, not for hot paths.
- Eviction loses the runtimes. A Durable Object can be evicted, taking the QuickJS globals and bash FS with it. Files and chat persist in SQLite storage and re-seed on next use.
- BYOK key lives in
localStorage. Convenient and server-stateless, but any script running on the page origin can read it. Do not use a funded key on an untrusted network. - Sessions are GitHub-keyed after login: every browser you log into shares
one Durable Object per user, so work resumes where you left off. The
cookie bearer is
gh-<userId>.<secret>— the id alone proves nothing, the secret allowlist is checked on every request, including the cookie-less app iframe endpoints. Anonymous visitors get only the login gate.
- Pick zen or go, paid models only. The composer has a gateway picker
(
zenpay-as-you-go,gosubscription) and a model picker listing only Responses-compatible paid models. Free*-freepreviews are excluded: Zen rejects them outside the OpenCode client (FreeTierError403). A 429 means rate limited; a 401 means the browser key is missing or wrong. - Contributor tier trains on prompts.
muse-spark-1.3-contributor(go) exchanges steep discounts for permission to train on usage. - Anonymous deploys expire (~7 days at time of writing) and disable
server-side
fetchand hosted env. Claim the deploy and redeploy withLAKEBED_TOKENfor the full platform. - The Preview tab runs the real app, not a snapshot.
client/index.tsxis bundled in the isolate (same entry wrapper as deploy) and rendered in a sandboxed, opaque-origin iframe against the isolate's database, so queries, mutations, and actions work with live state. Auth is the stub guest, state is in-memory (resets on rebuild or isolate restart), and the iframe cannot reach the parent page or the browser's API key. Tailwind classes still render unstyled (no CSS compiler in the isolate). process ram/process cpuin the header are blank. Neither is observable from inside a Worker, and inventing numbers would be worse.