# fx0: the artifact builder is the demo, the durable agent loop is the product

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/fx0
> date: 2026-10-23
> tags: agents, harness, developer-tools, tool-calling

On 19 October Guillermo Rauch quoted a Vercel post and wrote:

<Callout type="note">
**"We open sourced a \*really fast\* HTML artifact builder. It's powered by libfx, making the agent durable. Building the low-level plumbing for v0 to survive server crashes, flaky LLMs, function limits was a huge pain. Now it's easy, free, and OSS."**
</Callout>

The quoted post from `@vercel_dev` calls it a "mini v0" with "200+ models from Vercel AI Gateway" whose "outputs are pure HTML artifacts". The demo is at `fx0.vercel.dev`. The code is two repositories:

<RepoCard repo="vercel-labs/fx0" />

<RepoCard repo="vercel-labs/fx" />

The replies split two ways. Half are about speed ("took like less than 5 secs", a stopwatch at about 10 seconds). The other half picked out the second sentence: one reply says resuming after a crash mid-generation "is a better pitch than the speed", another asks how libfx handles "idempotency for side-effecting tools on replay". The second half is right. The speed is whatever model you point it at. The durability is code, and it is the part worth reading.

I read fx0 at commit `8541d8f` (22 October) and fx at `d824f60`, installed `libfx@0.0.14` from npm into a scratch directory, and killed it mid-turn. I made no account and called no hosted model: the model in my runs is a fake OpenAI-compatible server on `localhost`. Every number below is labelled **measured** (I ran or counted it), **reported** (Vercel's figure, not re-run) or **reasoned** (my arithmetic on the other two).

## Three pieces: fx, libfx, fx0

**fx** is Vercel Labs' coding-agent CLI. Its README calls it "one native binary written in Zig" and marks it experimental. The tree has about 711,000 lines of Zig under `src/` (measured, `git ls-files 'src/*.zig' | xargs cat | wc -l`), covering the agent loop, sessions, permissions, MCP, ACP and the terminal UI. CI fails a pull request if a benchmarked command such as `fx help` takes longer than 2 ms on Linux (reported).

**libfx** is that same Zig engine shipped to JavaScript as a Node.js native addon and as WebAssembly. The npm package for 0.0.14, published 16 October under Apache-2.0, holds four prebuilt addons (macOS and Linux, x64 and arm64) and two `.wasm` files. The Linux x64 addon is 10,005,872 bytes and `fx-core.wasm` is 2,615,841 bytes (measured from the installed package). You hand it a model, instructions and tools; it runs the model loop, streaming, tool calls, retries and steering. The 0.0.14 changelog leads with "libfx is now durable out of the box".

**fx0** is a Next.js app (`package.json` version 0.0.10, Apache-2.0) that puts a chat beside a preview pane and uses libfx as its agent. It is about 14,600 lines of TypeScript across `lib/`, `app/` and `components/` (measured). Its README says it plainly: "fx0 is the app. libfx is the agent inside it."

<Figure
  src="https://media.thesatyajit.com/ai/articles/fx0/fig1.png?v=d6cdfb13278768a0"
  alt="Block diagram. Inside a box labelled 'fx0, a Next.js app on Vercel', the chat and live preview talk to API routes, which write to Redis (chat and live design) and Neon (accounts and history). The API routes hand off to a black 'libfx agent, runs every turn' box below, which calls AI Gateway (billed to your team), saves to Workflow World (saves every step), and is connected by a dashed line to Vercel Queues (restarts a crashed turn)."
  caption="fx0 owns the chat, sign-in, tools and history; libfx runs each turn, saves every step to Workflow World, and Vercel Queues restarts a turn whose function died (fx0 README, 'How fx0 uses libfx' diagram)."
/>

## What fx0 actually builds

The output is one HTML document. The agent has eight tools of its own in `lib/tools.ts`: `read_design`, `publish_design`, `edit_design`, `web_fetch`, `ask_user_question`, `read_tool_result`, `capability_search` and `subagent`, plus AI Gateway's provider-side `web_search`. `publish_design` takes a full document and tells the model to keep drafts under 12 KiB. `edit_design` takes up to 20 exact `old_string`/`new_string` replacements and applies them as one version. That is the same edit primitive coding agents use on source files, aimed at a single page.

`lib/design-artifact.ts` enforces what "pure HTML artifact" means:

- At most 512 KB (`MAX_DESIGN_ARTIFACT_BYTES = 512 * 1024`), no external assets, and no `<base>`, `<form>`, `<iframe>`, `<object>` or `<embed>`.
- The preview is a `sandbox="allow-scripts"` iframe on an opaque origin, with a CSP injected right after the doctype: `default-src 'none'`, inline scripts and styles only, images, fonts and media from `data:` only, and `connect-src 'none'`. A generated page cannot make a network request.

That answers one reply's question, whether the result "needs fx0 around to run". It does not: it is one file you can copy or download. It also shows the ceiling. A page that needs an API, a form post or a real asset pipeline is out of scope by construction. Rauch said in a reply that you can layer on Vercel Sandbox, and that it was in the demo and taken out "for simplicity".

New chats start on `anthropic/claude-opus-5.5-fast` (`lib/session.ts`). Signed-in users bill AI Gateway to their own Vercel team through OAuth. Guests get five free prompts per IP on that model at low effort, funded by a server-side key behind BotID. Every turn stops after 64 model requests, subagents included. fx0 counts them in Redis, not in libfx, because, as its comment puts it, "libfx leaves step budgets to the host". The "200+ models" figure comes from the live Gateway catalog, which I did not query, so it stays **reported**. The picker code names model families, not model IDs.

<Figure
  src="https://media.thesatyajit.com/ai/articles/fx0/fig3.jpg?v=3ee7abea35898a8c"
  alt="Promotional image: on the left, 'Open-source artifact builder, Built on libfx'. On the right, the fx0 interface: a chat where the user asks to add a featured collection below the hero, tool rows reading 'read design 19ms done' and 'edit design 73ms done', 'design published version 4', and token counts '7.4s · 18k in · 3k out'; beside it, a preview of a minimalist store page titled 'objects for everyday living.'"
  caption="The fx0 interface as Vercel presents it: a read and an exact-replacement edit, each timed, then a new design version. The timings are Vercel's own screenshot, not a measurement of mine (fx0 repository, app/opengraph-image.png)."
/>

The speed in the replies is mostly the model. One reply quotes "32ms to publish"; I could not find that figure in either repository. Vercel's own image shows a design read at 19 ms and an edit at 73 ms (reported). Those are tool calls against Redis. The seconds a user waits are tokens from Opus. libfx can do one thing about latency, and it does: when no worker holds the session, a prompt runs its turn in the request that sent it, so the first model call goes out with no queue hop.

## Why a server-side agent loses its turn

An agent turn is a loop: call the model, run the tools it asked for, call the model again with the results, repeat until it stops asking. On a laptop that loop lives in one process for as long as it needs. On a serverless platform it does not:

- the function has a time limit, 800 seconds in fx0's routes (`maxDuration = 800`);
- a deploy replaces the process;
- the process can run out of memory or crash in the middle of a tool call.

Kill the process and you have lost the conversation in flight, the model calls already paid for, and, worst, the knowledge of whether a side-effecting tool finished. Re-running the whole turn re-pays every model call and may run a refund twice. Not re-running loses the reply. That is the "huge pain" in Rauch's post, and every agent product on serverless rebuilds some version of it.

## How libfx makes a turn durable

libfx's answer is a write-ahead log per session. The libfx README states the rule: it "stores every prompt before it accepts it and every step of a turn as the turn runs, so the next process that picks up the session continues the turn where it stopped." Four mechanisms carry that sentence.

**1. A session is a log in a Workflow World.** The store is Vercel Workflow's World abstraction (`sdk/durable/`). On Vercel, `vercel()` uses Vercel's World and Vercel Queues; in plain Node, `local()` keeps files in a temp directory; in a browser, `memory()` keeps nothing past the page. A session ID is a Workflow run ID (`wrun_…`). `turn.accepted` resolves only after the prompt is stored, so a prompt the UI shows as sent cannot be lost. A `messageId` makes a retried request follow the original turn instead of starting a new one.

**2. One writer, fenced.** One worker holds a session at a time. A worker that was replaced without knowing it is refused at its next write, so "two never write at once". On a World with Workflow's in-band writer fence, the refusal happens in the store itself. Locally, `local()` checks whether the lease holder's PID is still alive with `process.kill(pid, 0)`, so a crashed worker's session frees at once.

**3. Every tool declares whether it may run twice.** This is the one design decision an integrator has to make, and libfx refuses a tool that does not make it. A call to an `idempotent: true` tool that a crash cut off runs again, under the same `executionId`. Any other tool never runs twice on its own: the model gets an error saying the call may have partly run, and decides whether to check, retry or ask. The README's suggested pattern for a write you want retried is to pass `executionId` to the downstream service as an idempotency key and then mark the tool idempotent. fx0's split is in `lib/libfx-agent.ts`: `read_design`, `publish_design`, `web_fetch`, `read_tool_result` and `capability_search` re-run, while `edit_design`, `ask_user_question` and `subagent` do not. Publishing the same full document twice is harmless. Applying the same edit twice is not.

**4. The model is told.** A resumed turn adds a user message saying the session was interrupted. libfx does not pretend nothing happened; it makes the model aware so it can check before going on.

The durability is also specified, not only coded. `tests/tla/durable/` holds seven TLA+ models (Fencing, Inbox, Journal, ParallelBatch, Resumption, SnapshotTail and ToolReplay), and `tests/tla/sdk/` has Bun drivers that walk the real SDK code along the checked state graphs (measured, by listing the folders). I did not run TLC.

## What I measured: killing libfx mid-turn

The README describes what should happen. I checked it. The harness (my code, in scratch) has three parts:

- a fake model: a Node HTTP server that speaks the OpenAI Chat Completions streaming format, wired in through libfx's custom-provider option. It asks for `get_order`, then `refund_order`, then answers;
- two tools that append each execution, with its `executionId`, to a log file. `get_order` is `idempotent: true`. `refund_order` is `writes: true`, and I ran it both ways;
- a parent that starts a worker on `local()` durability, waits until the worker is inside the step I want, sends `SIGKILL`, then starts a fresh process that calls `session.resume()` on the same session ID.

Everything ran on a shared 4-core cloud VM, CPU only, under `nice -n 19`.

| Kill inside | Runs | Model requests seen | What the new process did | Resume time |
|---|---|---|---|---|
| `refund_order`, not idempotent | 3 | 3 every time | never re-ran it; the model got a tool error | 113–185 ms |
| `refund_order`, idempotent | 1 | 3 | re-ran it once, same `executionId` | 126 ms |
| model request #2 | 3 | 4 every time | re-sent the cut-off request; `get_order` re-ran in 1 of 3 | 135–284 ms |
| `get_order`, idempotent | 9 | 3 in 5 runs, 4 in 4 | re-ran `get_order` every time | 125–259 ms in 4 runs, about 30 s in 5 |

All of it is measured. Five findings come out of the table.

**Finished model calls are not paid twice.** When the kill landed in a tool, the fake model saw exactly three requests across both processes, the same as an uninterrupted turn. Model requests #1 and #2 were replayed from the log into the new engine, not re-sent. When I killed the process while request #2 was in flight, that one request was sent again, for four in total. That is the right behaviour: the reply never reached the log.

**A non-idempotent tool really is not re-run.** In all 3 runs, `refund_order` executed once, in the killed process. The resumed model received this tool result, verbatim: "fx stopped while this tool was running, so it may have partly run. Check its effects before running it again." Followed by the user message "Resuming from unexpected session interruption." The decision whether to refund again goes to the model, which in a real deployment means it should have a way to check, such as a `get_refund` lookup.

**An idempotent tool re-runs under the same `executionId`.** With `idempotent: true`, the refund ran in both processes, and both logged `executionId: call_refund_order`. A payment API that honours idempotency keys would refund once. Note what that ID is: the tool-call ID the model emitted. It is stable because libfx stored the call before running a `writes: true` tool.

**Idempotent tools run more often than you would guess.** A read with `idempotent: true` and no `writes` "starts as soon as the model asks for it, while libfx stores the call" (libfx README). That overlap is a latency win. It also means a kill can land before the call is stored. In 4 of my 9 `get_order` kills the new process asked the model again, which is the README's documented behaviour. In 1 of 3 kills during model request #2, `get_order` ran a second time even though its result had already reached the model. Both are harmless for a lookup, which is exactly what the flag promises. Do not mark something idempotent because it is cheap.

**Recovery is usually sub-second, but 5 of 9 lookup kills took about 30 seconds.** The resumed turns ended in 113 to 284 ms, except for five `get_order` runs, which took 29.8 to 29.9 s. That number does not depend on whether the model was asked again. libfx has two 30-second constants in `durable.js`: the default reserve before a function's deadline, and `retryEndWaitMs`, "how long a retried turn waits for an end the log already holds". I did not trace which one this is, so it stays an observation on `local()`, not a diagnosis. It never lost the turn.

The local store for a three-step turn came to 33 to 37 KB on disk (measured).

<CrashReplay />

## On Vercel: where the minutes go

Locally, liveness is knowable: a dead PID is dead. On Vercel it is not, and that sets fx0's worst case. The constants below are reported in the READMEs, and the arithmetic is mine.

- **A crash in the request that sent the prompt.** "On Vercel, libfx can't tell a crashed function from a slow one", so that request holds the session until its 800-second limit. Vercel Queues keeps the prompt as a backstop due 5 seconds after that. A turn that crashes 20 seconds in therefore resumes 805 seconds after the send. That is 13 minutes 25 seconds, the README's "about 13 minutes" (reasoned). The earlier the crash, the longer the wait.
- **A turn that just runs long.** libfx stops it 30 seconds before the limit, keeps what it has done, and queues the rest to the `/api/libfx` route. A long turn spans several invocations without any crash.
- **A crash inside a queue delivery whose process exits.** The delivery runs again about a second later.
- **A delivery that stalls without exiting.** It holds the message until the queue's hold runs out, up to five minutes.
- **Opting into leases.** `vercel({ leaseMs: 15_000 })` renews a 15-second lease every 5 seconds, so a dead worker frees about 15 seconds after its last renewal. The cost: every prompt goes through the queue, and you give up the in-request start.

The default trades worst-case recovery for best-case latency, and the fx0 README says so in its Limits section. The page keeps showing the reply as running during the wait.

<RecoveryClock />

<Figure
  src="https://media.thesatyajit.com/ai/articles/fx0/fig2.png?v=ebfa3ef194940347"
  alt="Flow chart. You send a message; 1. fx0 saves your message; 2. fx0 seals your token, so your own team pays; 3. libfx runs the turn, saving every step as it goes (highlighted). A solid arrow goes to 4. You see the reply as it's written. A dashed branch labelled 'the function crashes' goes to 5. Vercel Queues starts a new function that picks up at the last saved step, then 'later, nothing lost' back to step 4."
  caption="One fx0 message from send to reply; the dashed path is the crash case (fx0 README, 'Durability' diagram)."
/>

The wiring on fx0's side is small. One route, `export const POST = agent().wakeHandler()` with `maxDuration = 800`, and one `experimentalTriggers` entry in `vercel.json` subscribing it to the topic `__libfx_wkf_workflow_session`. The trigger makes the route private: only Vercel Queues can call it. fx0's README says it "has no code of its own to recover a turn". Most of fx0's remaining complexity is product, not recovery:

- `lib/turn-credential.ts` seals each turn's funding in Redis, because a queue delivery has no browser cookie.
- `lib/projector.ts` copies libfx's event stream into fx0's own transcript, with a cursor check so each line lands once however many tabs follow it.
- `lib/journal.ts` makes every Redis write a single Lua script, so it is atomic.

## How this differs from v0, and from other harnesses

**v0 is Vercel's hosted product; fx0 is a fork-it-yourself sample.** Rauch's post says the plumbing was built "for v0". Neither repository shows v0's code, so whether production v0 runs on libfx today is his claim, not something I can check. What the code does show is the scope gap. fx0 makes one sandboxed HTML file, with no project, no npm and no deploy. A file with `connect-src 'none'` cannot call your API.

**libfx is the agent loop, not a workflow engine.** Vercel Workflow and similar durable-execution systems make your own code resumable, step by step. libfx sits on Workflow's World, but you write no workflow: the loop, its log and its replay rules are libfx's. You supply tools and their idempotency flags. That is a narrower contract, and an easier one to get right.

**Other harnesses log for different reasons.** The site's piece on [DeepSeek Harness: an agent harness that refuses to send what it didn't log](/articles/deepseek-harness) is about a log as a correctness check on what is sent to the model. libfx uses the log to survive a dead process. [Cloudflare OS: approve the agent's writes after it has finished](/articles/cloudflare-os) splits reads from writes for human approval. libfx splits idempotent from non-idempotent calls for crash replay. Same instinct, different failure. On the artifact side, [json-render + Jev: generative UI in two model calls](/articles/generative-ui-by-decision) is another Vercel Labs take on generated UI, where a model chooses from your components instead of writing HTML. fx0's ten `design.md` styles come from VoltAgent's awesome-design-md, the same idea as the design vocabulary in [Impeccable: a design vocabulary for agents is mostly a linter with opinions](/articles/impeccable-design-skill). [Prompt caching in coding agents: where eight harnesses put the breakpoint](/articles/prompt-caching-harnesses) is the other half of "don't pay for a model call twice".

## What holds and what does not

- **"Durable"** holds at the level I could test: SIGKILL anywhere in a tool or a model request, and a fresh process finished the turn without re-paying a completed model call or re-running a non-idempotent tool. I tested `local()`, not `vercel()`. On Vercel, the recovery delay is bounded by the function limit, up to 805 seconds for a crashed prompt request.
- **"Really fast"** is the model and the in-request start. libfx adds no queue hop to an idle session's first call. Nothing in either repository benchmarks end-to-end time to a design.
- **"Easy"** holds for the wiring: one route and one `vercel.json` line. The hard part moves to you, as it should: deciding which tools are idempotent, and giving the model a way to check a write it cannot see.
- **"Free"** means the code is Apache-2.0. Model calls are billed through AI Gateway, either to your team or, for the five free prompts, to whoever runs the deployment.
- **Complex pages** are a fair complaint in the replies. One user posted a "digestive system" that did not come out as one. That is a model and a 512 KB, single-file format, not durability.

The interesting artifact here is not the HTML. It is a 0.0.x library whose tool interface makes "can this run twice?" a required field. A crash-safe agent comes down to answering that question for every tool.
