Run the unmodified pi coding-agent — and its extensions — inside QuickJS. No Node, no bun.
Pocket Pi is a small Rust runtime that embeds QuickJS and gives JavaScript exactly
enough of a Node/Web platform — a module system, the node: builtins, fetch,
and a native TypeScript loader — that the whole, unmodified pi-coding-agent
runs on it, drives real LLM turns with tools, loads real extensions, and
persists sessions. It is the substrate the
cat desktop-assistant harness runs on,
and the sibling of PocketJS: where
PocketJS proved a UI runtime can live outside the browser under a tiny budget,
Pocket Pi does the same for an agent runtime.
┌────────────────────────── PiRuntime (one QuickJS realm, one thread) ──────────────────────────┐
│ prelude.js timers · AbortController · fetch/Response/ReadableStream · Blob/FormData │
│ node: builtins fs · path · child_process · process · buffer · events · stream · … (JS) │
│ pi-full.bundle the UNMODIFIED pi-coding-agent, esbuild-bundled to one ES module │
│ your extension a .ts factory, transpiled by oxc at load — NOT jiti │
│ │ __node (resolve/fs/spawn) host.http host.transpile host.tool host.emit │
│ ──────┼───────────────────────────────────────────────────────────────────────────────────── │
│ native │ NodeResolver/NodeLoader · oxc TS→JS · fs & subprocess ops · HTTP hub (TLS+SSE) │
│ pump() │ timers → deliver fetch/LLM chunks → drain job queue → flush events → your callback │
└────────┴───────────────────────────────────────────────────────────────────────────────────────┘
- Unmodified
pi-coding-agentruns end-to-end on QuickJS — a real gpt-5.6 turn streams tokens and completes, tools and all. No source edits to pi; it's a real npm dependency synced withnpm update. - Extensions load through Pocket Pi's own loader (oxc TypeScript transpile, no jiti). A normal pi extension registers tools and lifecycle hooks, and the agent can call those tools in a live turn.
- Sessions persist and resume — pi's
SessionManagerreads/writes.jsonlthrough the runtime'sfsbuiltin. - A near-complete Node/Web platform: a Node resolver/loader (relative,
node_modules,exports/imports,.tson the fly), CommonJS interop, ~30node:builtins, and WHATWGfetch/Response/ReadableStream/Headers/URLbacked by a native, proxy-aware HTTP hub. - A coalesced frame scheduler — an agent spends almost all its time waiting on
the model, so the host drives work in
pump()frames and can run as slow as 2 Hz while a turn streams, at near-zero idle CPU. See ARCHITECTURE.md. - CI: clippy (
-D warnings) + build + test on every push and PR.
An extension for Pocket Pi is the same file an unmodified pi extension is: a
module whose default export is a factory (pi) => void (or async). Pocket Pi
loads it through its own module system, so the file is written in TypeScript and
may import node: builtins, relative modules, and npm packages.
// my-extension.ts
export default (pi) => {
pi.registerTool({
name: "echo",
description: "Echo the given text back.",
parameters: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
},
// pi calls execute(toolCallId, input, signal, onUpdate, ctx).
// Return a result whose `content` is an array of content blocks.
execute: async (_id, input) => ({
content: [{ type: "text", text: String(input.text) }],
}),
});
pi.on("agent_start", () => {
/* lifecycle hook — fires when a turn begins */
});
// also available: pi.registerCommand / registerFlag / registerShortcut /
// registerMessageRenderer / sendMessage / exec / getActiveTools / …
};Pocket Pi loads it through pi's own extensionFactories seam — no jiti, no Node.
The host imports the file (which routes through the oxc loader) and hands the
factory to createAgentSession; the session's extension runner then exposes the
tool and hook. The runnable reference is js/src/pi-full/driver.ts:
// the essence — see driver.js for the full session setup
const factory = (await import("/abs/path/my-extension.ts")).default; // ← oxc transpiles the .ts
const resourceLoader = new DefaultResourceLoader({
cwd, agentDir, settingsManager,
noExtensions: true, // skip on-disk (jiti) discovery
extensionFactories: [factory] // inject ours — loaded via loadExtensionFromFactory, no jiti
});
await resourceLoader.reload();
const { session } = await createAgentSession({ model, resourceLoader, tools: ["echo"], /* … */ });Everything import-resolves (a bundled extension never crashes at load), but the surface splits into real, partial, and stub:
| Module | Status |
|---|---|
fs, fs/promises |
Real (native) — read/write/append/readdir/mkdir/stat/exists/realpath/unlink, sync + callback + promise, plus openSync/readSync |
path, buffer, events, util, stream, string_decoder, os, url, querystring, assert, timers, readline, module, process |
Real JS implementations |
child_process |
Real spawnSync / execSync / execFileSync (native subprocess) |
crypto |
Partial — randomUUID (real host entropy), randomBytes/getRandomValues (Math.random), createHash/createHmac use FNV (fast, non-cryptographic — fine for cache keys/ids, not for security) |
http, https, net, tls, dns, zlib, vm, v8, worker_threads, async_hooks, perf_hooks, tty |
Stub — imports resolve; classic socket/server/client calls throw or no-op |
For networking, use the global fetch() — real, streaming, and proxy-aware
(routes through the native HTTP hub); http.request/net.Socket are stubbed
toward it. Also available as globals: Response, Headers, URL,
URLSearchParams, ReadableStream, TextEncoder/TextDecoder, Blob/File/
FormData, AbortController, structuredClone, Buffer, setTimeout/setImmediate.
Bottom line: an extension that reads/writes files, shells out, or calls an
HTTP API runs unmodified. Socket servers, native compression, and worker threads
do not yet — those builtins exist only so imports resolve. Adding a real one is
one row in crates/pocket-pi/src/node/builtins.rs (the single source of truth for
builtins) plus its JS shim.
If you're building a host on top of Pocket Pi — like cat — this is your surface.
PiRuntime owns one QuickJS realm and is driven from a single thread; the host
owns the pump() cadence.
use pocket_pi::{PiRuntime, ToolResult};
let mut rt = PiRuntime::new()?;
// Native tools are Rust closures the agent calls by name.
rt.register_tool("current_time", |_args| ToolResult::text(now_unix().to_string()));
rt.on_event(|ev| println!("{}: {}", ev.kind, ev.raw)); // start / text / tool_* / end
rt.boot(r#"{"model":"gpt-5.6","apiKey":"…","systemPrompt":"Be terse."}"#)?;
rt.prompt("What time is it?")?;
// Pump at whatever cadence suits the host — 2 Hz is plenty while streaming.
while !rt.is_idle() {
rt.pump()?;
std::thread::sleep(std::time::Duration::from_millis(500));
}PiRuntime::new() embeds and loads the whole, unmodified pi-coding-agent —
sessions, extensions, pi's own tool suite — plus a host harness. boot stands up
a pi session from a config (provider/model/apiKey/systemPrompt/tools);
each config tool bridges to the native Rust closure you registered with
register_tool (that's how cat's look_at_screen screenshot tool reaches the
agent); prompt runs a turn and events stream to on_event. There is one path —
no trimmed variant, no feature flag.
Other PiRuntime methods: run_module, eval_script, get_global_json,abort,
is_idle.
QuickJS's module linker null-derefs (js_inner_module_linking,
quickjs.c:30806) when you import createAgentSession and pull pi-coding-agent's
~500-module circular graph — an engine-level bug on graphs that large. The fix is
to hand QuickJS one module instead of five hundred: esbuild bundles the
unmodified pi source into a single ES module (only node:* left external), and
Pocket Pi's Node/Web layer satisfies it at runtime. Bundling isn't forking —
every dependency is the real, unmodified upstream package. (The one substitution
is undici, pi's HTTP-proxy transport, aliased to a stub — Pocket Pi proxies in
the native hub, so undici is never used; this keeps its whole web-fetch stack out
of the bundle.)
cd js && npm install
node build.mjs # → pi-full.bundle.js.gz (~1.8 MB, committed + embedded) + runtime glueThe .gz is committed and include_bytes!'d into the crate, so PiRuntime
carries the whole pi with no external files — and cargo build (including from
cat's vendored submodule) needs only Rust, no Node.
Staying in sync with upstream pi (@earendil-works/pi-coding-agent) is
npm update + node js/build.mjs + commit — no patches to carry.
Everything ships as one self-contained binary carrying the whole unmodified
pi (the ~9 MB bundle, gzip-embedded to ~1.8 MB) — nothing to npm install at
the destination:
| Shipping pi as… | Size |
|---|---|
| Pocket Pi — one binary, full unmodified pi embedded | ~8.9 MB |
bun build --compile (providers external; embeds JavaScriptCore) |
~61 MB |
node runtime + node_modules (pi-agent-core + pi-ai + deps) |
~114 MB + ~131 MB |
The base is dominated by oxc (~1.6 MB — the TypeScript transpiler for
extensions), TLS rustls+ring (~0.6 MB), regex (~0.5 MB), and QuickJS (~0.5 MB).
# Acceptance: a self-contained ~8.9 MB binary carrying the whole pi says hello.
OPENAI_API_KEY=… cargo run --release --example hello # → "Hello! Nice to meet you."
# The full pi bundle is committed + embedded, so the whole suite runs with only
# Rust — no Node, no build step. This includes the offline pi tests: it loads,
# an extension binds to a session, sessions persist to disk.
cargo test
cargo clippy --workspace --all-targets -- -D warnings
# Rebuild the guest layer only after editing js/src/**:
cd js && npm install && node js/build.mjs
# Real-turn tests need an API key + (here) a proxy; they skip without one:
OPENAI_API_KEY=… OPENAI_MODEL=gpt-5.6 https_proxy=http://127.0.0.1:7897 \
cargo test -p pocket-pi live_openai_turn -- --nocapture # boot/prompt → real turn
OPENAI_API_KEY=… https_proxy=http://127.0.0.1:7897 \
cargo test -p pocket-pi runs_pi_turn_with_extension_tool -- --ignored --nocaptureMIT.