Runs fx inside a Cloudflare Worker, using libfx's WebAssembly build with
just-bash as an in-memory filesystem behind the
agent's shell tool.
A Durable Object per session holds a live wasm instance and its filesystem, so the terminal is genuinely interactive across turns rather than one-shot. Raw TUI bytes stream over a WebSocket and xterm.js renders them in the browser.
JSPI needs no flag here. libfx's wasm hard-requires JavaScript Promise Integration —
fx-sdk.js throws unless WebAssembly.Suspending and WebAssembly.promising exist. workerd
exposes both by default, so the agent instantiates directly in the isolate with no child process
and no runtime flag. Verified on deployed Workers, not just locally.
No server-side terminal emulator. Because output streams to the browser as raw bytes, nothing here needs to emulate a terminal, type prompts as synthetic keystrokes, or detect turn completion by watching for the screen to stop changing. xterm.js draws whatever fx paints, and keystrokes stream back the other way.
The native addon is irrelevant. libfx.*.node can't run on Workers, so this is wasm
throughout.
src/index.js— serves the client at/, upgrades/sessionto a WebSocket routed to one Durable Object per session id.src/session.js— theFxSessionDurable Object. Holds a live fx wasm instance and ajust-bashfilesystem for as long as the socket is open. Every command the agent runs is echoed to the client from the workspaceexecadapter, so the footer reflects commands that actually executed, not what the model claims it ran.src/client.js— xterm.js over the raw byte stream.src/instructions.js— anAGENTS.mdseeded into the workspace (see Limitations).
wrangler.jsonc declares {"type":"CompiledWasm"}, so import termWasm from "./fx-term.wasm"
yields an already-compiled WebAssembly.Module at isolate start — the compile never bills
against the 400 ms startup-CPU budget.
libfx exposes a clean headless createFxAgent() (ACP/JSON-RPC), which would have given
structured events instead of a byte stream. It sends no tools. Verified by intercepting the
Gateway request through libfx's fetch adapter:
| Surface | tools in the request to /v3/ai/language-model |
|---|---|
createFxAgent(), libfx 0.0.4 |
[] |
createFxAgent(), libfx 0.0.5-dev.285 |
[] |
createFxAgent(), libfx 0.0.5 (stable) |
[] |
createFxTerminal() |
[terminal] |
The workspace adapter is wired on both, but on the ACP path the model is never told the tool exists, so it can talk and never execute. Hence the terminal surface.
One WebSocket, two channels.
- Binary frames — raw fx TUI output, straight into xterm.js.
- Text frames —
{type:"status"}·{type:"ready",bootMs,cols,rows}·{type:"command",command,exitCode}·{type:"error",message,fatal?}
Client → server: {type:"input",data} (keystrokes) · {type:"prompt",text} (types a line and
presses return) · {type:"interrupt"} (sends \x03)
npm install # postinstall copies fx-term.wasm into src/
cp .dev.vars.example .dev.vars # then add your key
npm run devDeploy:
npx wrangler secret put AI_GATEWAY_API_KEY
npm run deployAI_GATEWAY_API_KEY is an AI Gateway API key — the credential fx's wasm expects. fx sends it as
a bearer token to ai-gateway.vercel.sh, so it must be a key valid for that gateway.
Local wrangler dev (workerd, wrangler 4.125.0, compat date 2026-08-01):
WebAssembly.Suspending / promising |
function / function — no flag |
| Boot to interactive TUI | 36 ms warm |
| Full turn, 2 shell commands + poem | 10 s (↑20 ↓259) |
| TUI stream | 1245 bytes over 21 binary frames for boot + one prompt |
just-bash in-isolate |
echo hi > a.txt && cat a.txt → exit 0 |
| Bundle | 6923 KiB raw / 2203 KiB gzip — under the 3 MB free-tier cap, but not by much |
| Default model | zai/glm-5.2 |
| libfx | 0.0.5 (fx v0.0.5) |
The TUI renders identically to the native CLI: 𝒇x v0.0.5 · Run /help for commands, auto · glm-5.2.
Verified turn — "create a file saying this is a test and then read it and then write a poem":
● 2 tool calls · 2 commands
├ Ran echo "this is a test" > ./test.txt
└ Ran cat ./test.txt
File created and confirmed — test.txt contains this is a test.
And here's your poem: …
10s (↑20 ↓259)
Both commands really executed inside just-bash (exit 0, surfaced as command frames).
- Project instructions are not discovered. The wasm build implements
path_open,fd_readdirandfd_prestat_dir_nameasunavailable— fx cannot open a file — and it does not shell out forAGENTS.mdeither (verified).src/instructions.jsseeds one into the vfs so the agent cancatit when asked, but fx logsproject instructions action=omittedevery turn. - No hibernation. The DO uses
server.accept()rather than the WebSocket Hibernation API, because a hibernating DO is evicted from memory and neither a JSPI-suspended wasm stack nor the just-bash vfs survivesserializeAttachment(). An idle session holds a DO resident. - Ephemeral filesystem. The vfs dies with the socket. Prompt history does not: a
promptHistoryStorebacked by Durable Object storage keeps it across reconnects, and the client pins its session id inlocalStorageso a reload reattaches to the same DO. !commanddirect terminal does not work — the TUI reportsDirect terminal was not started: unsupported host. That string lives only in the wasm and has no corresponding host import; a direct terminal needs a real PTY, which libfx's README lists among the things the WebAssembly runtime never provides. Use ordinary prompts instead — the agent's ownterminaltool works fine. Only the container path would change this.- No network or git in the shell. just-bash is pure JS;
curl,npm installandgitfail. - Permissions auto-approved.
workspace.permissionisallow-sandboxed. Safe only because just-bash is a sandbox with no host or network access. - Measurements are from local
wrangler dev. JSPI, the TUI and full turns are all verified on deployed Workers too, but the timings above are local. - xterm.js loads from jsDelivr, so the client needs network access to that CDN.
The idea this builds on — running fx's WebAssembly build headlessly with
just-bash standing in as an in-memory workspace for
the agent's shell tool — is from nicolasmontone/fx-inside-function
by @montonenico, which does it inside a Vercel Function.
That project also worked out that the terminal surface is the one that actually exposes a shell
tool, which saved a lot of time here.