Skip to content

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.

Schema definitions use TypeBox, the same library pattern you’d recognize if you’ve written JSON Schema by hand. This schema is one field:

.pi/extensions/stash-tools.ts
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, sys
from 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). params arrives already validated against your TypeBox schema - you don’t hand-check that feedId is an integer. signal is the turn’s AbortSignal, worth threading into anything that does I/O, exactly like pi.exec above does. ctx is the same kind of context object hooks receive, if you need it.
  • onUpdate streams 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 no onUpdate calls just looks stuck.
  • Throwing inside execute marks the result isError: true. You don’t need to catch the error and hand-format a failure response - the throw new Error(...) above is the whole error path. An unknown feed ID, a dead URL, a missing venv: the script exits nonzero, pi.exec reports the code, you throw, and the model sees it failed and reasons from there, the same way it would after a failed bash command.
  • details is a second, separate payload from content. content is what the model reads as the tool’s result and can quote back to you in prose; details is structured data attached alongside it - useful for a custom renderResult to 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 (no terminate) 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.