<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap">

rafal@arasz:~$cat blog/pi-agent-and-integrations.md

Pi: the coding agent behind my agent framework work

I do most of my agent-framework work inside pi these days. Not as a demo. As the daily driver.

Pi (earendil-works/pi-coding-agent) is a terminal coding agent with a real extension API. Extensions observe session events, register tools and commands, and render their own message cards. That one design choice is why I stayed. Most agent harnesses let you script around the agent. Pi lets you build inside it.

How I use it

My framework (ai-badger) scaffolds personas into a project's .pi/agents/ directory. A delegation is a separate pi -p child process running one persona with one task. The parent session keeps working while children run, and each child's result lands back as a structured follow-up message with exit code, duration, token usage, and log path.

That loop, repeated hundreds of times, exposed every rough edge pi had. Background children nobody watched. Polling loops that burned the main turn. A router failure at 2am that killed a whole batch. So I fixed each one as an extension and published the set.

Why I left Hermes

I wrote a warm review of Hermes in July (Hermes Agent) and meant it. Its hooks and on-demand skills carried my agentic work for months, and ai-badger grew up alongside it. Two months later I moved to pi anyway. Three threads, in increasing order of decisiveness.

First, the overlap. Hermes ships everything: its own fallback command, its own memory graph, its own cron scheduler, and skill categories for git, email, productivity, and dozens more. Early on that was a gift. As ai-badger grew its own versions of the same features, every session loaded both, and Hermes's defaults started fighting my workflow instead of serving it. Two skill systems, two cron systems, two memories. I kept reaching for the ai-badger one while paying context for the Hermes one.

Second, the context bill, which I can put a number on. hermes prompt-size reports the fixed budget of a fresh session, and on this project it reads 199,263 characters: a 98k system prompt, a 63k skills index with 685 entries (144 names indexed more than once, one skill six times across namespaces), and 36k of tool schemas. Roughly 50k tokens before I type a word. Trigger a skill and its full body loads on top; the largest bodies on my machine run past 100,000 characters each. Measured 2026-09-05, Hermes v0.21.0.

The pi side of that ledger is short enough to quote whole. A stock pi session opens with a 2,762-character system prompt: eight tools as one-liners, nine guidelines, pointers to docs it reads only on demand. Under a thousand tokens. A fully loaded ai-badger task skill with a 300-character task description lands around 24,000 characters, roughly 6k tokens. Rendered 2026-09-05 from pi 0.85.1's own prompt builder, task skill as scaffolded. So the comparison is 50k tokens doing nothing against 6k tokens doing the actual job.

# loading chart…

Third, speed, which I cannot put a number on. No benchmarks. Just months of felt difference: with pi, even small models answer before my attention wanders; with Hermes, the same class of model regularly sat long enough that I went to check whether it was stuck. Perceived latency is a squishy metric and it still made the decision. The context bill argues. The waiting convinces. That was the last straw.

The extension set

The repo is pi-badger-integration. Seven extensions, 974 tests measured 2026-09-05 (968 pass, 6 skip, 0 fail, across 41 files), one publish command that installs them to pi's user scope. Each section below is the same shape: why the extension exists, how it works, and the one decision that makes it unusual.

subagent: background delegation

I parallelize everything and kept losing track of the children. The delegate tool runs a persona as a separate pi -p child and returns immediately with a receipt (d-N). The main loop stays interactive. When the child settles, exactly one delegation-result follow-up carries the outcome. Ordering comes from the queue tool (serial or parallel groups); inspection from delegations (list, log, abort, results).

Background delegation lifecycle

flowchart
Background delegation lifecycle exit, usage, log path tee every event delegate persona + task receipt d-N, keep working child runs as pi -p one delegation-result follow-up JSONL log per run

Every child's raw event stream is teed to ~/.pi/agent/subagent-logs/. User scope, deliberately. Logs survive reboots and never touch any project's git status, so after a restart the log directory is the durable truth about what ran. At most 4 children run at once with 16 queued, and a watchdog aborts any child silent for 10 minutes. The bravest decision is what was removed: in the TUI, delegation cannot block. Passing background: false is rejected at execution time, with guidance pointing at queue and wait instead.

