Pi
Pi extension
Pi's registerTool with the prompt snippet and guidelines filled in.
- label
- Text Slug
- prompt
- snippet, 1 rule
- parameters
- text, separator
- failure
- thrown
Access
- Import
import { registerPiTools } from "@agntn/tools/pi" - Peer
@earendil-works/pi-coding-agent >=0.84.2 <2 - Fails
thrown
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { registerPiTools } from "@agntn/tools/pi";
import { tools } from "../../../src/tools.ts";
export default function (pi: ExtensionAPI) {
registerPiTools(pi, tools);
}
Pi gets name, label from title, the description, promptSnippet and promptGuidelines when you set them, and the schema as parameters. Same JSON Schema every other host gets. Pi's own validator checks it, then the core checks it again, because a second check costs nothing and a missing one costs an afternoon.
Failures
Up to 0.98, Pi reads a returned object as a success no matter what's in it. A tool that returns isError: true would look fine to the model. So by default the adapter throws the result's text when isError is set, and Pi records an error.
On Pi 0.99 or newer you can keep the result as it is:
registerPiTools(pi, tools, { failures: "return" });
That keeps details on a failure too. Only do it with a peer range of >=0.99.0, since Pi doesn't check peer ranges when it installs a package.
Asking first
Some calls shouldn't run just because the model asked. Opening a pull request, say. Or overwriting release notes?
Give the adapter a confirm builder per tool, and Pi asks the user before the call runs:
registerPiTools(pi, tools, {
confirm: {
notes_publish: (input) => ({
title: "Publish note?",
message: `Title ${String(input.title)}`,
}),
},
});
The arguments pass validation first, so the builder never sees junk. Then the dialog opens through ctx.ui.confirm with the call's abort signal. A yes runs the tool. A no throws, and the model reads that it shouldn't retry unless the user asks again.
No UI, no write. In pi -p there's nobody to ask, so the call throws instead of running unasked. Return undefined from the builder to skip the question for one call, a dry run for example.
The title goes through sanitizeLine. The message keeps its lines and tabs, but escape sequences and bidi tricks don't make it to the dialog. A model can't paint its own yes button. A name in confirm that isn't in the tool list throws at registration. A typo there would drop the check without a sound, the worst kind of quiet.
OMP doesn't need any of this. Its approval: "write" already stands in front of every write.
Call lines
Left alone, Pi draws a call as the tool name and a key=value dump of every argument. Fine for read. For a tool whose one interesting argument is a long URL? Mostly noise.
Hand the adapter a describeCall and Pi's Text, and the line becomes the title plus your summary, styled like Pi's own tools:
import { Text } from "@earendil-works/pi-tui";
registerPiTools(pi, tools, {
Text,
renderers: { text_slug: { describeCall: (args) => args.text } },
});
Pi resolves @earendil-works/pi-tui for every extension it loads, so the import works at runtime with nothing installed. The summary goes through sanitizeLine first. A newline in a model's argument can't forge a line. Forget Text and registration throws, which beats a summary that never shows up.
It's the same describeCall OMP takes. One function, two hosts.
Renderers
Pass Pi's own renderCall and renderResult per tool through renderers. They reach Pi unchanged, and a renderCall beats describeCall for the same tool:
registerPiTools(pi, tools, {
renderers: { books_text: { renderResult: preview } },
});
Handy when a tool returns a lot of text and you want the collapsed row to stay short.
Progress
A progress line reaches Pi through onUpdate. It's a partial result: one text block, details: {}. Pi shows it while the call runs. Your own renderResult gets it too, with isPartial set. Reading details in there? Check that flag first.