How to Create Extensions for Earendil Pi Agents: A Complete Developer’s Guide
To create an extension for Earendil π agents, export a default factory function that receives an ExtensionRuntime and returns an object implementing the Extension interface with metadata, commands, and optional UI components, then load it via the --extension CLI flag.
The earendil-works/pi repository provides a modular plug-in system that allows developers to add new slash-commands, UI widgets, and runtime behaviors to the built-in coding agent. Understanding how to create extensions for Earendil pi agents requires familiarity with the core interfaces defined in packages/coding-agent/src/core/extensions/types.ts and the loading mechanism orchestrated by loader.ts.
Extension Architecture Overview
The extension system is built around six core components that handle discovery, instantiation, and integration:
- Extension Interface – Defines the contract for metadata (
name,description,version), command handlers, and UI hooks. Defined in [packages/coding-agent/src/core/extensions/types.ts](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/types.ts). - ExtensionRuntime – Supplies services including the event bus (
runtime.eventBus), logger (runtime.log), and UI factory methods likeruntime.createDialog. Also defined intypes.ts. - Loader – Discovers and instantiates extensions via
loadExtensionsandloadExtensionFromFactoryin [packages/coding-agent/src/core/extensions/loader.ts](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/extensions/loader.ts). - ResourceLoader – Integrates extensions into the agent’s resource graph by calling
loadExtensionFactoriesfrom [packages/coding-agent/src/core/resource-loader.ts](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/resource-loader.ts). - Extension Bindings – Exposes registered commands as slash-commands (
/command-name) and manages event emission throughExtensionBindingsin [packages/coding-agent/src/core/agent-session.ts](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/agent-session.ts). - UI Layer – Renders optional components (status bars, dialogs) based on
ExtensionUIDialogOptionsandExtensionWidgetOptionstypes.
When the agent starts, ResourceLoader invokes loadExtensions with paths provided via the --extension flag. Each path is resolved as an ES module, imported dynamically, and the default export (a factory function) is called with a fresh ExtensionRuntime instance.
Creating Your First Extension
Every extension follows a three-step pattern: export a factory, define metadata and commands, and optionally provide UI components.
Minimal "Hello World" Extension
Create a new file (for example, packages/coding-agent/examples/extensions/hello.ts) and export an async factory that returns an Extension object:
// packages/coding-agent/examples/extensions/hello.ts
export default async function helloExtension(runtime: ExtensionRuntime) {
return {
name: "hello",
description: "Demo extension that greets the user",
version: "0.1.0",
commands: [
{
name: "greet",
description: "Say hello",
handler: async (ctx: ExtensionCommandContext) => {
ctx.api.sendMessage("👋 Hello from the hello extension!");
},
},
],
};
}
Key implementation details:
- The factory receives
runtimecontaining utilities likeruntime.logandruntime.eventBus. - The
commandsarray defines slash-commands accessible via/greetin the interactive UI. - Each command handler receives an
ExtensionCommandContext(ctx) providing access to the agent’s messaging API.
Adding UI Components
Extensions can render status-line indicators or dialogs by returning a ui object. The following example creates a dynamic status widget:
// packages/coding-agent/examples/extensions/status-line.ts
export default async function statusLineExtension(runtime: ExtensionRuntime) {
const widget = runtime.createStatusLine({
render: (state) => (state.isThinking ? "⏳" : "✅"),
});
return {
name: "status-line",
description: "Shows a tiny status indicator",
version: "0.1.0",
ui: {
statusLine: widget,
},
};
}
The ui field accepts any combination of statusLine, dialog, or widget options as defined in types.ts. These components are rendered by the agent’s TUI layer without requiring manual DOM manipulation.
Loading Extensions at Runtime
Pass the file path to your extension factory using the --extension flag when launching π:
pi --extension ./packages/coding-agent/examples/extensions/hello.ts
To load multiple extensions simultaneously, repeat the flag:
pi \
--extension ./packages/coding-agent/examples/extensions/hello.ts \
--extension ./packages/coding-agent/examples/extensions/status-line.ts
Under the hood, loadExtensions performs the following operations:
- Resolves each path relative to the current working directory.
- Imports the module using
await import(extensionPath). - Invokes the default export (the factory) with a newly created
ExtensionRuntime. - Registers commands with
ExtensionBindingsand UI components with the session renderer.
Testing Extensions
The repository provides a test harness to validate extensions without running a full agent session. Import createTestExtensionsResult from loader.ts to instantiate extensions in a controlled environment:
import { createTestExtensionsResult } from "../src/core/extensions/loader.ts";
import { createTestResourceLoader } from "../src/core/resource-loader.ts";
const extensionsResult = await createTestExtensionsResult(
[helloExtensionFactory, statusLineExtensionFactory],
tempDir
);
const resourceLoader = createTestResourceLoader({ extensionsResult });
await resourceLoader.load(); // Installs extensions into the test agent
Execute specific test suites using Vitest:
node ../../node_modules/vitest/dist/cli.js --run packages/coding-agent/test/extensions/hello.test.ts
Publishing and Distribution
To distribute an extension as a standalone package, structure your project as an ES module that depends on @earendil/pi (the core agent types). Expose the factory function as the default export from your package entry point. Consumers can then reference the compiled JavaScript file directly:
pi --extension ./node_modules/my-extension/dist/index.js
Ensure your package.json specifies "type": "module" and lists @earendil/pi as a peer dependency to avoid version conflicts with the host agent.
Summary
- Export a factory: Extensions must export a default async function receiving
ExtensionRuntimeand returning anExtensionobject. - Define commands: Register interactive commands in the
commandsarray; these become accessible as slash-commands (/command-name). - Optional UI: Inject status bars or dialogs via the
uifield using runtime factory methods. - Load via CLI: Use the
--extensionflag to specify paths to extension files or compiled modules. - Test isolated: Use
createTestExtensionsResultandcreateTestResourceLoaderto unit-test extension logic without booting the full agent.
Frequently Asked Questions
What is the minimum required structure for an Earendil Pi extension file?
At minimum, the file must export a default async factory function that accepts an ExtensionRuntime parameter and returns an object containing name, description, version, and optionally commands or ui properties. The factory can be defined in TypeScript or JavaScript as long as it conforms to the Extension interface defined in packages/coding-agent/src/core/extensions/types.ts.
Can extensions access the agent’s LLM or tool-calling capabilities?
Yes. The ExtensionCommandContext passed to command handlers includes ctx.api, which provides methods to send messages and interact with the agent’s core systems. For deeper integration, extensions can listen to the runtime.eventBus to react to agent lifecycle events or emit custom events that other extensions can consume.
How do I debug an extension during development?
Run the agent with the --extension flag pointing to your source file and enable verbose logging. The runtime.log object provided to your factory supports standard log levels (debug, info, error). Additionally, you can write unit tests using createTestExtensionsResult from packages/coding-agent/src/core/extensions/loader.ts to verify command handlers and UI state outside the interactive TUI.
Are extensions hot-reloadable without restarting the agent?
No. According to the current implementation in loader.ts, extensions are discovered and instantiated once during the agent’s initialization phase when ResourceLoader calls loadExtensions. To apply changes, you must restart the π process with the --extension flag.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →