How does the harness extend itself?
heard people saying the harness is extensible, "and it can even extend itself" -- what does that mean? how does it work?
Now I have a complete understanding. Let me summarize how Pi's harness is extensible and can "extend itself":
##How Pi's Harness is Extensible (and "Extends Itself")
The phrase "it can even extend itself" refers to a really clever design pattern in Pi:
###1. The Extension System
Pi is built around a powerful extension system. Extensions are TypeScript modules that can:
- Register new tools the LLM can call (
pi.registerTool()) - Subscribe to lifecycle events (tool calls, session start, compaction, etc.)
- Register commands like
/mycommand(pi.registerCommand()) - Add keyboard shortcuts (
pi.registerShortcut()) - Interact with users via UI primitives (select, confirm, input, overlays)
- Persist state across sessions
###2. How Pi "Extends Itself"
This is the key insight. Look at the system prompt in system-prompt.ts:
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK)
- When asked about: extensions (docs/extensions.md, examples/extensions/), ...
And the documentation headers say:
pi can create extensions. Ask it to build one for your use case.
So the "self-extension" works like this:
- You ask Pi to create an extension (e.g., "create an extension that asks for confirmation before running any
rmcommand") - Pi reads its own documentation (
docs/extensions.md) and examples (examples/extensions/) - Pi writes the extension to
~/.pi/agent/extensions/your-extension.tsusing itswritetool - You run
/reloadto hot-reload extensions - Your new capability is now active - the LLM can now use any tools you added, and your hooks are running
###3. Example Flow
User: "I want pi to play a sound when it finishes a task"
Pi: *reads docs/extensions.md and examples/extensions/notify.ts*
Pi: *writes ~/.pi/agent/extensions/sound-notify.ts*
User: /reload
[Now pi plays sounds when tasks complete]
###4. The Extension Ecosystem
Extensions live in:
- Global:
~/.pi/agent/extensions/ - Project-local:
.pi/extensions/ - Packages: Shareable via npm or git
The examples/extensions/ directory contains ~50+ example extensions including:
- Permission gates (
permission-gate.ts) - Custom tools (
hello.ts,todo.ts) - Git integration (
git-checkpoint.ts) - Games (
snake.ts,doom-overlay/) - Plan mode (
plan-mode/) - Sub-agents (
subagent/)
###5. Technical Implementation
Extensions use jiti for TypeScript loading at runtime, with virtual modules so extensions can import from Pi's packages even in compiled binaries:
// From loader.ts
const VIRTUAL_MODULES: Record<string, unknown> = {
"@sinclair/typebox": _bundledTypebox,
"@mariozechner/pi-agent-core": _bundledPiAgentCore,
"@mariozechner/pi-tui": _bundledPiTui,
"@mariozechner/pi-ai": _bundledPiAi,
"@mariozechner/pi-coding-agent": _bundledPiCodingAgent,
};
TL;DR: Pi's harness extensibility means you can add any capability via TypeScript extensions. The "extends itself" part is that Pi (the LLM) can read its own extension docs, write new extensions, and reload them - effectively allowing you to ask Pi to add new capabilities to itself without you writing any code.
and u load extensions imperatively? or is there a file system convention? extensions basically hold hooks and tools?
wondering about the tradeoffs between imperative vs declarative (if sth like that would exist). granted, the thing is quite powerful.
Now I have a complete picture. Let me summarize:
##How Extensions Are Loaded
It's a hybrid: filesystem convention + imperative configuration.
###Filesystem Convention (Auto-Discovery)
Extensions are automatically discovered from:
- Global:
~/.pi/agent/extensions/ - Project-local:
.pi/extensions/
Discovery rules inside those directories:
extensions/
├── hello.ts # Direct file → loaded
├── my-tool.js # Direct JS file → loaded
├── complex-extension/ # Subdirectory...
│ └── index.ts # ...with index.ts → loaded
└── package-ext/ # Subdirectory...
└── package.json # ...with pi manifest → loads what it declares
###Imperative (CLI/Config)
pi --extension ./my-ext.ts # CLI flag
pi -e ./another.ts -e ./third.ts
Or via settings:
{
"extensions": ["./path/to/ext.ts", "~/shared/ext.ts"]
}
##What Extensions Hold
An Extension is essentially a bag of registered items:
interface Extension {
path: string;
resolvedPath: string;
handlers: Map<string, HandlerFn[]>; // Event hooks
tools: Map<string, RegisteredTool>; // LLM-callable tools
messageRenderers: Map<string, MessageRenderer>; // Custom UI rendering
commands: Map<string, RegisteredCommand>; // /commands
flags: Map<string, ExtensionFlag>; // CLI flags
shortcuts: Map<KeyId, ExtensionShortcut>; // Keyboard shortcuts
}
So yes, extensions hold hooks + tools + commands + UI stuff.
##Tradeoffs: Imperative vs Declarative
You raised an interesting design question. The current system is imperative - you write code that calls pi.registerTool(), pi.on("tool_call", ...), etc.
###A Declarative Alternative Would Look Like:
# extension.yaml
name: permission-gate
hooks:
tool_call:
- when: { toolName: "bash", input.command: { contains: "rm -rf" } }
action: confirm
message: "Allow dangerous command?"
tools:
- name: greet
description: "Greet someone"
parameters:
name: { type: string }
execute: ./greet.sh # or inline script
###Tradeoffs:
| Aspect | Imperative (Current) | Declarative (Hypothetical) |
|---|---|---|
| Power | Full TypeScript - loops, conditionals, async, external APIs | Limited to what schema supports |
| Complexity | Can get messy | Constrained, predictable |
| AI-friendliness | LLM can write any extension | LLM could generate YAML more reliably |
| Validation | Runtime errors | Schema validation upfront |
| Composition | Can import libraries, call other extensions | Harder to compose |
| UI | Full TUI component access (ctx.ui.custom()) | Very hard to express |
| State | Full control (pi.appendEntry()) | Need special syntax |
###Why Pi Chose Imperative
Looking at the examples, the power comes from things that are hard to express declaratively:
- Custom UI -
snake.tsrenders a game,doom-overlay/runs DOOM - Complex state -
tools.tspersists tool selections across branches - External integrations -
ssh.tsdelegates to remote machines - Dynamic behavior -
subagent/spawns child agents
The declarative approach would need an escape hatch for 80% of interesting use cases, at which point you're back to code.
###Middle Ground?
Some harnesses do a tiered approach:
- Simple hooks via config (like
.cursorrulesorCLAUDE.md) - Full extensions for complex stuff
Pi's Skills are actually somewhat declarative - they're Markdown files with frontmatter:
---
name: brave-search
description: Search the web using Brave
---
# Instructions
1. Use the brave_search tool...
But even skills often include scripts in subdirectories for the imperative bits.
TL;DR: Extensions use filesystem convention for discovery + imperative TypeScript for registration. They hold event hooks, tools, commands, UI renderers, and shortcuts. The imperative approach is more powerful but less constrained than a hypothetical declarative config would be.
compare that to opencode's plugins. how do they do their stuff?
Now I have a complete picture of OpenCode's approach. Let me summarize the comparison:
##Pi vs OpenCode: Extension/Plugin System Comparison
###Loading Mechanism
| Aspect | Pi | OpenCode |
|---|---|---|
| Discovery | Filesystem: ~/.pi/agent/extensions/, .pi/extensions/ | Filesystem: .opencode/tool/, .opencode/plugin/, ~/.opencode/ |
| Config | CLI flags (-e), settings.json | opencode.json, CLI flags, npm packages |
| Format | TypeScript modules with export default function(pi) | TypeScript modules with export default tool({...}) or export async function(input): Hooks |
| Package Install | Manual or via npm/git pi packages | Auto-installs npm packages on startup via bun add |
###What They Hold
Pi Extensions:
export default function(pi: ExtensionAPI) {
// Hooks (20+ event types)
pi.on("tool_call", async (event, ctx) => {...})
pi.on("session_start", ...)
pi.on("context", ...) // modify messages before LLM
// Tools
pi.registerTool({ name, parameters, execute })
// Commands
pi.registerCommand("mycommand", { handler })
// Shortcuts, flags, message renderers...
pi.registerShortcut(...)
}
OpenCode Plugins:
export async function MyPlugin(input: PluginInput): Promise<Hooks> {
return {
// Hooks (fewer, more focused)
"tool.execute.before": async (input, output) => {...},
"tool.execute.after": async (input, output) => {...},
"chat.message": async (input, output) => {...},
"chat.params": async (input, output) => {...},
"permission.ask": async (input, output) => {...},
// Tools (inline in hooks object)
tool: {
"my-tool": tool({ description, args, execute })
},
// Auth (special hook for OAuth flows)
auth: { provider: "openai", methods: [...] }
}
}
OpenCode Custom Tools (simpler, standalone):
// .opencode/tool/github-pr-search.ts
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Search GitHub PRs",
args: {
query: tool.schema.string(),
limit: tool.schema.number().default(10),
},
async execute(args, context) {
return "result string"
},
})
###Key Differences
| Feature | Pi | OpenCode |
|---|---|---|
| Hook Count | ~20+ events (granular) | ~10 hooks (focused) |
| Hook Pattern | pi.on(event, handler) | Return object with hook functions |
| Tool Return | { content: [...], details } | Plain string |
| Tool Schema | TypeBox (Type.Object) | Zod (z.object) |
| UI Access | Full TUI API (ctx.ui.custom(), overlays, widgets) | Limited (question tool for prompts) |
| Session State | pi.appendEntry() for persistence | No direct session persistence API |
| Commands | pi.registerCommand("/foo") | Agents/commands via markdown files |
| Hot Reload | /reload command | Restart required |
| Self-Extension | LLM can write extensions (documented in system prompt) | No explicit self-extension pattern |
###Hook Comparison
Pi's Granular Hooks:
session_start, session_before_switch, session_switch, session_before_fork,
session_fork, session_before_compact, session_compact, session_shutdown,
session_before_tree, session_tree, context, before_agent_start, agent_start,
agent_end, turn_start, turn_end, model_select, tool_call, tool_result,
user_bash, input, resources_discover
OpenCode's Hooks:
event, config, chat.message, chat.params, chat.headers, permission.ask,
command.execute.before, tool.execute.before, tool.execute.after,
experimental.chat.messages.transform, experimental.chat.system.transform,
experimental.session.compacting, experimental.text.complete
###Philosophy
Pi: Maximum flexibility, imperative, the extension IS code that can do anything. Extensions have full TUI access, can render games, manage state, intercept everything. "Build your own Claude Code" approach.
OpenCode: More constrained, hooks modify input/output objects passed by reference. Cleaner separation between tools (simple) and plugins (complex). Tools are almost declarative - just describe args and implement execute.
###Tradeoffs
| Pi | OpenCode | |
|---|---|---|
| Power | Higher - full TUI, any hook | Lower - focused hooks |
| Simplicity | More complex API surface | Cleaner tool definition |
| Tool Definition | More boilerplate | Minimal (Zod + execute) |
| Auth Plugins | Manual via extension | First-class auth hook |
| Package Mgmt | Manual install | Auto npm install on startup |
| Declarative Tools | No | Somewhat (tool DSL is close) |
TL;DR: OpenCode has a simpler, more constrained plugin model - tools are almost declarative with Zod schemas. Pi has a more powerful imperative model with full TUI access and 20+ hook points. OpenCode auto-installs npm plugins; Pi requires manual setup but has hot-reload. OpenCode separates "tools" (simple) from "plugins" (full hooks); Pi combines everything into "extensions".
END OF POST