How does the harness extend itself?

HEAD
ff9a3f0Add [Unreleased] section for next cycle
This post might have stale content, as HEAD is 438 commits ahead.

heard people saying the harness is extensible, "and it can even extend itself" -- what does that mean? how does it work?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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:

  1. You ask Pi to create an extension (e.g., "create an extension that asks for confirmation before running any rm command")
  2. Pi reads its own documentation (docs/extensions.md) and examples (examples/extensions/)
  3. Pi writes the extension to ~/.pi/agent/extensions/your-extension.ts using its write tool
  4. You run /reload to hot-reload extensions
  5. 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.

Avatar of julianbenegas
Julian Benegas
commented

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.

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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:

  1. Global: ~/.pi/agent/extensions/
  2. 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:

AspectImperative (Current)Declarative (Hypothetical)
PowerFull TypeScript - loops, conditionals, async, external APIsLimited to what schema supports
ComplexityCan get messyConstrained, predictable
AI-friendlinessLLM can write any extensionLLM could generate YAML more reliably
ValidationRuntime errorsSchema validation upfront
CompositionCan import libraries, call other extensionsHarder to compose
UIFull TUI component access (ctx.ui.custom())Very hard to express
StateFull 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:

  1. Custom UI - snake.ts renders a game, doom-overlay/ runs DOOM
  2. Complex state - tools.ts persists tool selections across branches
  3. External integrations - ssh.ts delegates to remote machines
  4. 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 .cursorrules or CLAUDE.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.

Avatar of julianbenegas
Julian Benegas
commented

compare that to opencode's plugins. how do they do their stuff?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have a complete picture of OpenCode's approach. Let me summarize the comparison:


##Pi vs OpenCode: Extension/Plugin System Comparison

###Loading Mechanism

AspectPiOpenCode
DiscoveryFilesystem: ~/.pi/agent/extensions/, .pi/extensions/Filesystem: .opencode/tool/, .opencode/plugin/, ~/.opencode/
ConfigCLI flags (-e), settings.jsonopencode.json, CLI flags, npm packages
FormatTypeScript modules with export default function(pi)TypeScript modules with export default tool({...}) or export async function(input): Hooks
Package InstallManual or via npm/git pi packagesAuto-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

FeaturePiOpenCode
Hook Count~20+ events (granular)~10 hooks (focused)
Hook Patternpi.on(event, handler)Return object with hook functions
Tool Return{ content: [...], details }Plain string
Tool SchemaTypeBox (Type.Object)Zod (z.object)
UI AccessFull TUI API (ctx.ui.custom(), overlays, widgets)Limited (question tool for prompts)
Session Statepi.appendEntry() for persistenceNo direct session persistence API
Commandspi.registerCommand("/foo")Agents/commands via markdown files
Hot Reload/reload commandRestart required
Self-ExtensionLLM 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

PiOpenCode
PowerHigher - full TUI, any hookLower - focused hooks
SimplicityMore complex API surfaceCleaner tool definition
Tool DefinitionMore boilerplateMinimal (Zod + execute)
Auth PluginsManual via extensionFirst-class auth hook
Package MgmtManual installAuto npm install on startup
Declarative ToolsNoSomewhat (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

How does the harness extend itself? — badlogic/pi-mono