Skip to content

Course · Pi · Your first extension

Build a command: /stash

You already know this chore’s manual shape: open a terminal, write a one-off SELECT against stash.db (the database of stash, the course’s running example - the small FastAPI feed reader this module’s opener describes, with its backlog of rows saved while date parsing was broken), squint at which rows still carry a missing published or a blank summary. The backlog shrinks every time you fix a feed and grows again whenever a bad one slips through.

Finding which entries still need work is a job for a command you trigger yourself, on demand - not something you want the model deciding to run on your behalf mid-conversation. Slash commands are Pi’s answer to “user-initiated, not model-initiated,” and pi.registerCommand is how you add one.

One frame before the code: an extension is a TypeScript file under .pi/extensions/ (or your global extensions folder) whose default-exported function receives Pi’s ExtensionAPI when Pi loads it - the same mechanism that carries tools and hooks, introduced properly in the anatomy lesson. That exported function is the factory; pi.registerCommand inside it is what adds a slash command. With that frame:

Inside your extension’s factory function:

.pi/extensions/stash-tools.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const FILTERS = ["undated", "unlinked", "blank"];
const QUERY = `
import sqlite3, sys
where = {
"undated": "published IS NULL OR published IN ('', 'None')",
"unlinked": "link IS NULL OR link = ''",
"blank": "summary IS NULL OR summary = ''",
}[sys.argv[1]]
rows = sqlite3.connect("stash.db").execute(
"SELECT id, feed_id, title, link FROM articles WHERE " + where
).fetchall()
for r in rows:
print(f"# {r[0]} feed {r[1]} {r[2]} {r[3] or '(no link)'}")
`;
export default function (pi: ExtensionAPI) {
pi.registerCommand("stash", {
description: "Show stash entries that still need attention",
getArgumentCompletions: (prefix) =>
[
{ value: "undated", label: "undated - published missing or stored as 'None'" },
{ value: "unlinked", label: "unlinked - link empty" },
{ value: "blank", label: "blank - summary empty" },
].filter((c) => c.value.startsWith(prefix)),
handler: async (args, ctx) => {
const filter = args.trim() || "undated";
if (!FILTERS.includes(filter)) {
ctx.ui.notify(`Unknown filter '${filter}' - try undated, unlinked, or blank.`, "error");
return;
}
const result = await pi.exec("python3", ["-c", QUERY, filter], {
signal: ctx.signal,
timeout: 10_000,
});
if (result.code !== 0) {
ctx.ui.notify(`query failed: ${result.stderr}`, "error");
return;
}
const rows = result.stdout.trim().split("\n").filter(Boolean);
ctx.ui.notify(
rows.length
? `${rows.length} ${filter} entries:\n${rows.join("\n")}`
: `No ${filter} entries - you're clean.`,
"info"
);
},
});
}

Two assumptions make that pi.exec work, and both come from how the repo already runs. Pi’s session sits at the repo root, which is where stash.db lives - stash/db.py opens it by the bare relative path Path("stash.db"), so the working directory has to be right or the query hits a fresh empty database somewhere else.

And /stash needs only the standard library: sqlite3 ships with Python, so the python3 executable established in the course prerequisites is enough. The project’s venv doesn’t have to be active for this one - remember that, because the next lesson’s tool is pickier.

One clause in the query earns a second look. undated matches both NULL and the literal string 'None', and that’s not paranoia - db.py’s save_article stores str(article.published), and str(None) is 'None'. The rows saved while date parsing was still broken carry the string, not a null, so the filter has to catch both. That’s the kind of wart you only find by reading the writer, and it’s why the query lives in a file you can edit rather than a tool you were handed.

Save that, run /reload, and /stash is live. Before you type it, predict what you’ll see: the backlog was a few hundred rows at its worst and you’ve been working it down all week, so the undated list should come back a handful of rows long - not zero, not fifty. The shape of the reply, illustrative:

> /stash undated
2 undated entries:
# 1 feed 1 Hello https://example.com/hello
# 3 feed 2 Note https://notes.example.org/n1

Two rows, exact entry IDs, feed IDs attached - the two things the fix needs. Hold onto feed 1; you’ll re-sync it in the next lesson.

Type /stash and pause before hitting enter, and the completions you registered show up as suggestions - undated, unlinked, blank - so you don’t have to remember the exact filter strings you invented.

One of the three filters will look broken the first time you run it, and it’s worth knowing why before you distrust the other two. /stash unlinked comes back with at most one row, ever (the writer stores link as a plain string, so empty means '' - which the unique index caps at one; a NULL would evade it, and this repo never writes one) - not a bug in the query.

The dedup fix from the context chapter put a unique index on articles.link, and the migration that made the index possible collapsed every duplicate link into its first row, empty links included. The schema itself already swept most of the unlinked class of the backlog. The filter stays because an empty link can still land - the index just makes it impossible for a second one to pile up behind it - and because the three completions read as the backlog’s definition.

A few things about the signature are worth being precise about, because they’re easy to get wrong by analogy with the tool you’ll build next lesson:

  • name is passed without the leading slash. You call pi.registerCommand("stash", ...); the user types /stash.
  • args is the raw remainder of the line, as one string - everything after /stash , unsplit. /stash undated hands your handler "undated"; there’s no automatic positional-argument parsing the way some other tools’ templating syntax works. If a command needs several arguments, splitting args yourself is on you.
  • The handler gets ExtensionCommandContext, not the plain ExtensionContext your hooks will see - a superset with session-control methods layered on (ctx.newSession, ctx.fork, ctx.switchSession, ctx.navigateTree, ctx.reload), which is the machinery behind Pi’s fork/tree/resume commands and, later in this course, subagent extensions. The docs note these live only on the command context because calling them from an event handler can deadlock the session. You won’t need any of that for /stash, but it’s there when you do.
  • pi.exec, not something on ctx - a top-level convenience for shelling out: pass a command, an argument array, and optionally signal and timeout, and it returns { stdout, stderr, code, killed } with the turn’s abort signal already wired through (ctx.signal above). Everything /stash does with the database goes through it.

A raw string in, a notification out - a command’s contract is the simplest one this chapter builds.

Where /stash sits relative to everything else typed at the prompt

Section titled “Where /stash sits relative to everything else typed at the prompt”

A couple of ordering facts save you real confusion later:

  • Extension commands come first in Pi’s command ordering. pi.getCommands() lists extensions first, then prompt templates, then skills - so if you later build a stash skill (the skills chapter) with the same name, it doesn’t shadow the command you registered.
  • Name collisions between extensions auto-suffix instead of erroring. If two loaded extensions both register a stash command, Pi doesn’t crash or silently drop one - you get /stash:1 and /stash:2, in load order. Worth knowing if you ever load someone else’s extension pack alongside your own.
  • Built-in commands are not the same mechanism with better names. /settings, /model, /tree, /fork, /resume, /reload are a separate, privileged tier: they never appear in pi.getCommands(), and they’re handled only in interactive mode - a prompt sent over RPC can’t invoke them. The suffixing above is extension-versus-extension only; Pi’s own commands don’t compete in that list at all, and nothing you register displaces one.

/stash solves half the chore: finding what’s left. But it only ever runs when you type it - the model can’t reach for it mid-conversation, and it can’t act on what it finds without you copying a feed ID back into the chat. Clearing a feed’s backlog rows is exactly the kind of thing you’d rather hand to the agent directly. Next: give the model its own tool to do the re-sync.