Pi Agent Event System

The event system is the core mechanism for extensions.

By listening to lifecycle events, you can insert custom logic at various stages of the AI's work.


Lifecycle Overview

Below are all events triggered during Pi Agent's lifecycle from startup to exit:

Pi Agent 扩展事件生命周期:启动、项目信任、会话启动、turn 循环与会话关闭

Within each turn, the context, provider, and tool events fire in sequence, looping through multiple rounds when tool calls are made.

The dashed parts indicate that the stage can be triggered multiple times by multiple events.

For the trigger timing of other events such as input, before_agent_start, resources_discover, and agent_settled, see the full event quick reference below.


Core Events Explained

This section picks the 5 most frequently used events and explains their trigger timing and return value conventions.

input — Input Interception

Triggered before user input is processed; you can transform, intercept, or directly handle the input:

Example

// File path: ~/.pi/agent/extensions/quick-input.ts
// The following snippet is inside the extension factory function body
pi.on("input", async (event, ctx) => {
  // event.text - raw input text
  // event.images - attached images
  // event.source - "interactive" | "rpc" | "extension"

  // Transform input: prepend an instruction to the input
  if (event.text.startsWith("?quick ")) {
    return {
      action: "transform",
      text:`Brief reply: ${event.text.slice(7)}`
    };
  }

  // Handle directly: respond without going through the LLM
  if (event.text === "ping") {
    ctx.ui.notify("pong", "info");
    return { action: "handled" };
  }

  // Skip messages injected by the extension
  if (event.source === "extension") {
    return { action: "continue" };
  }

  return { action: "continue" };  // Default: process normally
});
action valueEffect
"continue"Does not change the input; continues the normal processing flow
"transform"Continues processing after modifying the input; transformations from multiple handlers chain together
"handled"Takes over completely, skipping the LLM call (the first handler that returns this value takes effect)

The actual effects of two typical implementations are as follows.

Input?quick hello worldwhen , the text actually sent to the LLM has been rewritten:

简短回复:hello world

Inputpingwhen , the LLM is not called; only a notification pops up in the terminal:

pong

The input event fires before Skill and template expansionbefore, so what you see is the raw input.

/skill:foo and /template are not yet expanded at this point.


tool_call — Tool Call Interception

Triggered before the tool executes; you can modify arguments or block execution:

Example

// File path: ~/.pi/agent/extensions/guard-bash.ts
// import statements go at the top of the module; the pi.on snippet below is inside the extension factory function body
// isToolCallEventType is a type guard and must be imported from the official package
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";

pi.on("tool_call", async (event, ctx) => {
  // Block dangerous bash commands
  if (isToolCallEventType("bash", event)) {
    // event.input is { command: string; timeout?: number }
    const dangerous = ["rm -rf /", "mkfs.", "dd if=", "> /dev/sda"];
    // First check whether it is dangerous, then decide whether to allow it
    if (dangerous.some(cmd =>
        event.input.command.includes(cmd))) {
      return { block: true, reason: "Dangerous command has been blocked" };
    }
    // Append environment variable loading only after the check passes
    event.input.command = `source ~/.profile\n${event.input.command}`;
  }

  // Log all file read operations
  if (isToolCallEventType("read", event)) {
    // event.input is { path: string; offset?: number; limit?: number }
    ctx.ui.notify(`Reading file: ${event.input.path}`, "info");
  }
});

If the AI attempts to runrm -rf /, the tool will not run, and the terminal will show the blocking reason:

Dangerous command has been blocked.

event.inputis mutable — modifying it directly affects tool execution.


tool_result — Modifying Tool Results

Triggered after the tool finishes executing; you can modify the result returned to the LLM:

Example

// File path: ~/.pi/agent/extensions/read-linenum.ts
// The following snippet is inside the extension factory function body
pi.on("tool_result", async (event, ctx) => {
  // event.toolName - tool name
  // event.content - content returned to the LLM
  // event.details - detail data
  // event.isError - whether it is an error

  // Example: add line numbers to read results
  if (event.toolName === "read" && !event.isError) {
    const lines = event.content
      .filter(c => c.type === "text")
      .map(c => c.text)
      .join("\n")
      .split("\n")
      .map((line, i) => `${i + 1}: ${line}`)
      .join("\n");

    return {
      content: [{ type: "text", text: lines }],
    };
  }
});

The original two-line file content becomes a line-numbered form when handed to the LLM:

