# Pi Extension Authoring

> WHAT: Conventions for adding or modifying a pi extension under `config/pi/extensions/` - the .ts + .md pair and where pure helpers / unit tests live. WHEN: the user asks to add a new pi extension, extract extension logic into a shared helper, or modify an existing extension's behavior. DO-NOT: put business logic in the .ts file (extract to `lib/node/pi/`); skip the companion .md doc; forget the `extensions/README.md` row; or make behavior tier-specific (small-model vs big-model branches).

- Skill: `weikinhuang/pi-extension-authoring` (Agent Skill)
- Install (CLI): `npx skillmds@latest add weikinhuang/pi-extension-authoring`
- Raw SKILL.md: https://api.skillmd.com/api/skills/weikinhuang/pi-extension-authoring/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: weikinhuang (https://skillmd.com/u/weikinhuang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/weikinhuang/pi-extension-authoring

---


# 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` + `.md` pair).
- 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 with
  `npm 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 `modelOverride` on 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 a `tiny-helper` subagent) - 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`:

1. **One-sentence purpose** - what failure mode the extension addresses.
2. **Composition table** (when multiple extensions listen on the same hook) - how this extension distinguishes its
   signal from siblings.
3. **Detection** - the exact patterns / walks / heuristics. Link into the helper file.
4. **Rule / config shape** - with a JSONC example when the extension supports per-project overrides.
5. **Environment variables** - `PI_<NAME>_DISABLED`, `PI_<NAME>_VERBOSE`, `PI_<NAME>_TRACE=<path>`. Standard trio;
   include what applies.
6. **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:

```bash
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:

```text
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:

```bash
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:

```bash
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`:

```jsonc
{
  "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 `.ts` extension file.** Tests can't reach it without spinning up pi. Extract to `lib/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 `.md` doc.** The `.md` is the contract for future readers. The extension index in
  `extensions/README.md` links to it.
- **No `PI_<NAME>_DISABLED` escape 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.ts` for the helper.** Small regressions in detection patterns destroy
  the guardrail's value; tests catch those.

## Checklist before finishing

1. `.ts` extension under `config/pi/extensions/` - thin, no business logic.
2. `.md` deep doc with detection + config + env vars + hot-reload sections.
3. Pure helpers extracted to `lib/node/pi/`.
4. Vitest spec under `tests/lib/node/pi/`. `npm test` passes.
5. Entry added to `extensions` array in `settings-baseline.json`.
6. Row added to `config/pi/extensions/README.md` index table.
7. `PI_<NAME>_DISABLED` env var supported; smoke-tested against a local small model.
8. `./dev/lint-shell.sh` passes for any shell files touched.
9. If a companion skill teaches WHEN to use this tool, add it under `config/pi/skills/<name>/SKILL.md` and cross-link
   from the `.md` and `README-skills.md`.

