ai/pi-coding-agent.md

Pi Coding Agent

Design philosophy: no built-in MCP

Pi doesn't have built-in MCP support โ€” this is an intentional design choice. From the docs:

No MCP. Build CLI tools with READMEs (see Skills), or build an extension that adds MCP support. Why? https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/

Two paths instead:


Option 1: Use Skills (the pi-native approach)

Skills are markdown-based capability packages that follow the Agent Skills standard. They're simpler than MCP โ€” a skill is just a folder with a SKILL.md that tells the LLM how to use a CLI tool.

# My Skill
Use this skill when the user asks about X.
## Steps
1. Do this
2. Then that

Place skills in:

  • ~/.pi/agent/skills/ (global)
  • .pi/skills/ (project-local)

Then invoke with /skill:name or let the agent auto-detect them.


Option 2: Build an MCP extension

Since pi supports TypeScript extensions, you can build one that speaks the MCP protocol. An MCP extension would typically:

  1. Register tools via pi.registerTool() for each MCP server tool
  2. Use @modelcontextprotocol/sdk (npm package) to connect to MCP servers via stdio or SSE
  3. Proxy tool calls from the LLM to the appropriate MCP server

The pattern is similar to how the ssh.ts example delegates tools to a remote machine, or how the subagent example delegates tasks. Here's the basic shape:

// ~/.pi/agent/extensions/mcp.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default async function (pi: ExtensionAPI) {
  // Connect to MCP servers on startup
  // Register their tools with pi
  // Proxy tool calls through to the MCP server

  pi.on("session_start", async (_event, ctx) => {
    // Initialize MCP client connections
    ctx.ui.notify("MCP servers connected", "info");
  });
}

There's currently no official MCP extension in pi's examples or packages, but you can ask pi itself to help you build one.


Recommendation

If your use case is fairly straightforward (e.g., the MCP server wraps a CLI tool), use Skills โ€” they're simpler, first-class in pi, and avoid the complexity of the MCP protocol. If you need the full MCP protocol (like connecting to a remote resource server), building an extension is the way to go.


Pi Web UI + MCP Adapter Setup

A web-based Pi with full MCP server support, proxied through nginx.

Architecture

Browser โ”€โ”€HTTPSโ”€โ”€> pi.dph.am โ”€โ”€nginxโ”€โ”€> pi-webui (port 49998) โ”€โ”€> Pi CLI (via /resume)
                                                    โ”‚
                                            pi-mcp-adapter
                                                    โ”‚
                                            ~/.config/mcp/mcp.json
                                                    โ”‚
                                              (symlink to ~/.mcp.json)

Components

Component Repo Purpose
pi-webui github.com/khimaros/pi-webui Web-based UI for Pi โ€” browser access instead of terminal
pi-mcp-adapter github.com/nicobailon/pi-mcp-adapter MCP server adapter so Pi can use all configured MCP servers

Installation & Config

npm install -g @khimaros/pi-webui

Symlink trick โ€” Pi-mcp-adapter reads MCP config from ~/.config/mcp/mcp.json. Link it to the global Claude MCP config:

ln -s ~/.mcp.json ~/.config/mcp/mcp.json

This way every MCP server configured for Claude Code (Asana, Granola, notes, LSP, etc.) is also available in the Pi web UI.

Environment & Startup

export PI_WEBUI_HOST=0.0.0.0
export PI_WEBUI_PORT=49998
pi-webui

Note: pi --webui-listen 0.0.0.0:49998 currently doesn't work โ€” always use the pi-webui CLI directly.

Session Navigation: /resume trick

Pi's pi-webui start directory determines where new sessions are rooted. To switch between projects without restarting the web UI:

  1. In Terminal (direct Pi CLI): start Pi sessions in different project directories:

    # Terminal 1: Qwestly
    cd ~/Work/qwestly-workspace && pi
    
    # Terminal 2: Personal
    cd ~/Work/notes && pi
    
    # etc.
    
  2. In pi-webui: use /resume to see and hop between existing sessions. Once resumed into a session that lives in a different directory, use /new to start a fresh Pi session in that project's context.

This effectively gives you "cd between folders" โ€” each session retains its original working directory, and /resume + /new lets you spawn new sessions within any of those directories.

Nginx Proxy

  • Domain: https://pi.dph.am
  • Backend: localhost:49998
  • Auth service: lives in ~/Work/services/nginx-auth-service
    • Provides an authentication layer in front of the Pi web UI
    • Keeps access restricted

The proxy config and auth setup live in the nginx-auth-service project.


Plugins

pi-anthropic-auth

github.com/gotgenes/pi-anthropic-auth โ€” a Pi plugin for Anthropic OAuth/subscription-based auth (e.g. using a Claude Pro/Max subscription instead of raw API key billing) plus related config options.

Config option โ€” warnings.anthropicExtraUsage:

