| name | pi-coding-agent-extensions |
| description | Build and use custom extensions for Pi Coding Agent, the open-source alternative to Claude Code |
| triggers | ["how do I extend pi coding agent","create a custom pi extension","add a widget to pi agent","build a pi coding agent extension","customize pi agent ui","make a pi agent tool","orchestrate multiple pi agents","pi agent communication between agents"] |
Pi Coding Agent Extensions
Skill by ara.so — Claude Code Skills collection.
Overview
Pi Coding Agent is an open-source terminal-based AI coding assistant similar to Claude Code. This project (pi-vs-claude-code) provides a collection of production-ready extensions that showcase Pi's customization capabilities: custom UI widgets, multi-agent orchestration, safety auditing, cross-agent integrations, and inter-agent communication.
Extensions are TypeScript files that hook into Pi's lifecycle events to add tools, modify UI, intercept commands, and coordinate with other agents.
Prerequisites
All three are required:
curl -fsSL https://bun.sh/install | bash
brew install just
Installation
git clone https://github.com/disler/pi-vs-claude-code.git
cd pi-vs-claude-code
bun install
cp .env.sample .env
API Key Setup
Pi does NOT auto-load .env files. You must source keys before launching:
Option 1 - Manual source:
source .env && pi
Option 2 - Shell alias (add to ~/.zshrc):
alias pi='source $(pwd)/.env && pi'
Option 3 - Use just (automatic):
just pi
Running Extensions
Single Extension
pi -e extensions/minimal.ts
Stack Multiple Extensions
pi -e extensions/minimal.ts -e extensions/cross-agent.ts
Using Just Recipes
just
just ext-minimal
just ext-tool-counter
just ext-subagent-widget
just ext-agent-team
just ext-damage-control
just ext-pi-pi
just coms-net-server
just coms
just all
Extension Architecture
Extensions export a PiExtension object with lifecycle hooks:
import type { PiExtension, PiApp } from "pi";
export default {
name: "my-extension",
version: "1.0.0",
async init(app: PiApp) {
},
async beforeTurn(app: PiApp, turn: Turn) {
},
async afterTurn(app: PiApp, turn: Turn) {
},
async destroy(app: PiApp) {
}
} satisfies PiExtension;
Key Extension Examples
Minimal Footer
Compact UI showing model name and context usage:
import type { PiExtension, PiApp } from "pi";
import { Widget } from "pi/ui";
export default {
name: "minimal",
version: "1.0.0",
async init(app: PiApp) {
const footer = new Widget({
position: "footer",
render: () => {
const model = app.session.model.name;
const usage = app.session.contextUsage;
const pct = Math.round(usage * 100);
const filled = Math.round(usage * 10);
const bar = "█".repeat(filled) + "░".repeat(10 - filled);
return `${model} [${bar}] ${pct}%`;
}
});
app.ui.addWidget(footer);
}
} satisfies PiExtension;
Custom Tool Registration
Add a new tool the agent can call:
import type { PiExtension, PiApp } from "pi";
export default {
name: "custom-tool",
version: "1.0.0",
async init(app: PiApp) {
app.registerTool({
name: "analyze_code_quality",
description: "Analyze code quality metrics for a file",
parameters: {
type: "object",
properties: {
file_path: {
type: "string",
description: "Path to the file to analyze"
}
},
required: ["file_path"]
},
handler: async (args: { file_path: string }) => {
const content = await app.fs.readFile(args.file_path);
return {
lines: content.split("\n").length,
complexity: calculateComplexity(content),
issues: findIssues(content)
};
}
});
}
} satisfies PiExtension;
function calculateComplexity(code: string): number {
const branches = (code.match(/if|while|for|case/g) || []).length;
return branches + 1;
}
function findIssues(code: string): string[] {
const issues: string[] = [];
if (code.includes("eval(")) issues.push("Dangerous eval() usage");
if (code.includes("TODO")) issues.push("Unfinished TODO items");
return issues;
}
Live Widget Above Editor
Create a persistent widget that updates in real-time:
import type { PiExtension, PiApp, Turn } from "pi";
import { Widget } from "pi/ui";
const toolCounts = new Map<string, number>();
export default {
name: "tool-counter-widget",
version: "1.0.0",
async init(app: PiApp) {
const widget = new Widget({
position: "above-editor",
height: 3,
render: () => {
const entries = Array.from(toolCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return entries
.map(([tool, count]) => `${tool}: ${count}`)
.join(" | ") || "No tools used yet";
}
});
app.ui.addWidget(widget);
},
async afterTurn(app: PiApp, turn: Turn) {
for (const call of turn.toolCalls || []) {
toolCounts.set(call.name, (toolCounts.get(call.name) || 0) + 1);
}
app.ui.refresh();
}
} satisfies PiExtension;
Safety Auditing (Damage Control)
Intercept and block dangerous commands before execution:
import type { PiExtension, PiApp, Turn, ToolCall } from "pi";
import { readFileSync } from "fs";
import { parse } from "yaml";
interface DamageControlRules {
blocked_patterns: string[];
allowed_paths: string[];
}
export default {
name: "damage-control",
version: "1.0.0",
async beforeTurn(app: PiApp, turn: Turn) {
const rules: DamageControlRules = parse(
readFileSync(".pi/damage-control-rules.yaml", "utf-8")
);
for (const call of turn.toolCalls || []) {
if (call.name === "bash") {
const cmd = call.arguments.command;
for (const pattern of rules.blocked_patterns) {
if (cmd.includes(pattern)) {
throw new Error(
`🚨 BLOCKED: Command contains dangerous pattern "${pattern}"`
);
}
}
if (cmd.match(/rm|mv|cp|>|>>/) && call.arguments.path) {
const isAllowed = rules.allowed_paths.some(
allowed => call.arguments.path.startsWith(allowed)
);
if (!isAllowed) {
throw new Error(
`🚨 BLOCKED: Path "${call.arguments.path}" is outside allowed directories`
);
}
}
}
}
}
} satisfies PiExtension;
Example .pi/damage-control-rules.yaml:
blocked_patterns:
- "rm -rf /"
- "dd if="
- ":(){ :|:& };:"
- "curl | bash"
- "wget | sh"
allowed_paths:
- "/tmp"
- "./src"
- "./tests"
- "./extensions"
Multi-Agent Orchestration
Subagent Spawning (/sub)
Offload tasks to background Pi agents:
import type { PiExtension, PiApp } from "pi";
import { Widget } from "pi/ui";
import { spawn } from "child_process";
const subagents = new Map<string, any>();
export default {
name: "subagent-widget",
version: "1.0.0",
async init(app: PiApp) {
app.registerCommand({
name: "sub",
description: "Spawn a background Pi agent for a task",
handler: async (args: string[]) => {
const task = args.join(" ");
const id = `sub-${Date.now()}`;
const widget = new Widget({
position: "above-editor",
height: 4,
render: () => `🤖 ${id}\n${subagents.get(id)?.status || "Starting..."}`
});
app.ui.addWidget(widget);
const proc = spawn("pi", ["-p", task], {
env: process.env,
stdio: ["ignore", "pipe", "pipe"]
});
subagents.set(id, { proc, status: "Running...", widget });
proc.stdout.on("data", (data) => {
subagents.get(id).status = data.toString().slice(-200);
app.ui.refresh();
});
proc.on("close", (code) => {
subagents.get(id).status = `✓ Complete (exit ${code})`;
app.ui.refresh();
setTimeout(() => {
app.ui.removeWidget(widget);
subagents.delete(id);
}, 3000);
});
}
});
}
} satisfies PiExtension;
Usage:
/sub implement user authentication with bcrypt
Agent Team Orchestration
Dispatcher pattern with specialist agents:
import type { PiExtension, PiApp } from "pi";
import { readFileSync } from "fs";
import { parse } from "yaml";
import { execSync } from "child_process";
interface TeamConfig {
agents: {
[key: string]: {
description: string;
system_prompt: string;
model?: string;
}
};
}
export default {
name: "agent-team",
version: "1.0.0",
async init(app: PiApp) {
const config: TeamConfig = parse(
readFileSync(".pi/agents/teams.yaml", "utf-8")
);
app.registerTool({
name: "dispatch_agent",
description: "Delegate a task to a specialist agent",
parameters: {
type: "object",
properties: {
agent_name: {
type: "string",
enum: Object.keys(config.agents),
description: "Which specialist agent to use"
},
task: {
type: "string",
description: "The task to delegate"
}
},
required: ["agent_name", "task"]
},
handler: async (args: { agent_name: string; task: string }) => {
const agent = config.agents[args.agent_name];
if (!agent) throw new Error(`Unknown agent: ${args.agent_name}`);
const agentPath = `/tmp/pi-agent-${args.agent_name}.json`;
writeFileSync(agentPath, JSON.stringify({
systemPrompt: agent.system_prompt,
model: agent.model || app.session.model.name
}));
const result = execSync(
`pi -c ${agentPath} -p "${args.task}"`,
{ encoding: "utf-8", env: process.env }
);
return {
agent: args.agent_name,
output: result
};
}
});
}
} satisfies PiExtension;
Example .pi/agents/teams.yaml:
agents:
backend:
description: Node.js/TypeScript backend specialist
system_prompt: |
You are an expert backend engineer specializing in Node.js, TypeScript,
Express, and PostgreSQL. Focus on API design, database schemas, and server architecture.
model: gpt-4o
frontend:
description: React/Next.js frontend specialist
system_prompt: |
You are an expert frontend engineer specializing in React, Next.js, and Tailwind.
Focus on component architecture, state management, and responsive design.
model: claude-opus-4
security:
description: Security auditing specialist
system_prompt: |
You are a security expert. Audit code for vulnerabilities, suggest fixes,
and ensure best practices for authentication, authorization, and data protection.
model: gpt-4o
Pi-to-Pi Communication
Local Communication (Same Machine)
Unix socket-based peer-to-peer messaging:
import type { PiExtension, PiApp } from "pi";
import { createServer, connect } from "net";
import { existsSync, mkdirSync, unlinkSync } from "fs";
const SOCKET_DIR = "/tmp/pi-coms";
const messages = new Map<string, string[]>();
export default {
name: "coms",
version: "1.0.0",
async init(app: PiApp) {
if (!existsSync(SOCKET_DIR)) mkdirSync(SOCKET_DIR);
const socketPath = `${SOCKET_DIR}/${app.session.id}.sock`;
const server = createServer((socket) => {
socket.on("data", (data) => {
const msg = JSON.parse(data.toString());
if (!messages.has(msg.from)) messages.set(msg.from, []);
messages.get(msg.from)!.push(msg.content);
});
});
server.listen(socketPath);
app.registerTool({
name: "coms_list",
description: "List all available Pi agents on this machine",
parameters: { type: "object", properties: {} },
handler: async () => {
const agents = readdirSync(SOCKET_DIR)
.filter(f => f.endsWith(".sock"))
.map(f => f.replace(".sock", ""));
return { agents };
}
});
app.registerTool({
name: "coms_send",
description: "Send a message to another Pi agent",
parameters: {
type: "object",
properties: {
to: { type: "string", description: "Target agent session ID" },
message: { type: "string", description: "Message content" }
},
required: ["to", "message"]
},
handler: async (args: { to: string; message: string }) => {
const targetSocket = `${SOCKET_DIR}/${args.to}.sock`;
const client = connect(targetSocket);
client.write(JSON.stringify({
from: app.session.id,
content: args.message
}));
client.end();
return { sent: true };
}
});
app.registerTool({
name: "coms_get",
description: "Retrieve messages from other agents",
parameters: { type: "object", properties: {} },
handler: async () => {
const all = Array.from(messages.entries()).map(([from, msgs]) => ({
from,
messages: msgs
}));
messages.clear();
return { messages: all };
}
});
},
async destroy(app: PiApp) {
const socketPath = `${SOCKET_DIR}/${app.session.id}.sock`;
if (existsSync(socketPath)) unlinkSync(socketPath);
}
} satisfies PiExtension;
Network Communication (Cross-Machine)
HTTP/SSE hub for LAN or remote agent coordination:
Start the hub server:
just coms-net-server
export PI_COMS_NET_AUTH_TOKEN=your-secret-token
just coms-net-server-lan
Connect agents:
just coms1
just coms2
The extension (extensions/coms-net.ts) provides tools:
coms_net_list - List connected agents
coms_net_send - Send message to specific agent
coms_net_broadcast - Broadcast to all agents
coms_net_get - Retrieve pending messages
Cross-Agent Integration
Load commands and skills from Claude Code, Gemini, Codex directories:
import type { PiExtension, PiApp } from "pi";
import { readdirSync, readFileSync, existsSync } from "fs";
export default {
name: "cross-agent",
version: "1.0.0",
async init(app: PiApp) {
const agentDirs = [".claude", ".gemini", ".codex"];
for (const dir of agentDirs) {
if (!existsSync(dir)) continue;
const skillsDir = `${dir}/skills`;
if (existsSync(skillsDir)) {
for (const file of readdirSync(skillsDir)) {
if (!file.endsWith(".md")) continue;
const content = readFileSync(`${skillsDir}/${file}`, "utf-8");
app.addContextDocument({
name: `${dir}-skill-${file}`,
content
});
}
}
const agentsDir = `${dir}/agents`;
if (existsSync(agentsDir)) {
for (const file of readdirSync(agentsDir)) {
if (!file.endsWith(".md")) continue;
const agentName = file.replace(".md", "");
const systemPrompt = readFileSync(`${agentsDir}/${file}`, "utf-8");
app.registerCommand({
name: `${dir.slice(1)}-${agentName}`,
description: `Switch to ${agentName} agent from ${dir}`,
handler: async () => {
app.session.systemPrompt = systemPrompt;
return `Switched to ${agentName}`;
}
});
}
}
}
}
} satisfies PiExtension;
Creating Your Own Extension
1. Create Extension File
touch extensions/my-feature.ts
2. Implement Extension
import type { PiExtension, PiApp } from "pi";
export default {
name: "my-feature",
version: "1.0.0",
description: "What this extension does",
async init(app: PiApp) {
console.log("Extension initialized");
},
async beforeTurn(app: PiApp, turn: Turn) {
},
async afterTurn(app: PiApp, turn: Turn) {
},
async destroy(app: PiApp) {
}
} satisfies PiExtension;
3. Add Just Recipe
Edit justfile:
ext-my-feature:
set -euo pipefail
source .env
pi -e extensions/my-feature.ts
4. Test Extension
just ext-my-feature
Configuration Files
.pi/settings.json
Pi workspace settings:
{
"model": "gpt-4o",
"temperature": 0.7,
"maxTokens": 4096,
"systemPrompt": "You are a helpful coding assistant.",
"theme": "default"
}
.pi/damage-control-rules.yaml
Safety rules for damage-control extension:
blocked_patterns:
- "rm -rf /"
- "> /dev/sda"
- "dd if="
- "mkfs"
- "curl | bash"
allowed_paths:
- "./src"
- "./tests"
- "./extensions"
- "/tmp"
max_file_size_mb: 10
.pi/agents/teams.yaml
Agent team definitions:
agents:
researcher:
description: Research and documentation specialist
model: gpt-4o
system_prompt: |
You are a research specialist. Your job is to find accurate, up-to-date
information from documentation, APIs, and code examples.
implementer:
description: Code implementation specialist
model: claude-opus-4
system_prompt: |
You are an implementation specialist. Given requirements and research,
you write clean, tested, production-ready code.
Common Patterns
Persistent State Across Turns
const state = {
taskList: [] as string[],
completedCount: 0
};
export default {
async init(app: PiApp) {
app.registerTool({
name: "add_task",
parameters: {
type: "object",
properties: {
task: { type: "string" }
},
required: ["task"]
},
handler: async (args: { task: string }) => {
state.taskList.push(args.task);
return { total: state.taskList.length };
}
});
},
async afterTurn(app: PiApp, turn: Turn) {
console.log(`Tasks: ${state.taskList.length}`);
}
} satisfies PiExtension;
Dynamic Widget Updates
const widget = new Widget({
position: "footer",
render: () => dynamicContent
});
let dynamicContent = "Initial";
setInterval(() => {
dynamicContent = new Date().toISOString();
app.ui.refresh();
}, 1000);
Command Registration
app.registerCommand({
name: "mycommand",
description: "Does something useful",
handler: async (args: string[]) => {
const result = await doSomething(args);
return result;
}
});
Usage in Pi:
/mycommand arg1 arg2 arg3
Troubleshooting
Extension Not Loading
Check TypeScript syntax:
bun run tsc --noEmit extensions/my-extension.ts
Verify extension export:
export default { ... } satisfies PiExtension;
API Keys Not Working
Verify environment:
echo $OPENAI_API_KEY
Check .env file:
cat .env
source .env
Widget Not Appearing
Check position:
const widget = new Widget({ position: "footer", ... });
Call refresh after updates:
widget.content = "New content";
app.ui.refresh();
Tool Not Being Called
Verify description clarity:
app.registerTool({
name: "my_tool",
description: "Clear, specific description of WHEN to use this tool",
});
Check parameter schema:
parameters: {
type: "object",
properties: {
required_param: {
type: "string",
description: "What this parameter does"
}
},
required: ["required_param"]
}
Subagent Communication Failing
Check socket directory:
ls -la /tmp/pi-coms/
Verify session IDs:
console.log("My session ID:", app.session.id);
For coms-net, check server is running:
curl http://localhost:3141/health
Advanced: Building Multi-Step Workflows
Combine extensions for complex workflows:
pi -e extensions/damage-control.ts \
-e extensions/agent-team.ts \
-e extensions/tool-counter-widget.ts
Create a custom workflow recipe in justfile:
workflow-full-stack:
set -euo pipefail
source .env
pi \
-e extensions/agent-team.ts \
-e extensions/damage-control.ts \
-e extensions/tool-counter.ts \
-e extensions/cross-agent.ts \
-p "Build a full-stack app with backend and frontend teams"
Resources
Contributing
Extensions in this repository are examples. To contribute:
- Create a new extension in
extensions/
- Add a just recipe in
justfile
- Document in a spec file under
specs/
- Submit a PR with working examples