Skip to content

Adding Domains

This guide walks through creating a new PanCode domain from scratch. A domain is a composable unit of functionality with its own manifest, extension, tools, commands, and state management.

Understand the existing domain architecture:

Every domain follows this file structure:

src/domains/<name>/
manifest.ts # Name and dependency declaration
extension.ts # Pi SDK ExtensionFactory (hooks, tools, commands)
index.ts # Public API barrel export

Additional implementation files are added as needed, but these three are required.

The manifest declares the domain name and its dependencies. Create src/domains/<name>/manifest.ts:

import type { DomainManifest } from "../../core/domain-loader";
export const manifest = {
name: "<name>",
dependsOn: [],
} as const satisfies DomainManifest;
  • List only direct dependencies. Transitive dependencies are resolved automatically by the topological sort.
  • Every dependency must be another domain that exists in the registry.
  • If a dependency is not enabled, the domain loader throws a hard error at boot.
  • Circular dependencies are detected and produce a clear error message.

Example with dependencies:

export const manifest = {
name: "reporting",
dependsOn: ["dispatch", "observability"],
} as const satisfies DomainManifest;

This means reporting loads after both dispatch and observability.

The extension is a Pi SDK ExtensionFactory that registers hooks, tools, and commands. Create src/domains/<name>/extension.ts:

import { PiEvent } from "../../engine/events";
import { defineExtension } from "../../engine/extensions";
export const extension = defineExtension((pi) => {
// Session initialization
pi.on(PiEvent.SESSION_START, (_event, _ctx) => {
// Initialize domain state here
});
});
  • Import Pi SDK types and helpers only from src/engine/. Never import directly from @pancode/pi-coding-agent or other Pi SDK packages.
  • Import core infrastructure from src/core/.
  • Import from other domains only through their barrel exports (index.ts).

Available Pi SDK events (from src/engine/events.ts):

EventWhen it fires
SESSION_STARTSession initialization (register subscriptions here)
SESSION_SHUTDOWNSession teardown
BEFORE_AGENT_STARTBefore the LLM processes a user message
MESSAGE_ENDAfter the LLM finishes a response
MODEL_SELECTWhen a model is being selected
CONTEXTWhen the LLM context is being assembled
TOOL_CALLBefore a tool call executes
TOOL_EXECUTION_ENDAfter a tool call completes

Tools are callable functions that the LLM can invoke. Use TypeBox schemas for parameter validation:

import { Type } from "@sinclair/typebox";
import { ToolName } from "../../core/tool-names";
import type { AgentToolResult } from "../../engine/types";
pi.registerTool({
name: "my_tool_name",
label: "My Tool",
description: "Description the LLM reads to decide when to call this tool.",
parameters: Type.Object({
input: Type.String({ description: "The input value" }),
verbose: Type.Optional(Type.Boolean({ description: "Show detailed output" })),
}),
async execute(_id, params) {
const result = doSomething(params.input);
return {
content: [{ type: "text", text: result }],
details: undefined,
};
},
});

Add the tool name constant to src/core/tool-names.ts if it does not exist.

Commands are user-invoked slash commands:

pi.registerCommand("mycommand", {
description: "Short description shown in /help",
async handler(args, _ctx) {
// args is the string after the command name
// Use pi.sendMessage() to display output
pi.sendMessage({
customType: "panel",
content: "Command output here",
display: true,
details: { title: "My Command" },
});
},
});

For cross-domain communication, subscribe to SharedBus events in the SESSION_START handler:

import { BusChannel, type RunFinishedEvent } from "../../core/bus-events";
import { sharedBus } from "../../core/shared-bus";
pi.on(PiEvent.SESSION_START, (_event, _ctx) => {
sharedBus.on(BusChannel.RUN_FINISHED, (payload) => {
const event = payload as RunFinishedEvent;
// React to dispatch completion
});
});

If your domain owns state that other domains need to observe, define new channel constants in src/core/bus-events.ts and emit via SharedBus:

sharedBus.emit("pancode:my-event", { key: "value" });

Create src/domains/<name>/index.ts to expose the public API:

export { manifest } from "./manifest";
export { extension } from "./extension";
// Export any public functions other domains need
export { getMyState } from "./state";

Keep the barrel export minimal. Only export what other domains actually need.

Add the domain to the registry in src/domains/index.ts:

import { extension as mydomainExtension, manifest as mydomainManifest } from "./mydomain";
export const DOMAIN_REGISTRY = {
// ... existing domains ...
mydomain: { manifest: mydomainManifest, extension: mydomainExtension },
} satisfies DomainRegistry;

Add the domain name to the enabled domains list. Either:

  1. Add it to DEFAULT_ENABLED_DOMAINS in src/core/defaults.ts (for always-on domains)
  2. Let users enable it via environment variable or configuration (for optional domains like intelligence)

Run the verification gate:

Terminal window
npm run typecheck && npm run check-boundaries && npm run build && npm run lint

The boundary check verifies that your new domain does not import directly from Pi SDK packages. The typecheck verifies that your manifest, extension, and barrel export types are correct.

If your domain needs persistent state:

  1. Create a state file in .pancode/ (e.g., .pancode/mystate.json)
  2. Read on construction, write on mutation
  3. Use atomic writes via atomicWriteTextSync() from src/core/config-writer.ts
  4. Implement a ring buffer if the data can grow unbounded
import { atomicWriteTextSync } from "../../core/config-writer";
function saveState(runtimeRoot: string, state: MyState): void {
const filePath = join(runtimeRoot, "mystate.json");
atomicWriteTextSync(filePath, JSON.stringify(state, null, 2));
}

Registering Pre-Flight Checks with Dispatch

Section titled “Registering Pre-Flight Checks with Dispatch”

If your domain needs to gate dispatch admission:

import { registerPreFlightCheck } from "../dispatch";
pi.on(PiEvent.SESSION_START, (_event, _ctx) => {
registerPreFlightCheck("my-gate", (context) => {
if (shouldBlock(context)) {
return { admit: false, reason: "Explanation for why dispatch is blocked" };
}
return { admit: true };
});
});

Import through barrel exports only:

import { getBudgetTracker } from "../scheduling";
import { getSpecRegistry } from "../agents";
const budget = getBudgetTracker();
const registry = getSpecRegistry();

Never import internal files from other domains. If you need something that is not exported, ask the owning domain to add it to their barrel export.

  • manifest.ts with correct name and dependencies
  • extension.ts using defineExtension() from engine
  • index.ts barrel export
  • Domain registered in src/domains/index.ts
  • Domain enabled (defaults or config)
  • Tool names added to src/core/tool-names.ts (if registering tools)
  • Bus channels added to src/core/bus-events.ts (if emitting events)
  • npm run typecheck passes
  • npm run check-boundaries passes
  • No direct Pi SDK imports outside src/engine/