{
  "warnings": {
    "anthropicExtraUsage": false
  }
}

Controls whether the plugin warns when usage would fall outside/exceed the subscription plan's included usage (i.e. spill over into extra, pay-as-you-go billing on the Anthropic account). Setting anthropicExtraUsage: false suppresses that warning.

pi-subagents

github.com/tintinweb/pi-subagents โ€” brings Claude Code-style autonomous sub-agents and workflow orchestration to Pi. Spawn specialized agents in isolated sessions (own tools, system prompt, model, thinking level), run them in background or blocking, steer mid-run, resume, and define custom agent types.

Install:

pi install npm:@tintinweb/pi-subagents

Requires pi 0.84.0+.

Key features:

  • Adds the same tool surface Claude Code uses: Agent, get_subagent_result, steer_subagent, plus a SubagentWorkflow tool for deterministic JS orchestration (agent(), parallel(), pipeline(), phase(), log()).
  • Background by default โ€” agents run detached, return an ID immediately, and notify on completion; run_in_background: false blocks for the full result inline.
  • Live widget + FleetView โ€” persistent above-editor widget showing active agents; a Claude Code-style navigable list below the editor (โ†“/โ† at empty prompt to jump in, Enter to open an agent's live conversation, x to stop).
  • Custom agent types โ€” define in .pi/agents/<name>.md (project) or globally, with YAML frontmatter for system prompt, model, thinking level, tool restrictions.
  • Agent mentions โ€” @explore do X addresses a subagent directly (message it, resume it, or start it) without it entering the main chat.
  • Scripted workflows โ€” SubagentWorkflow scripts are Claude Code Workflow-tool compatible (same globals/semantics), runs in a sandboxed worker thread.
  • Also supports: mid-run steering, session resume, graceful turn-limit wrap-up, fuzzy model selection ("haiku", "sonnet"), context inheritance, git worktree isolation, skill preloading, scheduled subagents (cron/interval/one-shot), tool denylists, and a cross-extension RPC event bus.

Essentially turns Pi's agent tooling into the same subagent/orchestration model this very session (Claude Code) uses โ€” Agent for one-off delegated tasks, SubagentWorkflow for deterministic multi-stage fan-out/pipeline work.

pi-web-access

github.com/nicobailon/pi-web-access โ€” web search, content extraction, and video understanding extension for Pi.

Install:

pi install npm:pi-web-access

Works with zero config (Exa MCP provides free search out of the box; Codex-subscription auth can also power OpenAI search). Optional API keys for more providers go in ~/.pi/agent/web-search.json (openaiApiKey, braveApiKey, exaApiKey, tavilyApiKey, geminiApiKey, etc.). Requires Pi v0.37.3+.

Tools it adds:

  • web_search โ€” search via Exa, OpenAI, Brave, Tavily, Firecrawl, Kagi, Perplexity, Gemini, Kimi, self-hosted SearXNG, keyless DuckDuckGo, and many more, with automatic provider fallback chains. Supports batched queries, recency/domain filters, and an optional "curator" summary workflow.
  • fetch_content โ€” fetch a URL as readable markdown, raw HTTP body, or a grounded answer. Auto-detects and specially handles GitHub repos/PRs/issues (clones locally instead of scraping), YouTube videos, PDFs, local video files, and images.
  • get_search_content โ€” retrieve cached content from a previous search/fetch (1-hour cache, 128 entries / 128 MiB cap), with text-finding (findText, exact/case-insensitive/fuzzy) or offset/limit paging.
  • source_check โ€” fact-check a claim and return a machine-readable artifact with exact passage citations, deduplicated/capped sources.

Notable capabilities: GitHub URLs are cloned locally rather than scraped (agent gets real files + a local path); video/screen-recording understanding with transcript, visual description, and frame extraction at timestamps (needs ffmpeg/yt-dlp for frame extraction, optional otherwise); smart per-capability fallback chains (search, YouTube, page fetch each have their own provider order).


Extensions

Extensions are plain TypeScript files (no build step โ€” loaded via jiti) that hook into pi's lifecycle. A minimal one is a default-exported factory:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Extension loaded!", "info");
  });
}

Where they live (auto-discovered, hot-reloadable with /reload):

