How to Create Custom Tools for earendil π Agents: A Complete Guide

earendil π agents expose a unified extensions system that lets you add custom tools written in TypeScript by placing an index.ts file in ~/.pi/agent/tools/ and registering them via pi.registerTool().

The earendil-works/pi repository provides a powerful framework for extending agent capabilities beyond built-in utilities like read and bash. Custom tools allow you to define domain-specific logic, maintain state across conversation turns, and leverage π’s interactive UI helpers to create sophisticated agent workflows.

Extension Discovery and Loading

When π starts, it automatically discovers custom tools by scanning the ~/.pi/agent/tools/ directory (or any additional paths configured in settings.json).

The extension loader, located at packages/coding-agent/src/core/extensions/index.ts, treats every sub-directory containing an index.ts file as a custom-tool module. This module receives a pi API object upon initialization, which you use to register your tool definitions.

The Tool Registration API

Inside your tool module, you register functionality by calling pi.registerTool(name, definition). The definition object adheres to the ToolDefinition interface and requires three key properties:

  • description — Human-readable text describing the tool’s purpose to the LLM.
  • inputSchema (optional) — A JSON Schema object that validates tool arguments before execution.
  • run(ctx, args) — An async function that implements the tool logic.

The run function receives two arguments: the agent context (ctx) and the parsed arguments (args). According to the source code in packages/coding-agent/src/core/agent-session.ts, this context is the same object passed to all extension event handlers, giving your tool access to the full agent environment.

The Context Object and Execution Flow

The ctx object provides essential capabilities for custom tool implementation:

  • Model Registry — Access LLM configurations via ctx.modelRegistry.
  • UI Helpers — Interact with users through ctx.ui.confirm(), ctx.ui.select(), ctx.ui.input(), and ctx.ui.notify() (documented in packages/coding-agent/docs/tui.md).
  • Session State — Persist data across turns using ctx.sessionState, which maintains state for the duration of the agent session.

When the LLM invokes your tool, π validates arguments against inputSchema if provided, executes the run function, and returns the result. You can stop further tool calls for the current turn by returning { result, terminate: true }, a feature noted in packages/coding-agent/CHANGELOG.md.

System Prompt Integration

To make your tool visible to the LLM, export a promptSnippet string from your module. π injects this text into the "Available tools" section of the system prompt.

If you omit promptSnippet, the tool remains callable but hidden from the default prompt—a useful pattern for internal utilities or programmatic tools that should only be invoked explicitly.

Step-by-Step Implementation

1. Create the Tool Directory

Create a new folder under ~/.pi/agent/tools/, for example markdown-summary/, and add an index.ts entry file.

2. Define the Tool Interface

Import the registration types and define your tool's metadata. The example below follows the pattern shown in packages/coding-agent/examples/extensions/hello.ts:

// ~/.pi/agent/tools/markdown-summary/index.ts
import { ToolDefinition, ToolResult } from '@mariozechner/pi-coding-agent';

export const promptSnippet = '- `markdown_summary` – produce a concise summary of a markdown file.';

export const definition: ToolDefinition = {
  description: 'Summarize a markdown document.',
  inputSchema: {
    type: 'object',
    properties: {
      filePath: { 
        type: 'string', 
        description: 'Absolute path to the markdown file' 
      },
    },
    required: ['filePath'],
  },

  async run(ctx, { filePath }) {
    const ok = await ctx.ui.confirm({
      message: `Summarize ${filePath}?`,
      default: true,
    });
    
    if (!ok) return { result: 'Cancelled by user' };

    const content = await ctx.fs.readFile(filePath, 'utf-8');
    const lines = content.split('\n').filter(l => l.trim() !== '');
    const summary = lines.slice(0, 5).join('\n');
    
    ctx.sessionState.lastSummarised = filePath;
    
    return { result: summary };
  },
};

export default (pi) => {
  pi.registerTool('markdown_summary', definition);
};

3. Register via the Default Export

The module must export a default function that accepts the pi object and calls registerTool(). This registration hook is invoked by the loader in packages/coding-agent/src/core/extensions/index.ts during agent startup.

4. Invoke the Tool

Once registered, invoke the tool via CLI flags (documented in packages/coding-agent/docs/usage.md):

pi -t markdown_summary "Summarize the README"

Or reference it by name within prompts:


Please use markdown_summary on ./docs/architecture.md

Key Source Files Reference

Understanding the following files helps when debugging or extending tool behavior:

Summary

  • Custom tools reside in ~/.pi/agent/tools/ with an index.ts entry point.
  • Register tools using pi.registerTool(name, definition) with a description, optional inputSchema, and run function.
  • Access UI helpers, model registry, and session state through the ctx object passed to run().
  • Export a promptSnippet to expose the tool in the system prompt; omit it for hidden tools.
  • Return { result, terminate: true } to prevent additional tool calls in the current turn.

Frequently Asked Questions

Where should I place files for custom tools in earendil π?

Place each custom tool in its own sub-directory under ~/.pi/agent/tools/. The directory name becomes your tool's namespace, and the index.ts file inside serves as the entry point that the loader in packages/coding-agent/src/core/extensions/index.ts automatically discovers on startup.

How can custom tools persist data across conversation turns?

Use ctx.sessionState, a session-wide storage object provided to the run() function. According to the implementation in packages/coding-agent/src/core/agent-session.ts, any data attached to this object remains available for the duration of the agent session, allowing you to track context or cache results between LLM turns.

Can I validate arguments before my custom tool executes?

Yes. Provide an inputSchema object in your ToolDefinition following JSON Schema syntax. The pi agent validates incoming arguments against this schema before invoking your run() function, preventing malformed data from reaching your implementation logic.

How do I make a custom tool available only programmatically but not to the LLM directly?

Omit the promptSnippet export from your tool module. As documented in packages/coding-agent/docs/extensions.md, tools without a prompt snippet remain callable via the SDK or internal hooks but are excluded from the system prompt's "Available tools" list, effectively hiding them from the LLM's default tool selection.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →