Hook Registration
Hooks are registered using pi.on() in your extension’s default export function:
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
export default function (pi: ExtensionAPI) {
pi.on('event_name', async (event, ctx) => {
// Handle event
});
}
Lifecycle Hooks
Session Lifecycle
session_start
Fired on initial session load.
pi.on('session_start', async (event, ctx) => {
console.log('Session started');
});
session_before_switch
Fired before switching to another session. Can cancel the operation.
pi.on('session_before_switch', async (event, ctx) => {
if (event.reason === 'new') {
const confirm = await ctx.ui.confirm(
'New Session',
'Start a new session?'
);
return { cancel: !confirm };
}
});
Why the switch is happening
Returns: { cancel?: boolean }
session_switch
Fired after switching to another session.
pi.on('session_switch', async (event, ctx) => {
console.log(`Switched from ${event.previousSessionFile}`);
});
event.previousSessionFile
Previous session file path
session_shutdown
Fired on process exit. Useful for cleanup.
pi.on('session_shutdown', async (event, ctx) => {
// Save state, close connections, etc.
console.log('Shutting down');
});
Agent Lifecycle
before_agent_start
Fired after user submits prompt but before agent loop starts. Can inject custom messages or modify system prompt.
pi.on('before_agent_start', async (event, ctx) => {
return {
message: {
customType: 'reminder',
content: [{ type: 'text', text: 'Remember to be concise.' }],
display: 'System Reminder',
},
systemPrompt: event.systemPrompt + '\n\nBe extra helpful.',
};
});
event.images
ImageContent[] | undefined
Attached images, if any
Returns: { message?: CustomMessage; systemPrompt?: string }
agent_start
Fired when an agent loop starts.
pi.on('agent_start', async (event, ctx) => {
console.log('Agent starting');
});
agent_end
Fired when an agent loop ends.
pi.on('agent_end', async (event, ctx) => {
console.log(`Agent done, ${event.messages.length} messages`);
});
All messages in the conversation
turn_start
Fired at the start of each turn (LLM request/response cycle).
pi.on('turn_start', async (event, ctx) => {
console.log(`Turn ${event.turnIndex} starting at ${event.timestamp}`);
});
Timestamp (milliseconds since epoch)
turn_end
Fired at the end of each turn.
pi.on('turn_end', async (event, ctx) => {
console.log(`Turn ${event.turnIndex} complete`);
});
The assistant message from this turn
Tool results from this turn
Message Hooks
message_start
Fired when a message starts (user, assistant, or toolResult).
pi.on('message_start', async (event, ctx) => {
console.log(`Message starting: ${event.message.role}`);
});
The message that’s starting
message_update
Fired during assistant message streaming with token-by-token updates.
pi.on('message_update', async (event, ctx) => {
if (event.assistantMessageEvent.type === 'text_delta') {
process.stdout.write(event.assistantMessageEvent.delta);
}
});
The message being updated
event.assistantMessageEvent
Streaming event (text_delta, tool_call, thinking, etc.)
message_end
Fired when a message ends.
pi.on('message_end', async (event, ctx) => {
console.log('Message complete');
});
Fired before a tool executes. Can block execution.
pi.on('tool_call', async (event, ctx) => {
if (event.toolName === 'bash' && event.input.command.includes('rm -rf')) {
return {
block: true,
reason: 'Dangerous command blocked by safety extension',
};
}
});
Unique ID for this tool call
Name of the tool being called
Returns: { block?: boolean; reason?: string }
Fired after a tool executes. Can modify the result.
pi.on('tool_result', async (event, ctx) => {
if (event.toolName === 'bash' && event.isError) {
return {
content: [
...event.content,
{ type: 'text', text: '\n[Extension: Check exit code]' },
],
};
}
});
Unique ID for this tool call
event.content
(TextContent | ImageContent)[]
Tool result content
Whether the tool execution failed
Returns: { content?: Content[]; details?: unknown; isError?: boolean }
Fired when a tool starts executing.
pi.on('tool_execution_start', async (event, ctx) => {
console.log(`Tool ${event.toolName} starting`);
});
Unique ID for this tool call
Fired during tool execution with partial/streaming output.
pi.on('tool_execution_update', async (event, ctx) => {
console.log(`Tool ${event.toolName} update:`, event.partialResult);
});
Unique ID for this tool call
Partial result from the tool
Fired when a tool finishes executing.
pi.on('tool_execution_end', async (event, ctx) => {
console.log(`Tool ${event.toolName} ${event.isError ? 'failed' : 'succeeded'}`);
});
Unique ID for this tool call
Context Hooks
context
Fired before each LLM call. Can modify messages sent to the model.
pi.on('context', async (event, ctx) => {
// Add a system reminder before every LLM call
return {
messages: [
...event.messages,
{
role: 'user',
content: [{ type: 'text', text: 'Remember to cite sources.' }],
},
],
};
});
Messages about to be sent to the LLM
Returns: { messages?: AgentMessage[] }
Fired when user input is received, before agent processing. Can transform or handle input.
pi.on('input', async (event, ctx) => {
// Transform input
if (event.text.startsWith('!!')) {
return {
action: 'transform',
text: event.text.slice(2).toUpperCase(),
};
}
// Handle completely (don't pass to agent)
if (event.text === '/quit') {
ctx.shutdown();
return { action: 'handled' };
}
// Continue normally
return { action: 'continue' };
});
event.images
ImageContent[] | undefined
Attached images
event.source
'interactive' | 'rpc' | 'extension'
Where the input came from
Returns: { action: 'continue' } | { action: 'transform'; text: string; images?: ImageContent[] } | { action: 'handled' }
user_bash
Fired when user executes a bash command via ! or !! prefix.
pi.on('user_bash', async (event, ctx) => {
console.log(`User bash: ${event.command}`);
console.log(`Exclude from context: ${event.excludeFromContext}`);
});
True if !! prefix was used (excluded from LLM context)
Current working directory
Returns: { operations?: BashOperations; result?: BashResult }
Model Hooks
model_select
Fired when a new model is selected.
pi.on('model_select', async (event, ctx) => {
console.log(`Model changed: ${event.previousModel?.id} → ${event.model.id}`);
console.log(`Source: ${event.source}`);
});
event.source
'set' | 'cycle' | 'restore'
How the model was selected
Resource Hooks
resources_discover
Fired after session_start to allow extensions to provide additional resource paths.
pi.on('resources_discover', async (event, ctx) => {
return {
skillPaths: ['/path/to/custom-skill.md'],
promptPaths: ['/path/to/custom-prompt.md'],
themePaths: ['/path/to/custom-theme.json'],
};
});
Current working directory
Why resources are being discovered
Returns: { skillPaths?: string[]; promptPaths?: string[]; themePaths?: string[] }
Hook Patterns
Stateful Extensions
Use pi.appendEntry() to persist state across sessions:
interface MyState {
counter: number;
}
let counter = 0;
pi.on('session_start', async (event, ctx) => {
// Restore state from session
const entries = ctx.sessionManager.getBranch();
for (const entry of entries) {
if (entry.type === 'custom' && entry.customType === 'my-extension-state') {
const data = entry.data as MyState;
counter = data.counter;
}
}
});
pi.on('agent_end', async (event, ctx) => {
counter++;
pi.appendEntry<MyState>('my-extension-state', { counter });
});
Use tool_call to implement safety checks:
pi.on('tool_call', async (event, ctx) => {
if (event.toolName === 'bash') {
const dangerous = ['rm -rf /', 'dd if=', 'mkfs'];
const input = event.input as { command: string };
if (dangerous.some(cmd => input.command.includes(cmd))) {
const confirm = await ctx.ui.confirm(
'Dangerous Command',
`Execute: ${input.command}?`
);
if (!confirm) {
return { block: true, reason: 'User cancelled' };
}
}
}
});
Logging Extension
Log all agent activity:
import { writeFileSync, appendFileSync } from 'fs';
const logFile = '/tmp/pi-agent.log';
pi.on('session_start', () => {
writeFileSync(logFile, `Session started: ${new Date().toISOString()}\n`);
});
pi.on('tool_call', async (event) => {
appendFileSync(logFile, `[${event.toolName}] ${JSON.stringify(event.input)}\n`);
});
pi.on('agent_end', async (event) => {
appendFileSync(logFile, `Turn complete: ${event.messages.length} messages\n`);
});
Auto-commit on Exit
Automatically commit changes when exiting:
pi.on('session_shutdown', async (event, ctx) => {
const { stdout } = await ctx.exec('git', ['status', '--porcelain']);
if (stdout.trim()) {
await ctx.exec('git', ['add', '-A']);
await ctx.exec('git', ['commit', '-m', 'Auto-commit on pi exit']);
console.log('Changes committed');
}
});