Location Scope
~/.pi/agent/extensions/*.ts Global (all projects)
.pi/extensions/*.ts Project-local (loads only after the project is trusted)

Either can be a single file or a dir/index.ts (plus helper modules, plus a package.json if it needs npm deps โ€” npm install in the extension dir and imports from node_modules/ resolve automatically).

Security note: extensions run with pi's full process permissions โ€” same trust level as running arbitrary local code. Only use ones you trust; pi has no built-in sandbox (see docs/security.md).

The four things extensions can do

  1. Intercept events โ€” the big one. Lifecycle events include session_start, before_agent_start (inject messages / rewrite system prompt per turn), tool_call (block or mutate tool args before they run โ€” returns { block: true, reason } to stop a call), tool_result (rewrite results after execution), context (edit the message list sent to the LLM), before_provider_request/after_provider_response (raw payload/header access), session events (session_before_compact, session_before_fork, session_before_switch), and model events. tool_call and input are the ones most useful for guardrails since they can outright block or transform.
  2. Register custom tools โ€” pi.registerTool() adds a new tool the LLM can call, with its own schema (via typebox), streaming updates (onUpdate), custom rendering, and optional promptSnippet/promptGuidelines so the model knows when to use it. Can even override a built-in tool by reusing its name.
  3. Custom UI โ€” ctx.ui.select/confirm/input/editor for dialogs, notify for toasts, setStatus/setWidget/setFooter for persistent footer/widget UI, ui.custom() for full custom TUI components.
  4. Commands & shortcuts โ€” pi.registerCommand("name", {...}) for /name, pi.registerShortcut("ctrl+x", {...}) for keybindings, pi.registerFlag() for new CLI flags.

Other useful patterns from the examples

  • tool_call can mutate, not just block โ€” e.g. prepend source ~/.profile to every bash command before it runs (bash-spawn-hook.ts).
  • Persistent extension state: pi.appendEntry() persists data in the session file without polluting LLM context; reconstruct it in session_start.
  • Custom providers: pi.registerProvider() points at a local proxy, self-hosted model, or adds OAuth login flows.
  • Sandboxed/remote execution: the user_bash event plus the sandbox/, gondolin/, and ssh.ts examples route shell execution into a micro-VM or remote host instead of local โ€” actual isolation, not just a guardrail check.
  • Subagents from inside pi itself: the subagent/ example shows a tool that spawns nested agent runs.
  • File-mutation safety: a custom tool that writes files should wrap the write in withFileMutationQueue() so it doesn't race with the built-in edit/write tools under parallel tool-call execution.

Full reference: docs/extensions.md in the pi-coding-agent package, with a working example for nearly every pattern above under examples/extensions/.

Example: scope-guard.ts โ€” block filesystem-wide scans (not project-scoped, just broad-root-scoped)

Problem: an agent working in a big monorepo can be told in AGENTS.md/AGENTS-LOCAL.md not to run find / or similar broad scans, but that's only a prompt-level hint โ€” it can be ignored, forgotten after compaction, or bypassed by a subagent that never saw the instruction. A tool_call hook enforces it at the runtime level instead, regardless of what the model was told.

First version restricted the agent to an allow-list of project roots. Revised to a looser rule instead: the agent is free to search anywhere โ€” another repo, ~/Downloads, wherever โ€” the only thing blocked is a small set of roots that are almost never a legitimate search target and are exactly the ones that lock things up: /, a bare home directory, /Users itself, /System, /Library, /proc, /sys, /dev, /Volumes, etc.

Placed at ~/.pi/agent/extensions/scope-guard.ts (global, applies to every project). Note: the code block below is the original block-only version (v2); the v3 section further down shows what changed to add mdfind auto-substitution, and the file on disk reflects v3.

  • Intercepts bash/powershell tool calls and scans the command text for find/grep/ag/ls/du/tree plus any absolute or ~-relative path argument (fd/rg dropped from this list in v3, see below).
  • Blocks only if that path (or the shell's cwd, when no path argument is given) matches the broad-root list above.
  • Also checks the built-in find/grep/ls tools' path input the same way.
  • PI_SCOPE_EXTRA_DENY="/a:/b" (colon-separated) adds extra path prefixes to the broad list, e.g. a large mounted volume that shouldn't be scanned.
  • The block message points the model at faster alternatives (see below) instead of just saying no.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import path from "node:path";
import os from "node:os";

export default function (pi: ExtensionAPI) {
  const homeDir = os.homedir();

  const BROAD_PATTERNS: RegExp[] = [
    /^\/$/,                                          // filesystem root
    /^~\/?$/,                                         // bare home, unexpanded
    new RegExp(`^${escapeRegex(homeDir)}/?$`),        // bare home, expanded
    /^\/Users\/?$/,                                   // all users
    /^\/Users\/[^/]+\/?$/,                            // someone else's whole home dir
    /^\/home\/?$/,
    /^\/home\/[^/]+\/?$/,
    /^\/(etc|var|usr|opt|System|Library|Applications|proc|sys|dev|private|Volumes)(\/|$)/,
  ];

  const extraDeny = (process.env.PI_SCOPE_EXTRA_DENY?.split(":").filter(Boolean) ?? []).map((p) =>
    path.resolve(p.replace(/^~(?=$|\/)/, homeDir)),
  );

  function escapeRegex(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }

  function resolveAgainst(base: string, p: string) {
    const expanded = p.replace(/^~(?=$|\/)/, homeDir);
    return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(base, expanded);
  }

  function isBroad(rawPath: string, absPath: string) {
    if (BROAD_PATTERNS.some((re) => re.test(rawPath) || re.test(absPath))) return true;
    return extraDeny.some((d) => absPath === d || absPath.startsWith(d + path.sep));
  }

  const SUGGESTION =
    "For a whole-disk or unknown-location search, use `mdfind` (Spotlight index, no filesystem walk โ€” " +
    "e.g. `mdfind -name 'pattern'` or `mdfind -onlyin /some/dir 'pattern'`). For a project-scoped recursive " +
    "search, use `fd`/`rg` (parallel, .gitignore-aware) or the built-in find/grep tools, scoped to a subdirectory.";

  function extractCandidatePaths(command: string): string[] {
    const matches = command.match(/(?:^|[\s(])(~\/[^\s;|&"']*|~|\/[^\s;|&"']*)/g) ?? [];
    return matches.map((m) => m.trim().replace(/^[(]/, ""));
  }

  function checkShellCommand(command: string, cwd: string | undefined) {
    const scanners = /\b(find|grep|rg|ag|ls|du|tree|fd)\b/;
    if (!scanners.test(command)) return undefined;

    const effectiveCwd = cwd ? resolveAgainst(process.cwd(), cwd) : process.cwd();
    for (const raw of extractCandidatePaths(command)) {
      const abs = resolveAgainst(effectiveCwd, raw);
      if (isBroad(raw, abs)) {
        return { block: true as const, reason: `Command targets "${raw}" (resolved: ${abs}), which is a filesystem-wide/broad root and likely to lock up. ${SUGGESTION}` };
      }
    }
    if (isBroad(effectiveCwd, effectiveCwd)) {
      return { block: true as const, reason: `Shell cwd "${effectiveCwd}" is a filesystem-wide/broad root and likely to lock up. ${SUGGESTION}` };
    }
    return undefined;
  }

  pi.on("tool_call", async (event) => {
    if (event.toolName === "bash" || event.toolName === "powershell") {
      const input = event.input as { command?: string; cwd?: string };
      const result = checkShellCommand(input.command ?? "", input.cwd);
      if (result) return result;
      return undefined;
    }

    if (event.toolName === "find" || event.toolName === "grep" || event.toolName === "ls") {
      const input = event.input as { path?: string };
      if (input.path) {
        const abs = resolveAgainst(process.cwd(), input.path);
        if (isBroad(input.path, abs)) {
          return { block: true, reason: `Path "${input.path}" (resolved: ${abs}) is a filesystem-wide/broad root and likely to lock up. ${SUGGESTION}` };
        }
      }
    }
    return undefined;
  });
}

Verified (v2, block-only):

  • find / -maxdepth 2 -name '*.txt' โ€” still blocked, with the reason surfaced back to the model, which explained the block and offered mdfind/fd instead of executing the broad scan.
  • find /Users/dominick/Work/personal-notes -maxdepth 2 -name '*.md' | wc -l โ€” now runs fine (a different repo entirely, outside the workspace pi started in) โ€” confirming the guard no longer requires staying inside one project.

Since it's in the global auto-discovered directory, it's active for every session going forward; edits take effect in a running session via /reload.

v3: auto-substitute mdfind instead of just blocking

Blocking is a dead end for the model โ€” it has to notice the failure, remember the suggestion, and manually retype an mdfind command itself. Since tool_call handlers can mutate event.input in place before the tool actually runs (not just return { block: true }), the guard can instead rewrite the command and let it execute for real.

Added tryTranslateFindToMdfind(): for the common case โ€” find <broad-root> [-maxdepth N] [-type f|d] -name|-iname '<pattern>' with nothing else in play โ€” it rewrites the command to the mdfind equivalent (stripping glob wildcards from the pattern, since mdfind -name is substring match, not glob; adding -onlyin <root> unless the root is literally /) and prepends an echo ... 1>&2 notice so the substitution is visible in the tool output, not silent.

The translator deliberately bails out (falls back to the old hard block) on anything it can't safely reinterpret: -exec/-ok/-delete, pipes/chains, any predicate other than -maxdepth/-mindepth/-type/-name/-iname/-print. Getting this wrong silently โ€” e.g. mistranslating a -exec rm into something that runs mdfind and reports success without deleting anything โ€” is worse than just blocking, so untranslatable commands still get the old "blocked + suggestion" treatment. Content search (grep -r over a broad root) has no safe mdfind equivalent (regex vs. substring/predicate semantics differ too much) and is always just blocked.

fd/rg were also dropped from the restricted-scanner list entirely โ€” they're already the fast/parallel/.gitignore-aware alternative this guard exists to nudge the agent toward, so there's nothing to protect against.

function tryTranslateFindToMdfind(command: string, rawRoot: string, absRoot: string): string | undefined {
  const trimmed = command.trim();
  if (/[|;&]|-exec\b|-ok\b|-delete\b/.test(trimmed)) return undefined;
  if (!/^find\s+/.test(trimmed)) return undefined;

  const afterRoot = trimmed.slice(trimmed.indexOf(rawRoot) + rawRoot.length);
  const tokens = afterRoot.match(/'[^']*'|"[^"]*"|\S+/g) ?? [];

  let pattern: string | undefined;
  for (let i = 0; i < tokens.length; i++) {
    const t = tokens[i];
    if (t === "-maxdepth" || t === "-mindepth") { i++; continue; }
    if (t === "-type") { i++; continue; } // no direct mdfind equivalent, ignored
    if (t === "-name" || t === "-iname") {
      if (pattern !== undefined) return undefined; // ambiguous, bail
      const next = tokens[i + 1];
      if (!next) return undefined;
      pattern = next.replace(/^['"]|['"]$/g, "");
      i++;
      continue;
    }
    if (t === "-print" || t === "-print0") continue;
    return undefined; // -mtime, -size, -newer, "(", "and", "or", ... โ€” too much drift to translate
  }
  if (pattern === undefined) return undefined;

  const substring = pattern.replace(/^\*+/, "").replace(/\*+$/, "");
  if (!substring) return undefined;

  const scopeFlag = absRoot === "/" ? "" : ` -onlyin ${shellQuote(absRoot)}`;
  return `mdfind${scopeFlag} -name ${shellQuote(substring)}`;
}

In the tool_call handler, a successful translation mutates event.input.command and returns undefined (not blocked) instead of { block: true, ... }:

if (result && "rewrite" in result) {
  input.command = result.rewrite; // mutate in place โ€” pi executes the rewritten command
  return undefined;
}

Verified (v3):

  • find / -maxdepth 2 -name '*.txt' โ€” transparently substituted to mdfind -name .txt, actually ran, returned real matches (e.g. Chrome cache index.txt files) instantly instead of hanging. The stderr notice ([scope-guard] find over a broad root (/) is lockup-prone; substituting: mdfind -name .txt) showed up in the tool output so the model reported the substitution rather than silently believing the literal find ran.
  • grep -r 'zzqwerty123' / --include='*.txt' -l (content search, no safe translation) โ€” still blocked; model fell back to running mdfind itself on the search term.
  • find /Users/dominick/Work/personal-notes -maxdepth 1 -name '*.md' (not broad) โ€” ran completely unchanged, no interference.
  • find / -maxdepth 1 -name 'zzqwerty123' -exec echo hit {} \; (has -exec, translation correctly refuses) โ€” still blocked rather than mistranslated into a no-op mdfind call that would silently skip the -exec side effect.

Faster than find

On macOS, find and grep -r are slow specifically because they physically walk every directory entry โ€” that's what locks up on / or other huge trees. Alternatives, roughly fastest-to-slowest for different jobs:

  • mdfind (Spotlight) โ€” queries the pre-built system index instead of walking the filesystem, so it's the right tool for "search everywhere" or "I don't know where this file lives." mdfind -name 'pattern' for filenames, mdfind -onlyin /some/dir 'pattern' to scope it, mdfind 'kMDItemTextContent == "foo"' for content search. Doesn't work well on paths Spotlight excludes (e.g. some mounted/network volumes, .git internals) and only reflects what's indexed.
  • fd โ€” a find replacement: parallelized, respects .gitignore by default, much simpler syntax (fd pattern /some/dir). This is what pi's own built-in find tool prefers under the hood when available (docs: "Default: local filesystem plus fd").
  • rg (ripgrep) โ€” the grep -r replacement, same parallel/.gitignore-aware story; also what pi's built-in grep tool prefers by default ("Default: local filesystem plus ripgrep").
  • Plain find/grep -r โ€” fine for anything already scoped to a normal project directory; the problem was never find itself, it was pointing it at /.

Both fd and rg are already installed for this account under ~/.pi/agent/bin/.

Example: secret-guard.ts โ€” redact secrets from tool output instead of hiding that they exist

Distinct concern from scope-guard: the agent legitimately needs to read a .env, check whether a variable is set, or grep for a key name โ€” that's fine and shouldn't be blocked. What's not fine is the actual secret value ending up printed into the visible transcript, which then also lands in the session file on disk and gets sent to the model provider as plain context. Blocking the read isn't the right tool here (it can't tell the difference between "cat .env to see if DEBUG is set" and "cat .env to leak the API key"); redacting the value at the boundary is.

Placed at ~/.pi/agent/extensions/secret-guard.ts (global, applies to every project). Two hooks:

  • tool_result โ€” runs on every tool's output before it's finalized into the session/context. Scans content text blocks for secret-shaped patterns and replaces just the value with [REDACTED:<kind>], leaving the surrounding line intact (turning e.g. an API_KEY= assignment into API_KEY=[REDACTED:assignment] rather than dropping the line) so the model still knows the variable exists and is non-empty, just not the value. Appends a one-line notice when anything was redacted so the model doesn't mistake the placeholder for the real thing.
  • message_end (assistant messages only) โ€” same redaction applied to the model's own reply text, in case a secret already made it into context some other way and the model tries to relay it back verbatim.

Pattern set: PEM private key blocks, AWS access key IDs (AKIA...), GitHub tokens (ghp_/gho_/etc.), Slack tokens (xox...), Stripe keys (sk_live_/pk_live_), OpenAI/Anthropic keys (sk-.../sk-ant-...), Google API keys (AIza...), JWTs (eyJ...), user:password@host embedded in URLs, and a generic KEY=value/KEY: value assignment pattern for names containing SECRET/TOKEN/PASSWORD/API_KEY/ACCESS_KEY/PRIVATE_KEY/CREDENTIAL/CLIENT_SECRET. The assignment pattern is tool-output-only (not applied to assistant prose) to avoid flagging discussion of env var names without real values, and skips an obvious-placeholder allowlist (changeme, xxxx, your_key_here, example, etc.) so docs/templates don't get needlessly redacted. An empty assignment (EMPTY_TOKEN=) is left alone โ€” no redaction, so the model can still tell the difference between "set" and "unset."

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

interface SecretPattern {
  name: string;
  regex: RegExp;
  valueGroup?: number; // capture group holding just the value, if the match includes context
  toolOnly?: boolean;  // skip for assistant prose
}

const PLACEHOLDER_VALUES =
  /^(x+|\*+|changeme|change[_-]?me|example|placeholder|your[_-]?\w*|insert[_-]?\w*|replace[_-]?\w*|todo|redacted|none|null|n\/a|xxxx+|1234+)$/i;

const SECRET_PATTERNS: SecretPattern[] = [
  { name: "private-key", regex: /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g },
  { name: "aws-access-key-id", regex: /\bAKIA[0-9A-Z]{16}\b/g },
  { name: "github-token", regex: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
  { name: "slack-token", regex: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g },
  { name: "stripe-key", regex: /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g },
  { name: "anthropic-key", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
  { name: "openai-key", regex: /\bsk-[A-Za-z0-9]{20,}\b/g },
  { name: "google-api-key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/g },
  { name: "jwt", regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g },
  { name: "url-basic-auth", regex: /(:\/\/[^/\s:@]+:)([^@\s]+)(@)/g, valueGroup: 2 },
  {
    name: "assignment",
    regex:
      /\b((?:[A-Z0-9]+_)*(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIALS?|CLIENT[_-]?SECRET)(?:_[A-Z0-9]+)*)\s*([:=])\s*(['"]?)([^\s'";,]{4,})\3/gi,
    valueGroup: 4,
    toolOnly: true,
  },
];

function redact(text: string, includeToolOnly: boolean): { text: string; count: number } {
  let result = text;
  let count = 0;
  for (const p of SECRET_PATTERNS) {
    if (p.toolOnly && !includeToolOnly) continue;
    p.regex.lastIndex = 0;
    result = result.replace(p.regex, (...args) => {
      const groups = args.slice(0, -2) as string[];
      const full = groups[0];
      if (p.valueGroup === undefined) { count++; return `[REDACTED:${p.name}]`; }
      const value = groups[p.valueGroup];
      if (!value || PLACEHOLDER_VALUES.test(value)) return full;
      count++;
      return full.replace(value, `[REDACTED:${p.name}]`);
    });
  }
  return { text: result, count };
}

export default function (pi: ExtensionAPI) {
  pi.on("tool_result", async (event) => {
    if (!Array.isArray(event.content)) return undefined;
    let totalRedacted = 0;
    const newContent = event.content.map((block) => {
      if (block.type !== "text" || typeof block.text !== "string") return block;
      const { text, count } = redact(block.text, true);
      totalRedacted += count;
      return count > 0 ? { ...block, text } : block;
    });
    if (totalRedacted === 0) return undefined;
    return {
      content: [...newContent, { type: "text" as const, text: `\n[secret-guard] Redacted ${totalRedacted} secret-shaped value(s) from this output.` }],
    };
  });

  pi.on("message_end", async (event) => {
    if (event.message.role !== "assistant") return undefined;
    if (!Array.isArray(event.message.content)) return undefined;
    let totalRedacted = 0;
    const newContent = event.message.content.map((block) => {
      if (block.type !== "text" || typeof block.text !== "string") return block;
      const { text, count } = redact(block.text, false);
      totalRedacted += count;
      return count > 0 ? { ...block, text } : block;
    });
    if (totalRedacted === 0) return undefined;
    return { message: { ...event.message, content: newContent } };
  });
}

Verified:

  • A test .env with a fake API key, GitHub token, and a postgres://user:password@host/db-style connection string, plus DEBUG=true and a blank EMPTY_TOKEN= โ€” cat .env and grep API_KEY .env both came back with values replaced by [REDACTED:...] placeholders, DEBUG=true untouched, EMPTY_TOKEN= untouched (still shows as unset, no false "it's set" signal), and a summary note appended so the model reported the redaction accurately instead of assuming it saw the real values.
  • [ -n "$TEST_TOKEN" ] && echo 'TEST_TOKEN is set' โ€” an existence check with no value printed โ€” passed through completely unmodified, confirming the guard doesn't interfere with "does this var exist" checks.
  • Asking the model to repeat a secret already sitting in its context verbatim โ€” the message_end hook caught and redacted it in the assistant's own reply too, not just tool output.

Known limitations: pattern-based, so it only catches secrets in recognizable shapes (known token prefixes, PEM blocks, or an assignment naming pattern) โ€” a bespoke internal token format with no name/prefix hint wouldn't be caught. It also doesn't redact details fields tools may populate separately from content, or tool_execution_update streaming partials shown live before the final result. Good as a backstop against the common leak shapes, not a substitute for not printing secrets in the first place.

v2: narrowed scope to the assistant's own reply only, after the tool_result hook caused real collateral damage

The original design hooked both tool_result (redact raw tool output) and message_end (redact the assistant's own prose). The tool_result half turned out to be a mistake in practice, discovered by hitting it personally:

  • It corrupted this very documentation. Writing up the "Verified" bullet above involved describing a test .env file containing realistic-looking fake secrets (API_KEY=sk-..., a postgres://user:pass@host URL). Later reads of this doc file through any pi tool (Read, cat, grep) ran back through tool_result, which matched those documentation examples as if they were live secrets and redacted them โ€” permanently, once a subsequent edit accidentally baked the already-redacted (display-time) text back onto disk by copying from a corrupted read instead of the original source.
  • It corrupted reads of secret-guard's own source code. const SECRET_PATTERNS: SecretPattern[] = [...] (a TypeScript type annotation) matched the generic assignment pattern's KEY: value shape โ€” "SECRET_PATTERNS" contains "SECRET", followed by : SecretPattern[], which reads exactly like KEY: value to a regex that doesn't know TypeScript syntax from an env file.
  • Both were fixed with better heuristics (a long-monotonic-character-run detector for sequential filler text like abcdefghijklmnopqrstuvwxyz, and a bare-identifier/type detector for things like SecretPattern[]) โ€” but fixing false positives one shape at a time is a losing game against arbitrary documentation and source code, which was never really what needed guarding anyway.

The actual requirement, restated by the person who asked for this extension partway through debugging it: "I don't care about reads and writes in a file. I just don't want the agent printing it out in the main chat thread." That's a materially narrower scope than "redact every tool's output" โ€” reading a .env, cat-ing a file, or grepping for a key name are all fine; the failure mode is specifically the model relaying a secret value in its own visible reply. Removing the tool_result hook entirely and keeping only message_end satisfies that scope exactly, and eliminates the whole class of false positives above by construction โ€” there's no legitimate reason for the assistant's own prose to contain a real secret unless it's about to print one for the user.

Verified after the narrowing:

  • cat .env against a file with a real-looking API_KEY=sk-... and a postgres://user:realpassword@host URL โ€” passed through completely untouched (this is now explicitly out of scope, not a regression).
  • Asking the model to repeat a random-looking fake key (sk-mK7qXz2LpN9wRt4VbY8sJd3FhC) verbatim โ€” still correctly redacted to [REDACTED:assignment] in the assistant's own reply.
  • Asking it to repeat a fake key built from sequential filler (sk-abcdefghijklmnopqrstuvwxyz...) โ€” correctly left alone, matching the placeholder-detection design intent.
  • Reading secret-guard.ts's own source and this doc file back โ€” both display clean now, with no live re-redaction happening on top of them.

Aside โ€” debugging under a still-stale cached extension: while chasing the doc corruption, every read of the affected files (Read, cat, grep, even sed) was passing back through the old, not-yet-restarted tool_result hook, which kept re-redacting the same already-corrupted text on every single read โ€” consistently, since replacing [REDACTED:url-basic-auth] with [REDACTED:url-basic-auth] again is a no-op-looking but still "redacted" transformation. That made it impossible to trust any tool-mediated read as ground truth for what was actually on disk. The fix was to drop to python3 file I/O directly (open(path).read()) and write assertions/diffs to a temp file, then cat only that small, secret-free verification output โ€” sidestepping the tool layer entirely instead of trying to out-guess which layer of redaction was live at any given moment.


Debugging lesson: extensions can be cached at the host process level, not per-session

Hit this hard while iterating on scope-guard.ts under pi-web (a persistently-running pm2-managed pi host, see Pi Web UI + MCP Adapter Setup above). The symptom: edited and overwrote the file on disk multiple times, confirmed the new content with Read, but the running session kept producing the exact old error text โ€” including wording (PI_SCOPE_ROOTS, "outside the allowed scope") that no longer existed anywhere in the file. /reload didn't fix it. A brand-new chat session in the same pi-web instance hit the identical stale error, immediately โ€” which was the tell: if extensions reloaded fresh per-session, a new session would have read the current file. It didn't, which meant the extension module was cached once at the process level and reused across every session hosted by that process, not reloaded per-session or per-/reload.

Fix: restart the actual host process โ€” pm2 restart pi-web:49997 โ€” not /reload from inside a session. Confirmed immediately after: the new file's behavior took effect for every session on that host.

Takeaway: when an extension edit doesn't seem to take effect no matter how certain you are the file changed, check whether it's running under a long-lived host (pm2, docker, systemd, any "server that spawns/hosts sessions" setup) before assuming the code itself is wrong. /reload covers the interactive-CLI case pi's docs describe; it does not necessarily cover a hosted server that loaded extensions once at its own startup. A cheap sanity check when this is suspected: open a second, independent session against the same host and see if it exhibits the identical stale behavior โ€” if a fresh session shows the old bug too, it's a host-level cache, not a session-level one, and no amount of /reload will fix it short of restarting the host.

Aside โ€” working around a misbehaving path-scoping extension to debug itself: while the guard was actively (and wrongly) blocking absolute-path commands, inspecting the extensions directory itself was awkward โ€” ls/cat on ~/.pi/agent/extensions/scope-guard.ts tripped the very bug being diagnosed. A simple workaround: symlink the directory into the current project so it's reachable via a relative path instead:

ln -s ~/.pi/agent/extensions ~/Work/qwestly-workspace/_private/pi-extensions

Then ls/cat/find against _private/pi-extensions/... (relative to an already-safe cwd) instead of the absolute ~/.pi/agent/extensions/... path that the broken guard was misclassifying. General trick for debugging any tool that scopes/restricts based on path shape: relocate the thing you need to inspect into a path the tool already trusts, rather than fighting the tool's own logic to reach it.

scope-guard.ts v4: precision and cost fixes after the caching detour

Once the process-restart issue was understood (and no longer confused with the guard's own logic), a few real, independent issues turned up from actually exercising v3 under load:

  • RESTRICTED_SCANNERS matched ls inside unrelated subcommand names. \bls\b treats a hyphen as a word boundary, so it matched ls inside git ls-files, git ls-remote, git ls-tree โ€” commands that have nothing to do with the filesystem ls. Low real-world risk (also requires a broad path argument in the same command to actually trigger a block), but a real false-positive class, same shape as the 2>&1 issue below. Fixed with a negative lookahead: \bls(?!-)\b.
  • Dead code: iname was tracked through the find โ†’ mdfind translator but never used โ€” mdfind -name has no case-sensitive variant, so -name and -iname were always translated identically. Removed the unused tracking and the no-op ternary that resulted from it (iname ? "-name" : "-name").
  • Substitution notice could get truncated away, making a working guard indistinguishable from a broken one. The notice was embedded via a leading echo ... 1>&2 in the substituted command, so it lived inside the same stdout stream as the (possibly huge) mdfind output. Pi's own tool-output cap (2000 lines / 50KB) truncates from the head when output overflows, which silently ate the echoed notice along with the first N lines of results โ€” hit this personally: got a 5090-line dump with no visible confirmation the substitution had even happened, had to go read a saved log file directly to confirm it wasn't just the raw find / leaking through. Fixed by moving the notice out of the executed command entirely: track toolCallId โ†’ { original, substituted } in a Map when a rewrite happens, then a separate tool_result handler appends the notice as its own trailing content block (same technique secret-guard uses for its redaction-count note). A trailing, separately-tracked block survives truncation in a way that text baked into the front of a giant stdout blob does not.
  • Uncapped mdfind substitutions could return thousands of results for a generic pattern. An unscoped root search has nothing to bound it by, so find / -name '*.txt' โ†’ mdfind -name .txt legitimately matched 5090 files. Pi's platform-level truncation bounded what actually reached context, but that's a bigger, noisier result than useful, and there's no reason to rely on a generic backstop when the guard is generating this command itself. Fixed by appending our own | head -n 50 to the executed (capped) command while keeping the notice referencing the clean, uncapped translation โ€” so the model sees a short, useful result and an explicit "capped to the first 50 matches" caveat, not a wall of browser-cache noise.

All four verified together: find / -name '*.txt' now returns exactly 50 results with a truncation-proof notice; git ls-files and npm ls no longer false-trigger; grep -r 'x' / (untranslatable) still blocks correctly; ls -la ~/.pi/agent/skills/vercel/ (a real, narrow path) still passes through untouched.