monitor: predicate wake-ups

This one exists because I kept writing the poll loop myself. The monitor tool arms one-shot predicates over delegation transitions: a JS expression evaluated against the whole delegation fleet on every transition. The first time it holds true, the monitor fires exactly one monitor-event follow-up and disarms. The companion wait tool spends idle time without polling. It blocks until a delegation settles, a monitor fires, you type, or the timeout passes.

Predicate monitors replace polling

flowchart
Predicate monitors replace polling first true still false register predicate delegation transition evaluate against fleet one monitor-event, disarm stay armed

The guard I am proudest of is the no-polling rule. The monitor counts status checks (delegations list, log, results) and blocks the fourth one inside a sliding two-minute window, redirecting to wait or a monitor instead. It exists because I kept hand-writing the loop it now forbids. That stung enough to automate.

router-fallback: free-model fallback on router failure

The 2am story. A whole batch died on a dead provider route, so the extension advances the session model once per episode over a pinned chain: OpenRouter :free models first for breadth, then Groq as the workhorse, then Gemini last. An entry serves only when its key is set, and stale model ids resolve or skip instead of throwing, so a stale id bends the chain instead of snapping it.

Classify once, switch at most once

flowchart
Classify once, switch at most once billing, auth, dead route throttle 429 overflow or request error provider failure classify error + status switch once, OR free, Groq, Gemini hold silent, native retry owns wait ignore, never switch

Two things never switch models, and both are deliberate. Throttle (429 without billing text) holds silently while pi's native retry owns the wait. Context overflow and request-side failures (400/403/404) are ignored entirely. The first version switched on a 429 and I watched it burn a whole free-model chain on nothing. The unusual part is structural: the extension-level setModel takes no options, so the session-level persist path is unreachable. The fallback can move a session to a free model at 2am and cannot change anyone's stored default. That is pinned by test, not by convention.

pi-cron: scheduling inside pi

Recurring work I kept running by hand. Jobs are declared in ~/.config/ai-badger/cron.json, and the extension registers them on two rungs: in-process Bun.cron when pi runs under bun, otherwise one self-managed launchd agent per job. Pi ships as node today, so the second rung fires. Five-field schedules compile down to launchd calendar intervals, and sub-hourly schedules are refused outright (an every-minute job needs 1,440 dict entries against a budget of 366).

Two-rung cron registration

flowchart
Two-rung cron registration running under bun running as node cron.json jobs Bun.cron in-process launchd agent per job run command on schedule

The unusual part is rung one. It cannot fire today, and it is written so it cannot rot: the schedule-to-interval compiler is pure and fully unit-tested without touching launchd, so the day pi runs under bun the path just works.

pi-mcp-tools: universal MCP tools

Every project I touch declares MCP servers in .mcp.json, and pi needed to call them like any other tool. This is a fork of tickernelz/pi-mcp-tools, flattened for directory installs. Each session re-derives its MCP state from the project file plus global settings, registers the tools, and keeps a ledger: what got armed, what got skipped, what was untrusted. Status commands (mcp-status, mcp-list, mcp-toggle, mcp-reconnect) expose all of it.

Per-session MCP arming

flowchart
Per-session MCP arming parse clean converter error session_start project .mcp.json plus global register tools, write ledger fall back to global-only

The unusual part is how it fails. A broken project config degrades to global-only instead of failing session start. MCP setup must never be the reason a session does not open.

session-signals: urgent means urgent

Some messages cannot wait for the turn to end. Ai-badger's prompt markers (h:, f:, e:, q:, i:) accept a ! token between alias and colon, and any marker carrying it is interrupt-grade. When an f!: arrives while the agent is busy, the run aborts immediately and the message drives the next turn. Without the token, everything behaves as before. The same extension keeps a delegation progress line in the footer while children run.

