Pi Extension Authoring
Pi extensions live under config/pi/extensions/. Each extension is a pair: a .ts file (the extension itself) and a
sibling .md file (the deep reference doc). Pure logic extracts into lib/node/pi/ so it's unit-testable under
tests/lib/node/pi/. This skill captures the repo-specific conventions on top of pi's public extension API.
When this applies
- Adding a new pi extension (new
.ts+.mdpair). - Extracting logic from an extension into a shared helper.
- Modifying an extension's detection / nudge / guardrail behavior.
- Wiring (or unwiring) an extension in
settings-baseline.json.
Skip this skill for config-only changes to settings-baseline.json that don't touch extension code.
File trio
Every extension is three things:
| File | Purpose |
|---|---|
config/pi/extensions/<name>.ts |
The extension itself. Hooks, tool registrations, minimal glue. |
config/pi/extensions/<name>.md |
Deep reference: detection rules, config shape, env vars, hot-reload notes. |
lib/node/pi/<helper>.ts |
Pure helper(s) the extension calls into. Testable without pi's runtime. |
tests/lib/node/pi/<helper>.spec.ts (added with helper) |
Vitest spec covering every branch of the helper. |
Wire the extension into config/pi/settings-baseline.json under the extensions array. Import paths are relative to
the baseline file.
The layering rule
Keep extensions thin. Every non-trivial behavior belongs in a pure function under lib/node/pi/.
- Extension
.ts: pi hooks (session_start,agent_end, tool registrations), plumbing, env var reads, and calls out to the helper. Aim for under 100 lines. - Helper
lib/node/pi/*.ts: the actual logic. Pure, deterministic, no pi API calls. One exported function per concern. These get vitest specs. - Helper spec
tests/lib/node/pi/*.spec.ts: covers every input/output pair the helper produces. Run withnpm test.
This layering is what lets the test suite run without spinning up pi. If you find yourself mocking pi inside a vitest spec, the logic probably wants to move down into a helper.
Robustness, not tier switches
The robustness machinery in every extension carries the load across model tiers. Do NOT write if (model.isSmall) /
if (tier === 'weak') branches.
- Detection patterns (regex / heuristics) stay the same regardless of which model is running.
- Nudge text stays the same - don't "dumb down" for small models; a clear instruction is a clear instruction.
- When a feature genuinely needs a cheap model (e.g. a critic subagent's grader), use
modelOverrideon the subagent call site, not a tier switch inside the extension. - See the companion memory
research-extensions-robustness-principle.
Tiny-model usage is opt-in and non-load-bearing
If the extension uses a local tiny model (e.g. a small quantized local model) for a subtask:
- Route through
runOneShotAgent(or atiny-helpersubagent) - never embed the model id directly. - Gate via a setting that defaults off. When the tiny model is disabled or unavailable, the extension falls back to deterministic behavior - never errors out.
- The tiny model must never touch user-visible research content or verification verdicts. It's for plumbing (heuristic classification, phrase extraction) only.
- See the companion memory
research-tiny-model-non-load-bearing-rule.
Deep-doc (.md) shape
The .md file is the reference, not a tutorial. Mirror the shape of existing docs like verify-before-claim.md:
- One-sentence purpose - what failure mode the extension addresses.
- Composition table (when multiple extensions listen on the same hook) - how this extension distinguishes its signal from siblings.
- Detection - the exact patterns / walks / heuristics. Link into the helper file.
- Rule / config shape - with a JSONC example when the extension supports per-project overrides.
- Environment variables -
PI_<NAME>_DISABLED,PI_<NAME>_VERBOSE,PI_<NAME>_TRACE=<path>. Standard trio; include what applies. - Hot reload - which files trigger
/reload, which require a session restart.
Update config/pi/extensions/README.md's index table in the same commit.
Smoke-testing against a local small model
After shipping a nudge / detection / guardrail, smoke-test against a local small / weak model to confirm it behaves sensibly when tool-call precision is weak:
pi -p "<scenario prompt>" --model <provider/local-small-model> --no-session
Use whatever local small/weak model your setup runs; omit --model to smoke against the current model instead.
Scenarios to exercise:
- The detection positive case - does the extension fire?
- An explicit negative - confirm the check exempts legitimate work.
- The idempotency path - the sentinel-marker check means the same user message must NOT re-trigger the extension on a retry.
For visual / critic extensions that call a critic subagent, confirm the critic can attach images (read <png>
auto-attaches on recognized extensions - png, jpg, gif, webp). Render SVGs to PNG with whatever rasterizer your
machine has (rsvg-convert or magick).
Record your own setup's specifics - the exact local model id, any proxy caveat, the installed SVG rasterizer - in a personal note or memory rather than here, so this doc stays portable for anyone cloning the repo.
Multi-turn headless
A single pi -p is one turn. To drive a real multi-turn conversation headless (e.g. a guardrail that should fire on
turn 2, or summary / affinity state that accrues across turns) there are two ways:
RPC mode - preferred when an agent is driving the test. One long-lived pi --mode rpc process takes JSONL prompt
commands on stdin and streams events back as JSON lines on stdout. From inside pi, start it as a bg_bash job with
interactiveStdin: true, write one command per turn, and watch for the agent_end event that marks each turn done:
pi --mode rpc --model <provider/local-small-model> --no-session
# write to stdin, one JSON object per line:
{"id":"t1","type":"prompt","message":"turn 1"}
{"id":"t2","type":"prompt","message":"turn 2"} # same process, remembers turn 1
Context lives in the process for its lifetime, so --no-session is fine. It uses stdin / stdout pipes (no unix socket),
so the sandbox caveat below does not apply. Wait for agent_end before sending the next prompt; to enqueue while the
agent is still streaming, add "streamingBehavior":"steer" (delivered after the current tool calls) or "followUp"
(delivered when the agent stops). Shut down with SIGTERM. Full protocol: docs/rpc.md in the pi clone.
Session reuse - good for shell scripts / CI. Respawn pi -p against a persisted session:
DIR=$(mktemp -d)
pi --session-dir "$DIR" --session-id smoke -p "turn 1" --model <provider/local-small-model>
pi --session-dir "$DIR" --session-id smoke -p "turn 2" # resumes the same session, sees turn 1
rm -rf "$DIR"
--session-id <id> opens the session when one with that exact id already exists in the project, otherwise creates it;
it cannot be combined with --no-session. A throwaway --session-dir keeps the test isolated and easy to clean up.
(-c / --continue also continues a conversation, but it picks the most-recent session non-deterministically - prefer
--session-id for scripted tests.)
Driving the TUI with tmux
Interactive surfaces (statusline, header / footer widgets, avatar, keybindings) never render under -p. Script the TUI
in a detached tmux pane and assert on the captured screen:
SOCK=./.pi-tui-sock # socket path under cwd or /tmp
tmux -S "$SOCK" new-session -d -x 120 -y 40 -s t "pi --model <provider/local-small-model>"
sleep 8 # let the TUI boot
tmux -S "$SOCK" capture-pane -p -t t # read the rendered screen
tmux -S "$SOCK" send-keys -t t "your prompt" Enter # type + submit
sleep <model-latency>
tmux -S "$SOCK" capture-pane -p -t t # assert on the response / widget
tmux -S "$SOCK" send-keys -t t C-p # exercise keybindings, slash commands, etc.
tmux -S "$SOCK" kill-server # clean up
Sandbox caveat (Linux): the sandbox extension's seccomp filter blocks tmux's unix socket, and the unixSockets.allow
path list is ignored on Linux (seccomp can't match by path). When you are about to run a tmux TUI test, ask the user to
disable the sandbox for the session (/sandbox-disable, or relaunch with PI_SANDBOX_DISABLED=1) - do not enable
unixSockets.allowAll, which would relax socket isolation for the whole session.
Wiring into settings-baseline.json
Add an entry under extensions:
{
"extensions": [
// …existing…
"./extensions/<name>.ts",
],
}
settings-baseline.json mirrors ~/.pi/agent/settings.json; keep runtime-only keys out of the baseline (e.g.
lastChangelogVersion). Run /reload in a live pi session to pick up extension changes without restarting.
Anti-patterns
- Logic in the
.tsextension file. Tests can't reach it without spinning up pi. Extract tolib/node/pi/. - Tier-specific branches. Small-model vs big-model code paths. Use robust detection + gentle nudges instead.
- Tiny model on the hot path. If the extension errors when the local model is unreachable, it's load-bearing - refactor to a deterministic fallback.
- Skipping the deep
.mddoc. The.mdis the contract for future readers. The extension index inextensions/README.mdlinks to it. - No
PI_<NAME>_DISABLEDescape hatch. Every extension should be silenceable by env var. - Re-triggering on the extension's own nudge. Use a sentinel marker (e.g.
⚠ [pi-<name>]) on the injected message and short-circuit when it's present on the most recent user message. - Leaving the test gap - no
lib/node/pi/*.spec.tsfor the helper. Small regressions in detection patterns destroy the guardrail's value; tests catch those.
Checklist before finishing
.tsextension underconfig/pi/extensions/- thin, no business logic..mddeep doc with detection + config + env vars + hot-reload sections.- Pure helpers extracted to
lib/node/pi/. - Vitest spec under
tests/lib/node/pi/.npm testpasses. - Entry added to
extensionsarray insettings-baseline.json. - Row added to
config/pi/extensions/README.mdindex table. PI_<NAME>_DISABLEDenv var supported; smoke-tested against a local small model../dev/lint-shell.shpasses for any shell files touched.- If a companion skill teaches WHEN to use this tool, add it under
config/pi/skills/<name>/SKILL.mdand cross-link from the.mdandREADME-skills.md.