Just Enough Node: Porting the Pi Coding Agent to the ESP32-P4
Pi is a TypeScript coding agent harness people build on, and it runs wherever Node ≥ 22 runs — nowhere near a microcontroller. We wanted the complete agent living on embedded hardware, not a screen paired to a server, so we ported it layer by layer: a Node-shaped runtime in Rust around QuickJS (the unmodified 9 MB pi-coding-agent runs on the desktop with no Node installed), a 304 KB pi-agent-core profile for the board after the 46.6 MB measurement, native Rust tools standing in for POSIX on LittleFS, a streaming model transport over Wi-Fi or UART, and a PocketJS UI wired end to end. What Pi assumes, what the chip lacks, what broke, and how it ran.
Written by Siwei "Jerry" Yuan

Pi is a coding agent harness people actually build on: a deliberately minimal TypeScript loop with schema'd tools, streaming providers, and a clean extension seam. It runs wherever Node ≥ 22 runs — which is to say, nowhere near a microcontroller. We wanted the complete Pi to be usable on embedded devices: an agent that lives on the hardware, with its loop, files, schedules, and behavior on board, rather than a screen paired to a server that runs the real agent somewhere else. And we wanted it in TypeScript specifically, because an agent whose behavior is code-as-data can eventually revise itself — something an agent harness compiled into firmware can never offer.
So we ported it to an ESP32-P4 (PR #9). This is the story of how, layer by layer — starting with the part that didn't exist: embedded JavaScript has always meant an engine, never a runtime. Pocket Pi is the runtime we built so that Pi would have something to stand on. (If you are new here: PocketJS runs real web-framework components on 2004 handhelds; QuickJS, its engine, is about to get a very different tenant.)
What Pi stands on
Pi's architecture is what made the port thinkable. Upstream, it splits into layers with real boundaries:
pi-agent-core— the agent loop, turn state, and tool-call protocol. The model decides when to call a tool; tools return structured results; the loop knows nothing about transports or platforms.- The tool registry — every capability is a schema'd value, not a hardcoded behavior.
- Provider transports — streaming adapters, separate from the loop.
pi-coding-agent— the full product on top: sessions, extensions via a(pi) => voidfactory seam, the works.
Nothing in that stack inherently needs Node. It just assumes Node — casually, everywhere, the way all server-side JavaScript does: fs, path, Buffer, streams, fetch, an event loop, an ESM module graph. Engines like QuickJS, JerryScript, and Moddable XS give you ECMAScript and sometimes a module system of their own; none gives you that floor. Porting Pi therefore meant one thing at every layer: find what Pi assumes, and supply it.
First, the desk: unmodified Pi with no Node installed
The port did not start on the board. It started with a stricter question: can the full, unmodified pi-coding-agent — the entire 9 MB bundle — run on QuickJS on a desktop, with no Node or Bun on the machine?
Getting to yes meant building the Node-shaped layer in Rust, feature by feature, in the order the bundle demanded it: a module system with CommonJS interop, then Web globals (fetch, streams), then the rest of the builtin surface — filesystem, path, Buffer, events, streams, process, synchronous subprocesses. Two problems stood out. Pi's dependency graph contains indirect ESM re-export cycles that QuickJS's module loader refuses outright, so the build pipeline learned to rewrite them before the engine ever sees the code. And extensions had to stay dynamic: Pi's plugin seam takes TypeScript factories at runtime, so the runtime transpiles them on the fly with oxc and injects them through Pi's own extensionFactories — the agent can be handed new tools, or write its own, without anyone recompiling anything. Pi itself is never patched.
That desktop profile still ships as the product's full tier. It also produced the number that shaped everything after.
46 MB of agent, 32 MB of PSRAM
With the runtime working, we measured what the board would actually have to hold:
Booting the full bundle takes 46.6 MB of heap — 27.4 MB of it just compiled JS functions — against 32 MB of PSRAM that also has to hold framebuffers, Wi-Fi buffers, and TLS. That chart closed the "ship the desktop profile" debate in an afternoon and split the runtime into two compositions of one family:
The embedded profile runs upstream pi-agent-core — the loop, the turn state, the tool-call protocol, unchanged — bundled to 304 KB, holding a conversation turn in about 1.3 MB of heap. Under it sits the same runtime idea taken to its floor: the Rust host provides exactly what that core touches — TextEncoder, a microtask pump, URL, a streaming transport, tool dispatch — and nothing speculative. The list is honest because every missing item announced itself; the first on-device tool call died with URL is not defined, and URL joined the shims.
Even getting QuickJS to parse the bundle on-device was a fight. Fully minified esbuild output produces expressions nested deeply enough to blow the 8 KB task stack before the agent ever ran — and a later variant kept the parser busy for four minutes, tripping the 5-second watchdog. The fixes were humbling: minifyWhitespace only, and a dedicated 64 KB stack for the agent task.
Tools are the operating system now
On the desk, Pi's tools lean on POSIX. On the board there are no processes, no pipes, no /bin — so every capability had to be rebuilt as native Rust behind Pi's own schemas, on a chip that fights back:
The file six-pack — read, write, edit, find, grep, ls — landed on an 8 MB LittleFS partition, and immediately taught us LittleFS's rules: rename on a file whose handle is still open returns EBUSY, a lesson we got to learn twice, once in the file tools and once more in schedule persistence. bash became an honest allowlisted dispatcher (ls, cat, grep, wifi status, reboot; backticks rejected) rather than a POSIX cosplay that would only teach the model to fail. workspace.context aggregates the agent's own Markdown memory into its context, bounded at 16 KiB. And schedule.* turned the agent into something with a pulse: one-off and recurring wake prompts persisted to /workspace/.pi-agent/schedule.json, missed wakes collapsing into one catch-up run, fired by the firmware's wake loop with no human and no server involved. The Pi runtime reads tool definitions from the executable registry itself, so advertised and executable tools cannot drift.
The model is the one thing that stays remote — same as a laptop. Pi's provider layer kept its upstream boundary and grew two hosts: WirelessBackend speaks Chat Completions and Anthropic Messages over HTTPS, and UartBackend streams through a Mac's logged-in Codex or Claude Code CLI during development. Everything that decides, executes, and persists stays on the board.
Wiring the glass
An agent you can hold needs a screen you can touch, and this is where PocketJS pays for itself twice. Its engine already hosts the agent; its renderer got a sibling crate, pocket-pi-device-ui, that emits draw lists straight to the panel — chat with streamed replies, the workspace file browser, Wi-Fi settings, an on-screen keyboard, and a telemetry bar showing real PSRAM, per-core CPU, and presented-frame FPS:
The end-to-end interaction loop runs across two threads: touch events map through a hit table into screen state, the agent works on a worker thread, and streamed model deltas are projected into the chat as they arrive, so the UI never blocks on the model. Linking the renderer cost about 24 KB of code, with zero PocketJS engine changes — the port pins an unmodified upstream revision. The same crate compiles into a macOS simulator (that is what the hero image shows): one draw list, one set of font atlases, one touch hit map, mouse clicks dispatched through the same handle_tap as the hardware touch controller, so product flows iterate in seconds while the board stays the acceptance target.
The glass also produced the port's best bugs. The panel flashed blue once per second, and we fixed it three times: triple buffering helped but didn't cure it, because the real cause was the MIPI-DSI controller underrunning while fetching frames from PSRAM — the ESP-IDF driver source literally documents that the panel "may turn blue." Damage-region rendering cut the write traffic; dropping the pixel clock from 58 MHz to 34 MHz and enabling the PPA finished it. And at the very end, with everything working, the model streamed I'm Pocket Pi — and the panel rendered I?m. The font atlases were ASCII-only; the model uses U+2019 like any well-raised language model. The last fix of the port baked curly quotes, dashes, and the ellipsis into all four atlases with the PocketJS font baker. If you want proof that a real model is talking to your firmware, typography is it.
The end-to-end run
The acceptance run is a 45-second recording of the board doing, in order:
- answering
Who are you?as a Pi agent on an ESP32-P4; - calling
device.statusand reporting live heap numbers — values that change between runs, so they can't be canned; - reading three workspace files and writing a summary it composed itself, then showing the new file in the Files browser;
- setting a one-minute schedule and waking up on its own, untouched, to reply;
- coming back from a hard power cut with files and schedules intact — they live in LittleFS, not RAM.
Each step closes a specific skeptic's loophole: real tool calls instead of scripted text, a filesystem that visibly changed, a wake with no hands on the device, and persistence proven by cutting power rather than by claiming it.
The gaps, named, because that is house policy
- The board runs the embedded
pi-agent-coreprofile, not the desktoppi-coding-agentunchanged. That distinction is load-bearing and 46.6 MB wide. - Runtime TypeScript extension loading ships in the desktop profile; the embedded profile's tool registry is still fixed at boot. The runtime treats code as data, so the path is open — but on-device hot-loading is direction, not shipped.
- The embedded runtime is not general Node: it implements what
pi-agent-coreexercises, and a dependency that wants more will say so at parse or boot. - File tools and the on-device viewer handle UTF-8 text — no PDFs, no images.
bashis an allowlisted dispatcher, not POSIX. - Inference is remote; the model is a provisioned network service. Model transport errors still render as chat text rather than a proper faulted state.
Try it
The repo is pocket-stack/pocket-pi. The simulator runs the same embedded agent, tool registry, and UI crate as the firmware:
cargo xtask run esp32-p4-sim --backend codex --workspace target/esp32-workspaceAnd if you have the board:
cargo xtask build esp32-p4
espflash flash --baud 921600 --port "$DEVICE_PORT" \
firmware/esp32-p4/target/riscv32imafc-esp-espidf/release/pocket-pi-p4
python3 tools/uart-model-bridge.py "$DEVICE_PORT" --backend uart --provider codexWhere this goes next follows from the property that justified the whole port — behavior as files. Apps as tools-plus-a-view in the agent's own workspace, loadable and revisable by the agent itself, are the obvious continuation; that is a post for when it is real.
The missing piece was never the agent, and it was never the chip. It was the runtime between them. Ask the board who it is. It knows.