Skip to content

Course · Pi · Subagents

Spawn a child Pi and read its JSONL

Here’s the spoiler for this lesson, stated as a claim you can check yourself: spawn a child Pi to process one URL from imports/old-saves.json, and your own context - the one you’re sitting in right now - doesn’t grow by a single token. Every token the child spends, it spends in a process you never opened a window into. That’s the whole trade this module is about, isolation bought for the price of a handoff, and this lesson is where you build the one primitive underneath every version of it.

Pi has four run surfaces: the interactive TUI (the default), plus three headless ones - -p, the one-shot switch; --mode json, machine-readable event output; and --mode rpc, for driving Pi as a subprocess from any language. The flags stack: -p makes a run single-shot whatever its output mode. So far this course has only used the first surface. The other three exist for exactly this module: they’re what let one Pi process treat another Pi process as a tool.

There’s no pi.registerSubagent(). Pi’s author puts sub-agents on the explicit list of things Pi intentionally doesn’t ship. What there is instead: child_process.spawn("pi", [...]), pointed at a prompt instead of a person, with its output piped somewhere you can parse. Call this the naked spawn: no dispatcher, no registry, no roster of typed roles - a child process and a pipe, nothing else.

Everything you’ll build for the rest of this module - chains, fan-out, a verifier that checks a builder’s work - is this one call, wired up in different control-flow shapes.

Pick one URL off the import - imports/old-saves.json, the export of saved links this module’s opener sets up - and hand it to a child agent, headless:

pi --mode json -p "imports/old-saves.json holds the URLs to import. Take the
first one, https://notes.example.org/. Work out whether that site publishes
a feed: fetch the page and look for a feed link - stash/feeds.py's
discover() returns the URL unchanged either way, so don't lean on it.
If a feed exists, add a row to the feeds table and run the
fetch-and-parse pipeline for it, the same procedure the add-feed-source
skill describes. Report the rows you saved, with their published values." \
--no-session \
--tools read,bash \
--thinking off

Read that flag by flag, because each one is a deliberate choice, not boilerplate:

  • --mode json - instead of TUI chrome, stdout becomes a stream of newline-delimited JSON events: text as the model writes it, every tool call, every result, and a final event when the turn ends. This is what makes a child process legible to a parent process instead of just a wall of terminal output.
  • -p "<prompt>" - print mode. One prompt in, the agent runs to completion, then exits. No follow-up turn, no chat - you’ll come back to why that matters when you build the verifier.
  • --no-session - this child is ephemeral. It doesn’t need to remember this one URL tomorrow; when it exits, its context goes with it. (Give it --session <file> instead if you do want it resumable - that’s how a dispatcher gives each specialist persistent memory across repeated calls, and it’s also what the verifier two lessons from now depends on.)
  • --tools read,bash - a narrower toolset than your own session gets. This child greps pages, runs inline Python against stash.db (the database of stash, the course’s running example, a small FastAPI feed reader), and reads what came back; it has no business editing stash’s code, so write and edit stay out. Scoping a child’s tools to exactly what its one job needs is the same instinct behind the damage-control gate (a rule file deciding which actions are off the table) - just applied per-process instead of per-rule.
  • --thinking off - this is fetch-and-match work, not a judgment call. No reasoning budget to spend.

Five flags, one deliberate choice each. Nothing above is boilerplate you can skip reading.

Run that from inside an extension, a script, anything with a stdout pipe, and you get one JSON object per line, one event at a time. The three event types that matter first:

import { spawn } from "node:child_process";
const child = spawn("pi", [
"--mode", "json",
"-p", "Take https://notes.example.org/ from imports/old-saves.json. Does the site publish a feed? If so, add it to stash and run the first sync. Report the rows you saved.",
"--no-session",
"--tools", "read,bash",
"--thinking", "off",
]);
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk;
let newlineAt;
while ((newlineAt = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newlineAt);
buffer = buffer.slice(newlineAt + 1);
if (line.trim()) handleEvent(JSON.parse(line));
}
});
function handleEvent(event) {
switch (event.type) {
case "message_update":
// assistant text streams in as it's generated
if (event.assistantMessageEvent?.type === "text_delta") {
process.stdout.write(event.assistantMessageEvent.delta);
}
break;
case "tool_execution_start":
console.log(`\n[tool] ${event.toolName}`);
break;
case "agent_end": {
const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
console.log(`\n[done] stop reason: ${lastAssistant?.stopReason}`);
break;
}
}
}

Look at how the lines get split, because the obvious way is wrong. Node’s readline module looks purpose-built for this - a stream of lines, a line handler - and Pi’s own RPC documentation warns against it by name. The stream is strict JSONL with LF as the only record delimiter, and readline is not protocol-compliant, because it also splits on U+2028 and U+2029, the Unicode line and paragraph separator characters.

That distinction sounds exotic until you remember what’s inside these records. U+2028 and U+2029 are legal characters in a JSON string, and JSON strings are exactly what a child streams back - model text, in message_update event after event. The day the model emits a line separator mid-sentence, readline hands your parser half a record, JSON.parse throws, and in an unattended run nobody sees it happen.

The ten lines above are the fix the docs recommend: accumulate chunks in your own buffer, cut at each \n and only at \n, parse what you cut, keep the remainder for the next chunk. It is barely more code than readline, and it is correct on the input readline silently corrupts.

Past the splitting, message_update carries the model’s own words as they stream; tool_execution_start/tool_execution_end mark every bash call the child makes; agent_end fires once, when the child’s turn is fully done, carrying the whole messages[] array for the run - you pull stopReason (and usage, if you’re tracking token spend) off the last assistant message in that array, not off the event itself.

Nothing here is Pi-specific plumbing you have to reverse-engineer - it’s the same event vocabulary your own interactive session is built on, just handed to you directly on a pipe instead of rendered into a terminal UI.

One child, one URL, one narrow toolset, output you can parse instead of a black box you have to trust. That’s the naked spawn - the whole primitive, spent on the smallest possible job. The import isn’t one URL, though - it’s every saved link in your friend’s old account, and you’re not going to write this call by hand for each one. Next: chain a few of these in sequence, then fan a big batch of them out in parallel.