Course · Pi · Your first extension
Build a tool: re-sync a feed
/stash, the command from the last lesson, found the rows - but it stops at finding. Clearing a feed’s share of the backlog in stash (the course’s running example, a small FastAPI feed reader) means re-running its sync: fetch the feed with the fixed parser, then replace the rows the broken window mangled in one transaction under the unique-link constraint. That’s a job worth handing to the model directly - and for that, the model needs a tool of its own, not a command only you can type.
Why a tool and not just “ask it to run a script”
Section titled “Why a tool and not just “ask it to run a script””You could tell the agent “run the sync through bash whenever I ask you to fix a feed,” and it would probably work, most of the time. “Most of the time” is doing real work in that sentence.
Feed URLs are shell-hostile. A URL like https://notes.example.org/atom?feed=rss2&client=stash carries an &, and an agent composing that into a raw shell command has to get the quoting right on every special character in it. Miss the quotes and the shell reads the & as “run this in the background, then start a new command called client=stash” - your re-sync silently doesn’t run, and the agent has no structured way to notice.
A registered tool dodges the whole failure class by never composing a shell command out of the dangerous part: pi.exec passes an argument array with no shell in between, so any schema-validated parameter travels safely. The parameter is a feed ID, a typed integer, because the feeds table is the source of truth - not because integers are the only safe shape.
And it buys three things that instruction-following over bash doesn’t. A typed, validated schema the model fills in instead of guessing shell-quoting right. A description and usage guidance that show up in the model’s own available-tools list, so it reaches for this instead of improvising with a raw curl. A structured return value - separate from anything printed to a terminal - that the model can reason over precisely.
Small, well-scoped tools like this one are also the case Pi’s own docs point to as the alternative to reaching for an MCP server: you don’t need a whole protocol and a running process for something this contained. More on that trade-off in the next lesson.
Registering it
Section titled “Registering it”Schema definitions use TypeBox, the same library pattern you’d recognize if you’ve written JSON Schema by hand. This schema is one field:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";
const STASH_PYTHON = process.env.STASH_PYTHON ?? (process.platform === "win32" ? ".venv/Scripts/python.exe" : ".venv/bin/python");
const RESYNC = `import sqlite3, sysfrom stash.feeds import fetch_feed
feed_id = int(sys.argv[1])backlog = ("published IS NULL OR published IN ('', 'None') " "OR link IS NULL OR link = '' OR summary IS NULL OR summary = ''")with sqlite3.connect("stash.db") as conn: feed = conn.execute("SELECT url FROM feeds WHERE id = ?", (feed_id,)).fetchone()if feed is None: print(f"no feed with id {feed_id}", file=sys.stderr) sys.exit(1)
# Finish the fallible network and parse work before mutating the database.articles = fetch_feed(feed[0])
# Delete and replace as one transaction. Any insert failure rolls the delete back.with sqlite3.connect("stash.db") as conn: cleared = conn.execute( f"DELETE FROM articles WHERE feed_id = ? AND ({backlog})", (feed_id,) ).rowcount for a in articles: conn.execute( "INSERT INTO articles " "(feed_id, title, link, summary, published) VALUES (?, ?, ?, ?, ?) " "ON CONFLICT(link) DO NOTHING", (feed_id, a.title, a.link, a.summary, str(a.published)), ) remaining = conn.execute( f"SELECT COUNT(*) FROM articles WHERE feed_id = ? AND ({backlog})", (feed_id,) ).fetchone()[0]print(f"feed {feed_id}: fetched {len(articles)}, " f"cleared {cleared} backlog rows, {remaining} still on the backlog")`;
export default function (pi: ExtensionAPI) { pi.registerTool({ name: "resync_feed", label: "Re-sync feed", description: "Re-runs stash's fetch-and-parse pipeline for one stored feed and clears that feed's backlog rows (missing published, empty link, blank summary).", promptSnippet: "Use resync_feed to refresh a stored feed whose entries were saved during stash's broken-parse window.", promptGuidelines: [ "Call it with the feed's integer id from the feeds table - never a URL.", "It fetches before mutating, then replaces only that feed's backlog rows in one transaction.", ], parameters: Type.Object({ feedId: Type.Integer({ description: "Row id of the feed in stash's feeds table." }), }),
async execute(toolCallId, params, signal, onUpdate, ctx) { onUpdate?.({ content: [{ type: "text", text: `Fetching feed ${params.feedId}...` }] });
const result = await pi.exec(STASH_PYTHON, ["-c", RESYNC, String(params.feedId)], { signal, timeout: 60_000, });
if (result.code !== 0) { throw new Error(`resync_feed failed: ${result.stderr || "unknown error"}`); }
return { content: [{ type: "text", text: result.stdout.trim() }], details: { feedId: params.feedId }, }; }, });}Notice what the schema doesn’t have: an enum. Nothing about a re-sync is one-of-a-fixed-set, so no enum-shaped field appears - and inventing one (a mode with a single meaningful value, say) would be schema decoration, paid for on every turn forever.
The rule for when you do need one: Pi ships a StringEnum helper, imported from @earendil-works/pi-ai, and you use it instead of TypeBox’s Type.Union/Type.Literal for a specific, documented reason - union types don’t survive Google’s API, and StringEnum is the shape that works across every provider Pi supported as of this writing. The first enum-shaped parameter you add to a stash tool - a status filter, a scope selector - is StringEnum(["a", "b"] as const), and that’s the whole trick.
Two assumptions make that pi.exec work, and this time both are load-bearing. The session still sits at the repo root, where stash.db lives and the stash package resolves.
And the command has to use the project’s venv interpreter, because fetch_feed pulls in httpx, which does not ship with Python. The example selects the conventional .venv/bin/python path on macOS and Linux and .venv/Scripts/python.exe on Windows. If your environment lives elsewhere, set STASH_PYTHON to its interpreter path before launching Pi; the extension uses that value first.
Reload (/reload - Pi re-reads extension files without restarting), and the model can reach for it the moment a re-sync makes sense. The shape of the exchange, illustrative:
> feed 1 still has an undated entry - can you clean it up?
● resync_feed { feedId: 1 } Fetching feed 1...
Re-synced feed 1: fetched 2, cleared 2 backlog rows, 0 still on the backlog. Both articles re-saved with their dates and links intact this time.Then run it twice. The second run says fetched 2, cleared 0 backlog rows, and the table’s row count doesn’t move. That’s not the tool being clever - its insert uses the same ON CONFLICT(link) DO NOTHING path the context chapter’s dedup fix established, so a re-sync of healthy rows inserts nothing. The transaction adds the other half of the guarantee: fetching finishes before any delete, and an insert failure rolls the delete back. When a tool’s second run is a no-op by construction and its replacement is atomic, you can hand it to a model without holding your breath.
The parts of the shape worth understanding
Section titled “The parts of the shape worth understanding”execute’s signature is(toolCallId, params, signal, onUpdate, ctx).paramsarrives already validated against your TypeBox schema - you don’t hand-check thatfeedIdis an integer.signalis the turn’sAbortSignal, worth threading into anything that does I/O, exactly likepi.execabove does.ctxis the same kind of context object hooks receive, if you need it.onUpdatestreams progress while the tool is still running. For anything that takes more than an instant - and a real feed fetch over the network qualifies - this is what turns “the UI sits frozen for ten seconds” into “Fetching feed 1…” appearing as the tool works. It’s optional; nothing breaks if you never call it, but a long tool with noonUpdatecalls just looks stuck.- Throwing inside
executemarks the resultisError: true. You don’t need to catch the error and hand-format a failure response - thethrow new Error(...)above is the whole error path. An unknown feed ID, a dead URL, a missing venv: the script exits nonzero,pi.execreports the code, you throw, and the model sees it failed and reasons from there, the same way it would after a failedbashcommand. detailsis a second, separate payload fromcontent.contentis what the model reads as the tool’s result and can quote back to you in prose;detailsis structured data attached alongside it - useful for a customrenderResultto draw on, or for another part of your extension to read later without re-parsing text. Here it just echoes the feed ID, which is about the smallest honest use of it.- We didn’t set
terminate: true. That flag skips the follow-up LLM turn entirely when every tool call in a batch sets it - the right call for a tool whose result needs no interpretation (a fire-and-forget log write, say). Here you want the model to look at the result and tell you whether the re-sync actually cleared the backlog, so the default (noterminate) is correct.
Before you let the model reach for this against a feed you actually care about, run it against one you don’t - a test feed, or whichever row in feeds matters least. Ask the model to re-sync it and read what comes back, so the first time resync_feed runs for real isn’t also the first time you’ve seen what it does. The transaction protects the old rows from fetch and insert failures; the watched rehearsal checks that the feed and schema behave the way the example assumes.
A typed integer in, a structured result out, and a throw that does all the error handling you need.
You now have both halves of the chore automated - a command you run, a tool the model runs - and they’re both live the moment you save the file and hit /reload. What you haven’t touched yet is the machinery underneath both of them: the lifecycle events that let an extension see, and change, what’s happening on every single turn, not just when a command or tool is explicitly invoked. Next: hook into the loop.