1: import { readFile } from "node:fs/promises";
2: export async function load() {

context — Modifying LLM Context

Triggered before each LLM call; you can filter or modify messages:

Example

// File path: ~/.pi/agent/extensions/context-filter.ts
// The following snippet is inside the extension factory function body
pi.on("context", async (event, ctx) => {
  // event.messages - deep copy of the current context messages; safe to modify

  // Filter out messages of a specific type
  const filtered = event.messages.filter(m => {
    // Remove messages with the custom type "debug"
    if (m.type === "custom" && m.customType === "debug") {
      return false;
    }
    return true;
  });

  return { messages: filtered };
});

before_agent_start — Injecting Messages

Triggered before the AI starts processing user input; you can inject extra messages or modify the system prompt:

Example

// File path: ~/.pi/agent/extensions/context-inject.ts
// The following snippet is inside the extension factory function body
pi.on("before_agent_start", async (event, ctx) => {
  // event.prompt - the user's prompt text
  // event.systemPrompt - the currently configured system prompt

  return {
    // Inject a persistent message (stored in the session and sent to the LLM)
    message: {
      customType: "my-context",
      content:`Current time is ${new Date().toISOString()}`,
      display: true,
    },
    // Append to the system prompt
    systemPrompt: event.systemPrompt
      + "\n\nPlease use Chinese in all replies.",
  };
});

Full Event Quick Reference

Several events also appeared in the ASCII diagram in the previous section; here they are listed together in trigger order for easy reference.

EventTrigger TimingModifiableBlockable
project_trustBefore loading project resources; confirm whether to trust the current projectnoYes (project extensions are not loaded if rejected)
session_startWhen the session starts or resumesnono
resources_discoverTriggered after session_start (on startup/reload)Yes (can only append Skills, prompt templates, and theme paths; cannot add or remove extensions)no
inputBefore user input is processedYes (can transform input)Yes (handled takes over)
before_agent_startBefore the AI starts processing user inputYes (can inject messages, modify system prompt)no
agent_startWhen the agent loop startsnono
message_start / message_updateDuring message generation (message_update is the assistant's streaming delta)nono
message_endWhen user, assistant, and toolResult messages are finalizedYes (can return { message } to replace the final message; role must match)no
turn_startAt the start of each turnnono
contextBefore each LLM callYes (can filter messages)no
before_provider_headersBefore sending HTTP request headersYes (can add or modify request headers)no
before_provider_requestBefore the request body is sent to the modelYes (returning a non-undefined value replaces the entire request payload, rather than aborting the request)no
after_provider_responseAfter receiving the HTTP response, before the streaming body is consumedNo (can only read status/headers for observation)no
tool_execution_startWhen the tool starts executingnono
tool_callBefore the tool executesYes (event.input is mutable)Yes (block: true)
tool_execution_updateWhen progress is generated during tool executionnono
tool_resultAfter tool execution completesYes (return content can be modified)no
tool_execution_endWhen tool execution endsnono
turn_endAt the end of each turnnono
agent_endWhen the agent loop endsnono
agent_settledAfter a round of interaction fully settlesnono
session_before_switchBefore /new or /resume is executednoYes (return { cancel: true })
session_before_forkBefore /fork or /clone is executednoYes (return { cancel: true })
session_before_compactBefore compression executes (manual /compact, threshold, context overflow)Yes (customizable compression summary)Yes (return { cancel: true })
session_shutdownTriggered before session destruction; quit, /new, /resume, /fork, /reload all trigger it (reason is quit/reload/new/resume/fork)nono

Event Handling Key Points

When writing event handlers, the following points determine whether the code is robust.

MethodPurpose
Multiple extensions listen to the same eventExecuted in sequence according to extension load order; the previous return value affects subsequent handlers
Return{ block: true, reason: "..." }Block the operation; the reason will be shown to the user and AI
Return{ cancel: true }Cancel operations such as session switching
Usectx.signalRespond to cancel signals in asynchronous operations; abort when the user presses Escape
Usectx.uiSeries of methodsInteract with the user, such as notify, confirm, select, etc.
usectx.hasUIGuard dialog callsIn Print/JSON/RPC modes, dialog-type methods are unavailable or no-ops; check before calling
usectx.mode === "tui"Guard TUI-specific APIscustom(), component factories, terminal input, etc. are only valid in interactive mode

Do not start background resources (processes, sockets, timers, etc.) in the extension factory function.

Defer resource startup to thesession_startevent, and clean up insession_shutdown.

Other extensions