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 caseExample
Custom toolsRun project-specific commands (tests, linters, builds)
Shell wrappersExpose CLI tools to the agent: npm test, docker ps, kubectl get pods
Event hooksInject instructions before every turn, log tool calls, react to session lifecycle
Database accessLet the agent query project databases safely
AI-powered toolsGenerate commit messages, review PRs, produce documentation
UI interactionsPrompt 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

EventWhenCan modify?
resources_discoverAfter session startAdd skill/prompt/theme paths
session_startSession loaded
session_before_switchBefore switching sessionCancel
session_before_forkBefore forkingCancel
session_before_compactBefore compactionCancel/provide compaction
session_compactAfter compaction
session_shutdownExtension runtime teardown
before_agent_startAfter user prompt, before LLM callModify system prompt
agent_startAgent loop starts
agent_endAgent loop ends
turn_startEach turn starts
turn_endEach turn ends
message_startMessage begins
message_updateToken-by-token streaming
message_endMessage finalizesReplace message
tool_execution_startTool starts executing
tool_execution_updateTool sends partial result
tool_execution_endTool finishes
tool_callBefore tool executesBlock or mutate input
tool_resultAfter tool executesModify result
model_selectModel changes
user_bashUser runs ! commandOverride execution
inputUser input receivedTransform, block, or continue
contextBefore each LLM callModify messages
before_provider_requestBefore provider API callReplace payload
after_provider_responseAfter 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:

  1. $CWD/.spectral/extensions/ — project-local extensions
  2. ~/.spectral/agent/extensions/ — user-global extensions
  3. --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:

PackagePurpose
@aexol/spectralExtensionAPI type, event types, tool helpers
typeboxZero-dependency runtime type validation
All node:* built-insfs, path, child_process, os, crypto, etc.
Any npm package in node_modulesProject 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

AspectBuilt-in (src/extensions/)User (.spectral/extensions/)
Locationcli-node/src/extensions/.spectral/extensions/ in project
ImportInternal SDK path@aexol/spectral
LoadingCompiles with spectraljiti at runtime
DiscoveryHard-codedAuto-discovered from directory
Shared across projects✅ (user-global via ~/.spectral/agent/extensions/)

Next Steps