Extending Pi Coding Agent with Custom Tools and Widgets
Pi is an agent harness with a full TUI framework built in — Box, Text, Markdown, SelectList, Image rendering, overlays, fuzzy matching, even Kitty image protocol support. It runs tools, renders output, manages sessions. But the interesting part is what happens when you extend it — when you give the agent capabilities that didn’t exist before and it starts using them without being told how.
npx skills add joelhooks/pi-tools --skill pi-tui-design --yes --globalI built an extension that lets the agent fire Inngest events and watch their lifecycle in a persistent widget. Send an event, see which function picks it up, watch steps complete in real time, get the result delivered silently when it’s done. No context-switching to a terminal. No polling by hand.
The whole thing is one TypeScript file.
What you’re building
Two tools and a widget:
inngest_send— fire an Inngest event, resolve it to runs, poll for statusinngest_runs— inspect tracked runs on demand- Widget — persistent status bar showing live run progress
The widget looks like this while a function executes:
◆ video-ingest-pipeline 12s step: download-videoAnd like this when it completes:
✓ check/system-health 0s 2 stepsThen it auto-hides. The model gets the result via a silent message — no conversation spam.
The extension API
A pi extension is a function that receives the ExtensionAPI and registers things:
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Text, Container, Spacer } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
export default function myExtension(pi: ExtensionAPI) {
// Register lifecycle handlers
pi.on("session_start", async (event, ctx) => { ... });
pi.on("session_shutdown", async () => { ... });
// Register tools
pi.registerTool({ name, parameters, execute, renderCall, renderResult });
// Register message renderers
pi.registerMessageRenderer("custom-type", (message, opts, theme) => { ... });
}Three primitives do most of the work: tools the model calls, widgets for persistent visual state, and messages for delivering results.
Widgets: persistent visual state
The widget is what makes this feel alive. Instead of dumping status into the conversation, you put it in a persistent bar above the editor that updates in place.
pi.on("session_start", async (_event, ctx) => {
if (ctx.hasUI) {
ctx.ui.setWidget("inngest-monitor", (tui, theme) => {
widgetTui = tui; // save for later invalidation
return {
render: () => renderWidget(theme), // returns string[]
invalidate: () => {},
dispose: () => { widgetTui = null; },
};
});
}
});The factory function runs once. After that, call widgetTui.requestRender() whenever state changes. The render function returns an array of strings — one per line. Return [] to auto-hide.
function renderWidget(theme: any): string[] {
const visible = getVisibleRuns();
if (visible.length === 0) return []; // auto-hide
return visible.map((run) => {
const elapsed = formatElapsed(run);
if (run.status === "completed") {
return `${theme.fg("success", "✓")} ${theme.fg("text", run.name)} ${theme.fg("dim", elapsed)}`;
}
if (run.status === "failed") {
return `${theme.fg("error", "✗")} ${theme.fg("text", run.name)} ${theme.fg("error", run.error)}`;
}
return `${theme.fg("warning", "◆")} ${theme.fg("text", run.name)} ${theme.fg("muted", "step: " + run.currentStep)}`;
});
}Always use theme tokens. theme.fg("success", ...) adapts to whatever color scheme the user has. Hardcoded ANSI breaks on theme switch. The pi-tui-design skill has the full vocabulary — 51 color tokens, Unicode repertoire, layout primitives.
Silent messages: model gets data, user sees nothing
The key UX pattern: background work updates the widget visually, and when it finishes, the result goes to the model via a hidden message.
pi.sendMessage(
{
customType: "inngest-run-complete",
content: "✓ check/system-health completed in 2s",
display: false, // user doesn't see this in conversation
details: snapshot, // structured data for the model
},
{
triggerTurn: true, // wake the model to act on the result
deliverAs: "followUp",
},
);display: false means the message goes to the model’s context but doesn’t appear in the conversation. triggerTurn: true tells pi to give the model a turn to respond — “hey, that thing you kicked off? Here’s the result.”
Batch turn triggering: if multiple runs are in flight, only trigger the model on the last one. Errors always trigger immediately.
const isError = run.status === "failed";
const otherRunsActive = [...timers.keys()].some(id => id !== run.runId);
const triggerTurn = isError || !otherRunsActive;Tool renderers: compact conversation history
Without renderers, tool calls and results dump raw JSON into the conversation. Renderers give you control over how they appear:
pi.registerTool({
name: "inngest_send",
// ...
renderCall(args, theme) {
return new Text(
`${theme.fg("toolTitle", theme.bold("inngest_send"))} ${theme.fg("muted", args.event)}`,
0, 0
);
},
renderResult(result, _opts, theme) {
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
return new Text(theme.fg("muted", text), 0, 0);
},
});Instead of a JSON blob, the conversation shows:
inngest_send system/health.check {"source": "test"}
→ Sent system/health.check → 1 run (monitoring)The polling loop: CLI-first
The extension shells out to a CLI for all Inngest communication. No raw GraphQL. No API plumbing. The CLI handles authentication, error formatting, and HATEOAS navigation.CLI Design for AI Agents covers the full philosophy — JSON always, HATEOAS next_actions, NDJSON streaming. If your CLI returns structured data, any consumer (agent, extension, script) can use it.
function runCli(args: string[]): Promise<{ stdout: string; code: number }> {
return new Promise((resolve) => {
const proc = spawn("joelclaw", args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
proc.stdout.on("data", (d) => (stdout += d.toString()));
proc.on("close", (code) => resolve({ stdout, code: code ?? 1 }));
});
}Send an event:
const { stdout } = await runCli(["send", eventName, "-d", jsonData]);
const result = parseJson(stdout);
const eventId = result?.response?.ids?.[0];Resolve event → runs:
const { stdout } = await runCli(["event", eventId]);
const data = parseJson(stdout);
const runIds = data?.runs?.map(r => r.id) ?? [];Poll a run:
const { stdout, code } = await runCli(["run", runId]);
if (code !== 0) return; // CLI error — keep polling, don't crash
const data = parseJson(stdout);
// data.run.status: RUNNING | COMPLETED | FAILED | CANCELLED
// data.trace.childrenSpans: step tree with names and statusesThe polling loop runs every 5 seconds via setInterval. When the run reaches a terminal state, it clears the interval, updates the widget, sends the silent message, and sets a linger timer so the result stays visible for 15 seconds before auto-hiding.
Resilience matters. The Inngest server might be restarting. The run might not exist yet — event IDs resolve to runs asynchronously. A CLI error code means “try again later,” not “crash.” The extension retries the event→runs resolution up to 3 times with 1.5s backoff.
Putting it together
The full flow:
- Model calls
inngest_send("system/health.check", '{"source": "test"}') - Extension runs
joelclaw send system/health.check -d '...' - Gets back event ID, resolves to run IDs via
joelclaw event <id> - Starts polling
joelclaw run <id>every 5s - Widget shows
◆ check/system-health 3s step: check-services - Run completes → widget shows
✓ check/system-health 5s 2 steps - Silent message delivered → model gets the result, responds accordingly
- 15 seconds later, widget auto-hides
One extension. One file. The agent gains native access to every Inngest function in the system.
Packaging
Pi discovers extensions in two places:
Pi packages — npm packages with a pi field in package.json:
{
"pi": {
"extensions": ["./inngest-monitor/index.ts"]
}
}Local extensions — .ts files in ~/.pi/agent/extensions/. Drop a directory there and pi loads it on session start. Good for system-specific extensions that don’t belong in a shared package.
I keep the inngest-monitor in the JoelClaw monorepo (packages/pi-extensions/) and symlink it to ~/.pi/agent/extensions/. Single source of truth, no copy drift.
Extensions load at startup. Edit one mid-session, type /reload.
What to build
The extension pattern works for anything with background state:
- Database monitor — run queries, show results in a widget
- Deploy tracker — trigger a Vercel/Railway deploy, poll for status
- Test runner — kick off tests, stream results, flag failures
- CI watcher — poll GitHub Actions, surface failures without leaving the editor
- Queue monitor — show job counts, processing rates, dead letters
The constraint is the same every time: widget for visual state, silent messages for model context, CLI for the plumbing. The pattern scales to anything.
The full source is at packages/pi-extensions/inngest-monitor/index.ts. The pi-tui-design skill (npx skills add joelhooks/pi-tools --skill pi-tui-design) has the complete component API, theme tokens, and design vocabulary.