- Added canonical `pi.zod` schema API exports and removed TypeBox package exports/imports. - Migrated Tool schema typing from TypeBox to shared `TSchema`/Zod flow with legacy TypeBox compatibility. - Updated AI provider adapters and MCP/agent builders to convert tool params through `toolWireSchema()`. - Reworked schema validation from AJV to Zod-safe parsing with `fromTypeBox`, `toolWireSchema`, and meta schema checks.
7.9 KiB
name, description
| name | description |
|---|---|
| authoring-extensions | Use when creating a new omp extension. Covers ExtensionAPI, factory signature, tool/command/event registration, and local-dev testing. |
Authoring Extensions
Extensions are the primary way to add capabilities to oh-my-pi. A single extension module can register tools the LLM can call, slash commands users can invoke, and event handlers that run throughout the session lifecycle — all from one TypeScript file.
Minimum viable extension
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("My extension loaded!", "info");
});
}
That is a working extension. Drop it into ~/.omp/agent/extensions/hello.ts and restart omp to see the notification.
Full example
The following extension registers a slash command, a tool, and a session-start hook:
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
const z = pi.zod;
// Runs once when the session loads
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify(`Session ready in ${ctx.cwd}`, "info");
});
// Slash command: /greet
pi.registerCommand("greet", {
description: "Send a greeting into the conversation",
handler: async (args, ctx) => {
const name = args.trim() || "world";
pi.sendMessage(
{
customType: "greeting",
content: `Hello, ${name}!`,
display: true,
attribution: "user",
},
{ triggerTurn: false }
);
ctx.ui.notify(`Greeted ${name}`, "info");
},
});
// LLM-callable tool
pi.registerTool({
name: "word_count",
label: "Word Count",
description: "Count the words in a string",
parameters: z.object({
text: z.string().describe("Text to count"),
}),
async execute(_id, params, _signal, _onUpdate, _ctx) {
const count = params.text.split(/\s+/).filter(Boolean).length;
return {
content: [{ type: "text", text: String(count) }],
details: { count },
};
},
});
}
Discovery path
omp discovers extension modules in this order:
- Project-scoped auto-discovery —
<cwd>/.omp/extensions/ - User-scoped auto-discovery —
~/.omp/agent/extensions/ - Marketplace-installed plugins —
~/.omp/plugins/node_modules/(extensions shipped inside installed plugin packages) - CLI flag —
omp --extension ./my-ext.ts(also-e;--hookis treated as an alias) - Settings
extensionsarray — paths listed in~/.omp/agent/config.ymlor<cwd>/.omp/settings.json
Within each scope, de-duplication is by resolved absolute path — first seen wins.
When a path points to a directory, omp resolves the entry point in this order:
package.jsonwithomp.extensions(or legacypi.extensions) fieldindex.tsindex.js- One-level scan for
*.ts/*.jsfiles and subdirindex.*/package.jsonmanifests
package.json manifest
To package an extension as an installable plugin, add an omp field to package.json:
{
"name": "my-omp-extension",
"omp": {
"extensions": ["./src/main.ts"]
}
}
The legacy pi key is also accepted for backwards compatibility:
{
"pi": {
"extensions": ["./index.ts"]
}
}
Multiple entry points are supported:
{
"omp": {
"extensions": ["./src/safety.ts", "./src/tools.ts"]
}
}
Registering commands
pi.registerCommand("my-cmd", {
description: "What the command does",
handler: async (args, ctx) => {
// args: everything the user typed after /my-cmd
// ctx: ExtensionCommandContext — includes ctx.ui, ctx.cwd, session controls
ctx.ui.notify("Running!", "info");
await ctx.waitForIdle();
await ctx.newSession();
},
});
ExtensionCommandContext session-control methods (safe to call from commands only):
| Method | Effect |
|---|---|
waitForIdle() |
Wait for the agent to finish streaming |
newSession(opts?) |
Open a fresh session |
switchSession(path) |
Switch to an existing session file |
branch(entryId) |
Fork from a specific history entry |
navigateTree(id, opts?) |
Jump to a different point in the session tree |
reload() |
Reload the session runtime |
compact(opts?) |
Compact the current context |
Registering tools
Tools are called by the LLM. Parameters use Zod schemas, available at pi.zod:
const z = pi.zod;
pi.registerTool({
name: "search_notes", // snake_case, unique
label: "Search Notes", // human-readable label for TUI
description: "Full-text search through project notes",
parameters: z.object({
query: z.string().describe("Search query"),
limit: z.number().default(10).describe("Max results").optional(),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
onUpdate?.({ content: [{ type: "text", text: "Searching..." }] });
// ... do work ...
return {
content: [{ type: "text", text: `Found N results for "${params.query}"` }],
details: { query: params.query, count: 0 },
};
},
});
Subscribing to events
pi.on("tool_call", async (event, ctx) => {
// event.toolName, event.input, event.toolCallId
if (event.toolName !== "bash") return;
const command = String((event.input as { command?: unknown }).command ?? "");
if (command.includes("rm -rf /")) {
return { block: true, reason: "Blocked by safety policy" };
}
});
pi.on("turn_end", async (_event, ctx) => {
ctx.ui.setStatus("tokens", `~${ctx.getContextUsage()?.tokens ?? "?"} tokens`);
});
Full event catalog: see hooks authoring guide.
Extension vs hook — when to use which
| Need | Use |
|---|---|
| Tools + commands + events in one module | Extension (ExtensionAPI) |
| Pure event interception (policy, redaction) | Extension or Hook (both work; extension is preferred) |
| Legacy hook module already exists | Hook (HookAPI from @oh-my-pi/pi-coding-agent/extensibility/hooks) |
| Registering provider / custom message renderer | Extension only |
| Shipping as a marketplace plugin | Extension (use package.json manifest) |
Extensions are a strict superset of hooks. New authoring should use ExtensionAPI.
Debugging
Start omp with --log-level debug to see extension load messages:
omp --log-level debug
Watch for lines like:
[extension-loader] loading /home/you/.omp/agent/extensions/my-ext.ts
[extension-loader] loaded: my-ext (1 tool, 1 command, 2 handlers)
To temporarily disable a specific extension by name without removing the file:
# ~/.omp/agent/config.yml
disabledExtensions:
- extension-module:my-ext
The derived name is the filename stem (or directory name for index.ts-style entries): /path/to/my-ext.ts → my-ext.
Important constraints
- Do not call runtime actions during load. Methods like
pi.sendMessage()throwExtensionRuntimeNotInitializedErrorif called synchronously during module evaluation (before a session is active). Register handlers/tools/commands during load; perform runtime actions only from event handlers, tools, or commands. tool_callerrors are fail-closed. If atool_callhandler throws, the tool is blocked.- Command names must not clash with built-ins. Conflicts are skipped with a diagnostic log.
- Reserved shortcuts are ignored (
ctrl+c,ctrl+d,ctrl+z,ctrl+k,ctrl+p,ctrl+l,ctrl+o,ctrl+t,ctrl+g,shift+tab,shift+ctrl+p,alt+enter,escape,enter).
Further reading
docs/extensions.md— runtime internals and full API surface referencedocs/extension-loading.md— detailed path resolution rulesdocs/hooks.md— hook subsystem internalsdocs/skills/examples/hello-extension/— complete working example