Extensions
Extensions let you extend the Spectral coding agent with custom tools, commands, event hooks, and UI components. They live in .spectral/extensions/ inside your project (or ~/.spectral/agent/extensions/ globally) and are written in TypeScript.
Why Extensions?
| Use case | Example |
|---|---|
| Custom tools | Run project-specific commands (tests, linters, builds) |
| Shell wrappers | Expose CLI tools to the agent: npm test, docker ps, kubectl get pods |
| Event hooks | Inject instructions before every turn, log tool calls, react to session lifecycle |
| Database access | Let the agent query project databases safely |
| AI-powered tools | Generate commit messages, review PRs, produce documentation |
| UI interactions | Prompt the user for input from within event handlers |
Quick Start
1. Create the extension
your-project/
└── .spectral/
└── extensions/
└── my-extension/
└── index.ts
2. Write the extension
import type { ExtensionAPI } from "@aexol/spectral";
export default function activate(pi: ExtensionAPI): void {
pi.registerTool({
name: "hello_world",
label: "Hello World",
description: "A simple tool that greets the caller.",
parameters: {
type: "object",
properties: {
name: { type: "string", description: "Name to greet." },
},
required: ["name"],
},
async execute(_toolCallId, params) {
const name = params.name as string;
return {
content: [{ type: "text", text: `Hello, ${name}! 👋` }],
details: {},
};
},
});
}
3. Restart spectral
Extensions are discovered on startup. No extra flags needed — just restart.
Extension Structure
Every extension must have a single default export — a function (sync or async) that receives the ExtensionAPI object:
export default function activate(pi: ExtensionAPI): void {
// Register tools, commands, events, etc.
}
Optionally, include a package.json with a pi manifest to declare skills, prompts, and themes:
{
"name": "my-extension-pack",
"pi": {
"extensions": ["index.ts"],
"skills": ["skills/"],
"prompts": ["prompts/"],
"themes": ["themes/"]
}
}
Tool Registration
The primary extension mechanism. Registered tools become callable by the LLM:
pi.registerTool({
name: "do_thing", // snake_case, globally unique
label: "Do Thing", // Human-readable for UI
description: "Does a thing.", // Used by LLM to decide when to call
parameters: { // JSON Schema object
type: "object",
properties: {
input: { type: "string", description: "The input to process." },
mode: { type: "string", enum: ["fast", "deep"] },
},
required: ["input"],
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// toolCallId — unique call identifier
// params — validated against your schema
// signal — AbortSignal (undefined if no stream in progress)
// onUpdate — send partial results during long operations
// ctx — ExtensionContext (cwd, sessionManager, model, etc.)
return {
content: [{ type: "text", text: "Done!" }],
details: {},
};
},
});
TypeBox Schemas (optional)
You can use the typebox package (zero deps, bundled with spectral) for richer schemas with runtime validation:
import { Type } from "typebox";
pi.registerTool({
name: "search_code",
label: "Search Code",
description: "Full-text search with filtering.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
filePattern: Type.Optional(Type.String({ description: "Glob pattern" })),
maxResults: Type.Optional(Type.Number({ default: 20, minimum: 1, maximum: 100 })),
}),
async execute(_id, params) {
// params.query, params.filePattern?, params.maxResults?
return { content: [{ type: "text", text: "..." }], details: {} };
},
});
Streaming Partial Results
For long-running operations, use onUpdate:
async execute(_id, _params, _signal, onUpdate) {
for (const step of [1, 2, 3]) {
onUpdate?.({ partial: `Step ${step}...` });
await sleep(1000);
}
return { content: [{ type: "text", text: "All steps complete." }], details: {} };
}
Return Tool-level Prompt Guidelines
Tool definitions can include promptSnippet and promptGuidelines to teach the LLM when and how to use them:
pi.registerTool({
name: "run_tests",
// ...
promptSnippet: "Run the project test suite with optional filtering.",
promptGuidelines: [
"Run tests after every code change to verify correctness.",
"Use the filter parameter to run only relevant tests when iterating quickly.",
],
});
Slash Commands
Register commands that users can invoke manually:
pi.registerCommand({
name: "my-command", // invoked as /my-command
description: "What it does.",
async execute(args: string[], ctx: ExtensionContext): Promise<void> {
process.stdout.write(`Hello! args: ${args.join(" ")}\n`);
},
});
Event Hooks
Hook into the agent lifecycle. Handlers can observe, modify, or block operations:
import type { ExtensionAPI, BeforeAgentStartEvent, BeforeAgentStartEventResult } from "@aexol/spectral";
export default function activate(pi: ExtensionAPI): void {
pi.on("before_agent_start", (event: BeforeAgentStartEvent, ctx) => {
const extraInstructions = `
## Custom Instructions
- Always use the project's linting conventions.
- Write tests for every new function.
- Commit messages must follow conventional commits.`;
const result: BeforeAgentStartEventResult = {
systemPrompt: event.systemPrompt + extraInstructions,
};
return result;
});
}
Available Events
| Event | When | Can modify? |
|---|---|---|
resources_discover | After session start | Add skill/prompt/theme paths |
session_start | Session loaded | — |
session_before_switch | Before switching session | Cancel |
session_before_fork | Before forking | Cancel |
session_before_compact | Before compaction | Cancel/provide compaction |
session_compact | After compaction | — |
session_shutdown | Extension runtime teardown | — |
before_agent_start | After user prompt, before LLM call | Modify system prompt |
agent_start | Agent loop starts | — |
agent_end | Agent loop ends | — |
turn_start | Each turn starts | — |
turn_end | Each turn ends | — |
message_start | Message begins | — |
message_update | Token-by-token streaming | — |
message_end | Message finalizes | Replace message |
tool_execution_start | Tool starts executing | — |
tool_execution_update | Tool sends partial result | — |
tool_execution_end | Tool finishes | — |
tool_call | Before tool executes | Block or mutate input |
tool_result | After tool executes | Modify result |
model_select | Model changes | — |
user_bash | User runs ! command | Override execution |
input | User input received | Transform, block, or continue |
context | Before each LLM call | Modify messages |
before_provider_request | Before provider API call | Replace payload |
after_provider_response | After provider API response | — |
Actions & UI
Send messages and interact with the session from within event handlers:
// Custom message
pi.sendMessage({ customType: "notification", content: "Something happened!" });
// Trigger a turn
pi.sendUserMessage("Run the test suite");
// Set session name
pi.setSessionName("Auth refactor session");
// Run shell commands
pi.exec("git", ["status"], { cwd: process.cwd() });
UI interactions (when available):
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(
"Which database?",
["Development", "Staging", "Production"],
);
if (choice) {
process.env.DB_TARGET = choice;
}
});
Model Control
// Get/set thinking level
const level = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high"
pi.setThinkingLevel("high");
// Manage active tools
const active = pi.getActiveTools();
pi.setActiveTools(["bash", "read", "write", "edit", "my_tool"]);
Custom Providers
Register custom AI backends:
pi.registerProvider("my-proxy", {
baseUrl: "https://proxy.example.com",
apiKey: "MY_API_KEY",
api: "anthropic-messages",
models: [{
id: "custom-model",
name: "Custom Model",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 16384,
}],
});
Discovery
Spectral looks for extensions in this order:
$CWD/.spectral/extensions/— project-local extensions~/.spectral/agent/extensions/— user-global extensions--extension <path>— explicit CLI paths
Each extension is a directory containing at minimum an index.ts file.
Available Packages
Extensions run as Node.js TypeScript modules loaded with jiti:
| Package | Purpose |
|---|---|
@aexol/spectral | ExtensionAPI type, event types, tool helpers |
typebox | Zero-dependency runtime type validation |
All node:* built-ins | fs, path, child_process, os, crypto, etc. |
Any npm package in node_modules | Project dependencies are available |
Common Patterns
Shell Command Wrapper
import type { ExtensionAPI } from "@aexol/spectral";
import { execSync } from "node:child_process";
export default function activate(pi: ExtensionAPI): void {
pi.registerTool({
name: "run_tests",
label: "Run Tests",
description: "Run the project test suite.",
parameters: {
type: "object",
properties: {
filter: { type: "string", description: "Test file pattern (optional)." },
},
},
async execute(_id, params) {
const filter = params.filter ? ` -- ${params.filter}` : "";
try {
const stdout = execSync(`npm test${filter}`, {
cwd: process.cwd(),
encoding: "utf8",
timeout: 60_000,
});
return { content: [{ type: "text", text: stdout }], details: { passed: true } };
} catch (err: any) {
const output = err.stdout || err.stderr || err.message;
return { content: [{ type: "text", text: `Tests failed:\n${output}` }], details: { passed: false } };
}
},
});
}
Database Access
import Database from "better-sqlite3";
export default function activate(pi: ExtensionAPI): void {
const db = new Database("./data.db");
pi.registerTool({
name: "db_query",
label: "DB Query",
description: "Run a read-only SQL query.",
parameters: {
type: "object",
properties: {
sql: { type: "string", description: "SELECT query." },
},
required: ["sql"],
},
async execute(_id, params) {
const rows = db.prepare(params.sql as string).all();
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
details: { rowCount: rows.length },
};
},
});
}
Auto-Research Extensions
Spectral can generate extensions automatically through auto-research — an agent that analyzes your project and creates .spectral/extensions/auto-research/ entries with project-specific knowledge (paths, schemas, workflows). Generated extensions follow the same API as hand-written ones.
Auto-research is triggered when the agent detects missing project knowledge or when dependencies change.
Built-in vs. User Extensions
| Aspect | Built-in (src/extensions/) | User (.spectral/extensions/) |
|---|---|---|
| Location | cli-node/src/extensions/ | .spectral/extensions/ in project |
| Import | Internal SDK path | @aexol/spectral |
| Loading | Compiles with spectral | jiti at runtime |
| Discovery | Hard-coded | Auto-discovered from directory |
| Shared across projects | ❌ | ✅ (user-global via ~/.spectral/agent/extensions/) |