Skip to main content

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');
});
event.type
'session_start'
Event type

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 };
  }
});
event.type
'session_before_switch'
Event type
event.reason
'new' | 'resume'
Why the switch is happening
event.targetSessionFile
string | undefined
Target session file path
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.type
'session_switch'
Event type
event.reason
'new' | 'resume'
Why the switch happened
event.previousSessionFile
string | undefined
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');
});
event.type
'session_shutdown'
Event type

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.type
'before_agent_start'
Event type
event.prompt
string
User’s submitted prompt
event.images
ImageContent[] | undefined
Attached images, if any
event.systemPrompt
string
Current system prompt
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`);
});
event.type
'agent_end'
Event type
event.messages
AgentMessage[]
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}`);
});
event.turnIndex
number
Zero-based turn index
event.timestamp
number
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`);
});
event.turnIndex
number
Zero-based turn index
event.message
AgentMessage
The assistant message from this turn
event.toolResults
ToolResultMessage[]
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}`);
});
event.message
AgentMessage
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);
  }
});
event.message
AgentMessage
The message being updated
event.assistantMessageEvent
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');
});
event.message
AgentMessage
The completed message

Tool Hooks

tool_call

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',
    };
  }
});
event.type
'tool_call'
Event type
event.toolCallId
string
Unique ID for this tool call
event.toolName
string
Name of the tool being called
event.input
Record<string, unknown>
Tool input parameters
Returns: { block?: boolean; reason?: string }

tool_result

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]' },
      ],
    };
  }
});
event.type
'tool_result'
Event type
event.toolCallId
string
Unique ID for this tool call
event.toolName
string
Name of the tool
event.input
Record<string, unknown>
Tool input parameters
event.content
(TextContent | ImageContent)[]
Tool result content
event.details
unknown
Tool-specific details
event.isError
boolean
Whether the tool execution failed
Returns: { content?: Content[]; details?: unknown; isError?: boolean }

tool_execution_start

Fired when a tool starts executing.
pi.on('tool_execution_start', async (event, ctx) => {
  console.log(`Tool ${event.toolName} starting`);
});
event.toolCallId
string
Unique ID for this tool call
event.toolName
string
Name of the tool
event.args
any
Tool arguments

tool_execution_update

Fired during tool execution with partial/streaming output.
pi.on('tool_execution_update', async (event, ctx) => {
  console.log(`Tool ${event.toolName} update:`, event.partialResult);
});
event.toolCallId
string
Unique ID for this tool call
event.toolName
string
Name of the tool
event.args
any
Tool arguments
event.partialResult
any
Partial result from the tool

tool_execution_end

Fired when a tool finishes executing.
pi.on('tool_execution_end', async (event, ctx) => {
  console.log(`Tool ${event.toolName} ${event.isError ? 'failed' : 'succeeded'}`);
});
event.toolCallId
string
Unique ID for this tool call
event.toolName
string
Name of the tool
event.result
any
Final result
event.isError
boolean
Whether execution failed

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.' }],
      },
    ],
  };
});
event.type
'context'
Event type
event.messages
AgentMessage[]
Messages about to be sent to the LLM
Returns: { messages?: AgentMessage[] }

Input Hooks

input

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.type
'input'
Event type
event.text
string
Input text
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}`);
});
event.type
'user_bash'
Event type
event.command
string
Command to execute
event.excludeFromContext
boolean
True if !! prefix was used (excluded from LLM context)
event.cwd
string
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.type
'model_select'
Event type
event.model
Model<any>
New model
event.previousModel
Model<any> | undefined
Previous model
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'],
  };
});
event.type
'resources_discover'
Event type
event.cwd
string
Current working directory
event.reason
'startup' | 'reload'
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 });
});

Blocking Tool Execution

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');
  }
});

Build docs developers (and LLMs) love