Importance is orthogonal to meaning

flowchart
Importance is orthogonal to meaning bearing marker plain text bang present no bang turn already ending input arrives marker with bang agent busy abort run, steer next turn normal handling

Meaning and importance are orthogonal now, which is the whole point. Every marker can interrupt, and the enforcement sits on pi's input event, which fires on receipt even mid-turn. The legacy hooks only fire at turn start and can never see a message like that. This handler can.

shift-enter-newline: one terminal quirk, fixed properly

The JetBrains IDE terminal sends Shift+Enter as ESC+CR, which terminals read as Alt+Enter, pi's follow-up submit binding. So newlines submitted prompts. The extension watches for that sequence, checks whether the physical Shift key is actually held on the local Mac keyboard, and if so rewrites it to the Kitty newline sequence so the editor breaks the line. Without Shift held, genuine Option+Enter still submits.

Disambiguating Shift+Enter

flowchart
Disambiguating Shift+Enter held not held ESC+CR arrives physical Shift held rewrite to Kitty newline pass through to submit

What I like is the honesty about limits, shipped in the docblock and the /shift-enter-debug trace: it polls the machine pi runs on, so it does nothing useful over SSH, and Shift+Enter can no longer submit. Small extension, no pretensions.

The other direction: ai-badger's message bus reaches pi

Delegation covers parent and child. The message bus covers everyone else. Sessions sharing a project coordinate over ai-badger's bus: started work, touched files, opened PRs, review requests, merges. Sending is the send-message skill (1:1, project broadcast, machine broadcast). Receiving inside pi is the hooks adapter, whose canonical source lives in this same repo and vendors into ai-badger on publish.

Message-bus push delivery into pi

flowchart
Message-bus push delivery into pi hook fires addressed mail off policy or broadcast send-message from any session user-DB bus pure prefilter decides wake per policy land quietly, no wake

Each pi session carries a wake policy (addressed by default, all for broadcasts too, off to stay dark). The decision core is pure: no I/O imports, everything injected, so the whole state machine is unit-testable without a database or a process spawn. And it fails open. Any error spawns delivery rather than dropping mail, because a lost coordination message is worse than an extra wake-up.

What pi gets right

Three things, all load-bearing for this kind of work.

First, the event model. Extensions subscribe to agent_end, after_provider_response, model_select, and a dozen more. The fallback classifies failures from the folded error text plus the last provider status, and it does that without ever rewriting pi's own retry behavior. React, don't rewrite. That composability is rare.

Second, the session model. setModel changes the model for the session only, with no persist knob reachable from an extension. The fallback can move a session to a free model at 2am and cannot permanently change anyone's default. I verified that structurally, not by convention. Constraints you can test beat policies you document.

Third, providers are boring in the good way. Keys arrive as GROQ_API_KEY, GEMINI_API_KEY, and OPENROUTER_API_KEY, and entries without keys simply leave the rotation. No accounts, no OAuth dance on the fallback path, no SDK upgrade when a provider renames a model. Stale model ids resolve or skip instead of throwing.

Honest limits

The free-model chain is burst capacity, not a plan. OpenRouter's free tier does roughly 50 requests a day without credits, so the fallback buys you a night, not a month. The pinned model list is exactly that, pinned; a live roster refresh is specified but not built, and I would rather say that than ship a network call on the failure path. Throttle holds are silent by design, which means pi's native retry owns the wait and you get no card telling you so. That trade confused me once already. It is documented now.

Pi itself is under active development. The extension API moved under me once (a registerCommand shape change), and I carry a version probe for it. That is the deal: the ground shifts occasionally, and in return you get to build inside the harness instead of around it. I will take that trade for the work I do.

Try it

bash
git clone https://github.com/Arasz/pi-badger-integration
cd pi-badger-integration
bun install
bun run publish
bun run check

If you run pi with ai-badger scaffolds, this set is the other half. If you run pi without them, the subagent and monitor extensions still stand alone. Issues and PRs welcome; the repo is MIT.