A lightweight design agent built with libfx. Describe a page and an agent writes it as one HTML file.
The project's README from github.com/vercel-labs/fx0. As one markdown file: README.md.
fx0 is an open-source, lightweight design agent built with libfx. You describe a page and an agent writes it as one HTML file.
fx0 is also a working example of an app built on libfx. One createFxAgent() call gives it the agent loop, streaming, tool calls, retries and durable sessions, and createFxEngine() runs each subagent.
Try it at fx0.vercel.dev, or clone it and make it your own.
FX0_MODEL when it's set.design.md styles, or upload your own. The ten styles, and the one-line descriptions in the picker, come from VoltAgent's awesome-design-md, which you can browse at getdesign.md. Thanks to VoltAgent for publishing them.fx0 is the app. libfx is the agent inside it. Vercel keeps the agent's work safe while it runs.
createFxAgent() and passes each chat's messages to it./api/libfx, and libfx continues from the last saved step.fx0 runs one durable libfx agent per server. libfx keeps each chat's conversation as a session and backs every turn with Vercel Queues, so a turn finishes even when the function running it dies. fx0 has no code of its own to recover a turn. Durability takes three pieces.
The agent. fx0 passes no durability option, so createFxAgent() uses libfx's default: on Vercel, libfx keeps its sessions in Vercel's World, the store behind Vercel Workflow, and uses Vercel Queues. libfx bundles both clients, so fx0 neither writes workflows nor calls Vercel Queues itself. A prompt runs its turn in the request that sent it, so the reply starts without a queue hop, and Vercel Queues keeps the prompt as a backstop until just after that request's 800-second limit. fx0 sends a message as a prompt when the chat is idle. One sent while a turn runs or waits to start is a steer instead, which libfx delivers through Vercel Queues to the running turn.
// lib/libfx-agent.ts, simplified
const agent = createFxAgent({
// No durability option: Vercel's World and Queues on Vercel, local files elsewhere.
apiKey: 'session-authenticated',
fetch: chatFetch,
tools,
})
// lib/agent.ts, simplified: a chat gets a libfx session on its first message,
// and each message is one turn of it
const libfxSessionId = await agent.newSessionId()
const session = agent.session(libfxSessionId, { model, instructions, context: { chatId, turnId } })
session.prompt(`[${author}] ${text}`, { messageId: `m${turnId}` })
The route. Vercel Queues delivers the turns no request runs itself to app/api/libfx: the rest of a turn that outlasted its prompt request, and a prompt's backstop when that request died. libfx's handler runs them:
// app/api/libfx/route.ts
export const maxDuration = 800
export const POST = agent().wakeHandler()
The trigger. One entry in vercel.json subscribes that route to libfx's queue topic:
{
"functions": {
"app/api/libfx/route.ts": {
"experimentalTriggers": [{ "type": "queue/v2beta", "topic": "__libfx_wkf_workflow_session", "retryAfterSeconds": 5 }]
}
}
}
fx0 keeps the transcript, designs and settings in Redis. Here is one message from send to reply. The numbers match the steps below:
1. Save the message before answering. POST /api/prompt adds the message to the transcript in one Redis script, then hands it to libfx with its message id. libfx stores it before acknowledging, so a message that shows as sent can't be lost, and a retried request reaches the same turn.
2. Keep the turn's funding. A queue delivery has no browser cookie, so the prompt route seals its funding in Redis. Every message records the funding of the turn it starts: either the admitted sponsorship or its paid sender's OAuth grant and team. Fresh SDK diagnostics and the journal's restore hook identify the executing turn after a crash. Refresh tokens stay in the cookie, and the server trial key stays out of every stored credential. Missing or expired trial funding never falls back to a customer's paid grant.
3. libfx runs the turn. One worker holds a session at a time, and libfx fences out any other. The prompt request runs the turn first; it and each delivery to /api/libfx can run for 800 seconds, their maxDuration. libfx stops a turn 30 seconds before that limit, keeping what it has done so far, and a delivery to /api/libfx continues it, so a long turn spans several invocations.
4. Copy the stream into the transcript. Any route following the chat, the prompt's own response or an open tab, copies libfx's stream into the transcript. A cursor check makes each line land once, however many follow. The same Redis script marks a turn running when its start lands, and when its end lands it leaves the turn's history to write, with the design the turn left. The copy then writes that history and design version to Neon, and a write that fails waits in Redis for the next copy to retry.
5. Pick up where it stopped. On Vercel, libfx can't tell a crashed function from a slow one, so the request running a turn holds its session until its time limit. The prompt's backstop comes due 5 seconds after that limit: if the request died, the backstop takes the session over and the turn continues where its records end. In a delivery to /api/libfx, a process that exits has its delivery run again about a second later, and a function that stops without exiting holds its message until the queue's hold runs out, up to five minutes. The model is told the turn was interrupted, so it can check what happened before going on. A function that only stalled and wakes after that stops at its next write, so two never write at once.
A tool call the crash cut off runs again only if it's safe to repeat. libfx refuses a tool that doesn't say which it is, and fx0 marks its tools in lib/libfx-agent.ts: reading the design, publishing it, fetching a page, paging through a tool result and searching the tools run again. Edits, questions and subagents don't, so the model is told those calls may have run and decides what to do. The reads start as soon as the model asks for them, while libfx saves the call.
A subagent is an in-memory createFxEngine() inside its tool call, billed to the same chat. It has the same design tools, except that it can't start a subagent of its own. Its model requests count toward the parent turn's 64 steps, in a count kept in Redis (lib/turn-budget.ts), so subagents can't multiply what one turn spends. A retried or recovered request counts once.
TL;DR:
You can watch this happen. Open session diagnostics from the header to see the chat's libfx session, its running turn and how far the transcript has copied libfx's stream.
/api/libfx.| Path | What's there |
|---|---|
lib/agent.ts |
Each chat's prompts, questions, cancel, deletion and sign-out |
lib/libfx-agent.ts |
The durable agent, its per-chat fetch and its tools |
lib/projector.ts |
Copies a chat's libfx stream into its transcript |
lib/turn-credential.ts |
The sealed credential each chat's turns bill |
lib/trial.ts |
Guest identities and the atomic per-IP trial allowance |
lib/journal.ts |
The Redis journal. Every write is a single Lua script, so it's atomic. |
app/api/libfx/ |
Where Vercel Queues delivers libfx's turns |
lib/tools.ts |
The agent's tools: read, publish and edit the design, fetch a URL, ask a question, page through a long tool result, search the available tools and start a subagent. AI Gateway's web search runs beside them. |
lib/design-artifact.ts |
Checks that a design is one HTML file of at most 512 KB, with no external assets |
app/api/ |
Route handlers. Chat routes check sign-in and ownership before anything else. |
components/ |
The chat, composer, preview and diagnostics panel |
Neon Postgres holds accounts, who owns each chat, finished turns and every design version (migrations/).
You'll need Node.js 24 and three services:
openid profile email offline_access and the permissions AI Gateway: Use and Team: Read Only. Add http://localhost:3000/api/auth/callback as a callback URL. Don't leave out offline_access: without it, Vercel sends no refresh token and sign-in fails.Copy .env.example to .env.local and fill it in. SESSION_SECRET has to be a random string of at least 32 characters. If your local app shares Redis with a deployment, set FX0_REDIS_PREFIX (for example fx0:local) to keep the chats apart. FX0_MODEL changes the model new chats start on.
npm ci
npm run db:migrate
npm run dev
db:migrate applies the files in migrations/ in order. Each one is idempotent, so running it twice is fine. The app never migrates on its own.
Set the same variables on Vercel. The Neon and Upstash integrations in the Vercel Marketplace fill in DATABASE_URL, KV_REST_API_URL and KV_REST_API_TOKEN for you. Set APP_URL to your production URL and add <APP_URL>/api/auth/callback to your Sign in with Vercel app.
OAuth redirects never trust the request's Host header. They go to APP_URL, or, on a preview deployment without one, to that deployment's own Vercel URLs.
To enable the free trial, apply the migrations, set FX0_FREE_PROMPTS=1 and a server-only AI_GATEWAY_API_KEY, then rebuild and deploy. BotID's client protection and server verification are included. Production uses Vercel's trusted client IP; local development shares one local allowance. Keep local Redis prefixes separate from production.
npm run typecheck # generates the route types, then runs tsc
npm run test:core
npm run test:auth
npm run build
npm run test:smoke # runs the production build with fake sessions
npm run test:journal:integration # needs KV_REST_API_URL/TOKEN, writes under its own prefix
npm run test:trial:integration # needs local redis-server/redis-cli; no remote services
npm run test:artifact:browser # needs local Chrome (or CHROME_PATH); checks design CSP enforcement
npm run test:budget:integration # needs local redis-server/redis-cli; no remote services
npm run test:turns:integration # needs local redis-server/redis-cli; no remote services
The auth and route tests mock Vercel, AI Gateway and both databases, so check a real sign-in and real billing on a deployment.
Generated pages run in a sandboxed iframe on an opaque origin. They can't read the app's cookies, storage or credentials, and their Content Security Policy blocks network requests (connect-src 'none'). It's still the browser enforcing all of this, though, not a sealed VM.
Paid model requests carry the sender's OAuth token only to AI Gateway. Between requests it stays sealed in Redis and expires with the token. Sponsored requests require a recorded trial admission; the server key is never sent to a browser, cookie, journal or model prompt. Signing out deletes that login's stored credentials and revokes its token. A running turn it funded stops at its next model request; another login's turns keep running. Treat the session cookie and SESSION_SECRET as credentials.
Apache-2.0. The design.md styles in lib/design-md/ come from VoltAgent/awesome-design-md, under the MIT license.
A lightweight design agent built with libfx. Describe a page and an agent writes it as one HTML file.