Third-Party Plugin Development Guide
Third-Party Plugin Development Guide
Section titled “Third-Party Plugin Development Guide”This guide explains how to develop and build third-party plugins (TypeScript, dist/, examples). How to install and wire a plugin in production (npm, private registry, /app/plugins mounts) is in extending-with-plugins.md — read that first if you only need deployment steps.
Table of Contents
Section titled “Table of Contents”- Overview
- Plugin Package Structure
- Plugin Implementation
- Building Your Plugin
- Distributing Your Plugin
- Installing Third-Party Plugins
Overview
Section titled “Overview”Third-party plugins extend agent-detective’s capabilities. They can be published to npm (or a private registry), vendored as a path on disk, or added to a fork under packages/* (see extending-with-plugins.md for how the runtime resolves each case).
Plugin Package Structure
Section titled “Plugin Package Structure”my-plugin/├── package.json # Package metadata├── tsconfig.json # TypeScript config├── tsconfig.build.json # Build-specific config├── src/│ └── index.ts # Plugin source├── dist/│ ├── index.js # Compiled JavaScript│ └── index.d.ts # TypeScript declarations└── README.md # Installation instructionspackage.json
Section titled “package.json”{ "name": "@myorg/agent-detective-my-plugin", "version": "1.0.0", "description": "My custom plugin for agent-detective", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "scripts": { "build": "tsc -p tsconfig.build.json", "dev": "tsc -p tsconfig.json --watch" }, "keywords": ["agent-detective", "plugin"], "peerDependencies": { "@agent-detective/sdk": "^1.0.0", "zod": "^4.0.0" }, "devDependencies": { "@agent-detective/sdk": "^1.0.0", "zod": "^4.0.0", "typescript": "^5.7.0" }}tsconfig.build.json
Section titled “tsconfig.build.json”{ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"]}tsconfig.json
Section titled “tsconfig.json”{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "declaration": true, "outDir": "./dist" }, "include": ["src/**/*"]}Note: No decorator flags are required. Routes are described with Zod schemas via
defineRoute()(see API Documentation (OpenAPI) below); the same schemas drive runtime validation and the OpenAPI spec at/docs.
Monorepo-only: use "@agent-detective/sdk": "workspace:*"; published plugins should use a semver range and depend on the npm release of @agent-detective/sdk. Plugins should not depend on @agent-detective/types directly — it’s a host-internal, type-only contract package, and every plugin-facing type is re-exported through @agent-detective/sdk.
Plugin Implementation
Section titled “Plugin Implementation”Basic Plugin Structure
Section titled “Basic Plugin Structure”import { definePlugin, defineRoute, registerRoutes, type PluginContext,} from '@agent-detective/sdk';import { z } from 'zod';
const WebhookBody = z.object({ event: z.string() });const WebhookResponse = z.object({ status: z.literal('received') });
export default definePlugin({ name: '@myorg/agent-detective-my-plugin', version: '1.0.0', schemaVersion: '1.0',
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, someOption: { type: 'string', default: 'default' }, }, required: [] },
register(scope, context: PluginContext) { const { config, logger } = context;
if (!config.enabled) { logger.info('Plugin is disabled'); return; }
registerRoutes(scope, [ defineRoute({ method: 'POST', url: '/webhook', schema: { tags: ['@myorg/agent-detective-my-plugin'], body: WebhookBody, response: { 200: WebhookResponse }, }, handler: async () => ({ status: 'received' as const }), }), ]);
logger.info('My plugin registered successfully'); }});
scopeis a Fastify instance already encapsulated under/plugins/agent-detective-my-plugin. The route above mounts atPOST /plugins/agent-detective-my-plugin/webhookautomatically — do not hard-code the prefix.
PluginContext Members Available
Section titled “PluginContext Members Available”| Member | Type | Description |
|---|---|---|
agentRunner | AgentRunner | Execute AI agent prompts |
registerService<T>(name, service) | function | Register a service for other plugins to consume |
getService<T>(name) | function | Get a registered service by name with type safety |
getServiceFromPlugin<T>(name, providerPluginName) | function | Get a service from a specific provider plugin |
registerCapability(name) | function | Register a capability provided by this plugin |
hasCapability(name) | function | Check if a capability is registered |
config | object | Validated plugin configuration |
logger | Logger | Structured logging |
enqueue | function | Queue tasks for sequential execution |
Capabilities vs services vs dependencies
Section titled “Capabilities vs services vs dependencies”- Use services (
registerService/getService) for concrete APIs shared across plugins.\n- UsedependsOnwhen you require a specific plugin’s side-effects (typically a service registration) before your plugin runs.\n- Use capabilities (registerCapability,requiresCapabilities) for broad feature-gating where the specific provider plugin is not important.\n- Prefer SDK constants (StandardCapabilities.*from@agent-detective/sdk) rather than inventing new strings.\n\nWhen multiple plugins provide the same capability-backed service,getService(...)selects a default provider by preferring first-party plugins (@agent-detective/*), otherwise using theconfig.plugins[]order as a stable tie-break. UsegetServiceFromPlugin(...)if you need a specific provider.\n
Building Your Plugin
Section titled “Building Your Plugin”1. Create the plugin project
Section titled “1. Create the plugin project”mkdir my-plugin && cd my-pluginpnpm init2. Install dependencies
Section titled “2. Install dependencies”pnpm add @agent-detective/sdk zodpnpm add -D typescript tsx3. Build
Section titled “3. Build”pnpm run build4. Output
Section titled “4. Output”After building, dist/ contains:
dist/├── index.js # ES module bundle└── index.d.ts # Type declarationsDistributing Your Plugin
Section titled “Distributing Your Plugin”Option A: npm Registry (Recommended for Public Plugins)
Section titled “Option A: npm Registry (Recommended for Public Plugins)”# Buildpnpm run build
# Publish to npmnpm publish --access publicUsers can then install it via:
npm install @myorg/agent-detective-my-pluginOption B: GitHub Release
Section titled “Option B: GitHub Release”# Create a release on GitHubgit tag v1.0.0git push origin v1.0.0
# Users download and extract the dist/ folderOption C: Private Distribution
Section titled “Option C: Private Distribution”Distribute the dist/ folder directly within your organization:
# Copy dist/ to a shared locationscp -r dist/ user@server:/path/to/plugins/my-plugin/Installing Third-Party Plugins (runtime)
Section titled “Installing Third-Party Plugins (runtime)”See extending-with-plugins.md for:
packagespecifiers (npm, path, monorepopackages/*)dependsOnand load order- private registry /
.npmrc - Path-based
plugins/directory and absolutepackagepaths in config
The sections above (Distributing) describe how to publish or copy artifacts; the extending guide ties that to a running server.
Example: Complete Jira-Style Plugin
Section titled “Example: Complete Jira-Style Plugin”Project Structure
Section titled “Project Structure”my-jira-plugin/├── package.json├── tsconfig.json├── tsconfig.build.json├── src/│ └── index.ts├── dist/│ ├── index.js│ └── index.d.ts└── README.mdpackage.json
Section titled “package.json”{ "name": "@myorg/agent-detective-jira-plus", "version": "1.0.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "scripts": { "build": "tsc -p tsconfig.build.json" }, "peerDependencies": { "@agent-detective/sdk": "^1.0.0", "zod": "^4.0.0" }}src/index.ts
Section titled “src/index.ts”import { defineRoute, registerRoutes, type Plugin, type PluginContext, type TaskEvent,} from '@agent-detective/sdk';import { z } from 'zod';
const PLUGIN_TAG = '@myorg/agent-detective-jira-plus';
const WebhookBody = z.object({ webhookEvent: z.string(), issue: z.object({ key: z.string() }).loose(),}).loose();
const WebhookResponse = z.object({ status: z.literal('queued'), taskId: z.string(),});
const jiraPlusPlugin: Plugin = { name: PLUGIN_TAG, version: '1.0.0', schemaVersion: '1.0',
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, baseUrl: { type: 'string', default: '' }, email: { type: 'string', default: '' }, apiToken: { type: 'string', default: '' }, priorityMapping: { type: 'object', default: { 'Critical': 1, 'Major': 2, 'Minor': 3 } } }, required: [] },
register(scope, context: PluginContext) { const { config, agentRunner, logger, getService } = context;
if (!config.enabled) { logger.info('Jira Plus plugin is disabled'); return; }
const localRepos = getService<{ getRepo(name: string): { path: string } | undefined }>('localRepos');
registerRoutes(scope, [ defineRoute({ method: 'POST', url: '/webhook', schema: { tags: [PLUGIN_TAG], summary: 'Receive a Jira webhook', body: WebhookBody, response: { 200: WebhookResponse }, }, handler: async (req) => { const taskEvent = normalizePayload(req.body);
const repo = localRepos?.getRepo(taskEvent.metadata.repoName as string); if (repo) { taskEvent.context.repoPath = repo.path; }
logger.info(`Processing: ${taskEvent.id}`);
// Process with agentRunner / enqueue... return { status: 'queued' as const, taskId: taskEvent.id }; }, }), ]);
logger.info('Jira Plus plugin registered'); }};
export default jiraPlusPlugin;Zod 4: On z.object(), .passthrough() is deprecated. Use .loose() when extra keys should still pass validation and serialization (common for webhook bodies and evolving API shapes).
API Documentation (OpenAPI)
Section titled “API Documentation (OpenAPI)”Plugins expose HTTP endpoints by defining Zod-typed routes with defineRoute() and mounting them on the Fastify scope passed into register(). The same Zod schemas drive runtime validation and the OpenAPI spec rendered at /docs, so there is no separate “documentation step”.
Adding routes with defineRoute
Section titled “Adding routes with defineRoute”First, add @agent-detective/sdk as a dependency:
{ "dependencies": { "@agent-detective/sdk": "workspace:*" }}Then declare your routes with Zod schemas:
import { defineRoute, registerRoutes, type FastifyScope } from '@agent-detective/sdk';import { z } from 'zod';import type { MyService } from './my-service.js';
const PLUGIN_TAG = '@myorg/my-plugin';
const StatusResponse = z.object({ status: z.literal('ok'), plugin: z.literal('my-plugin'),});
const WebhookBody = z.object({ event: z.string(), data: z.record(z.string(), z.unknown()).optional(),});
const WebhookResponse = z.object({ status: z.literal('received'), taskId: z.string().optional(),});
const ErrorResponse = z.object({ error: z.string() });
export function buildMyRoutes(_service: MyService) { const getStatus = defineRoute({ method: 'GET', url: '/status', schema: { tags: [PLUGIN_TAG], summary: 'Get status', description: 'Returns current plugin status', response: { 200: StatusResponse }, }, handler: () => ({ status: 'ok' as const, plugin: 'my-plugin' as const }), });
const handleWebhook = defineRoute({ method: 'POST', url: '/webhook', schema: { tags: [PLUGIN_TAG], summary: 'Handle webhook', description: 'Receives events from external systems', body: WebhookBody, response: { 200: WebhookResponse, 400: ErrorResponse }, }, handler: () => ({ status: 'received' as const }), });
return [getStatus, handleWebhook];}
export function registerMyRoutes(scope: FastifyScope, service: MyService) { registerRoutes(scope, buildMyRoutes(service));}Registering routes from the plugin
Section titled “Registering routes from the plugin”scope is a Fastify instance encapsulated under /plugins/{sanitized-name}; routes mount at that prefix automatically.
import type { Plugin } from '@agent-detective/sdk';import { registerMyRoutes } from './my-routes.js';
const myPlugin: Plugin = { name: '@myorg/my-plugin', version: '1.0.0', schemaVersion: '1.0',
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, }, },
register(scope, context) { const { logger } = context; const myService = new MyService(); registerMyRoutes(scope, myService); logger.info('My plugin registered'); },};
export default myPlugin;RouteSchema reference
Section titled “RouteSchema reference”| Field | Type | Description |
|---|---|---|
body | z.ZodType | Validates request.body; rejects with 400 when invalid |
querystring | z.ZodType | Validates request.query |
params | z.ZodType | Validates URL params |
headers | z.ZodType | Validates request headers |
response | Record<number, z.ZodType> | Per-status response schemas; used for serialization (drops unknown fields) and OpenAPI |
tags | string[] | Groups the route under tags in /docs |
summary / description | string | Surfaced in OpenAPI |
operationId | string | Stable id for the operation |
deprecated | boolean | Marks the operation deprecated |
security | Record<string, string[]>[] | Security requirements |
Server-Sent Events
Section titled “Server-Sent Events”For SSE handlers, call reply.hijack() then write to reply.raw:
defineRoute({ method: 'GET', url: '/events', schema: { tags: [PLUGIN_TAG], summary: 'Stream events' }, handler(_req, reply) { reply.hijack(); reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); reply.raw.write(`data: ${JSON.stringify({ hello: 'world' })}\n\n`); },});Accessing API Documentation
Section titled “Accessing API Documentation”- Without auth: Visit
/docsdirectly - With auth: Set
X-API-KEYheader or configureDOCS_AUTH_REQUIRED=trueandDOCS_API_KEY
Environment Variables for Docs
Section titled “Environment Variables for Docs”| Variable | Description |
|---|---|
DOCS_AUTH_REQUIRED=true | Require API key to access docs |
DOCS_API_KEY=<key> | The API key to use for authentication |
Or via config:
{ "docsAuthRequired": true, "docsApiKey": "your-secret-key"}Best Practices
Section titled “Best Practices”- Follow semver - Use meaningful version numbers
- Document configuration - Clear schema with defaults
- Handle errors gracefully - Don’t crash the host app
- Use logging - Help users debug issues
- Support hot reload - Design for development ease
- Test thoroughly - Mock external dependencies
Troubleshooting
Section titled “Troubleshooting”Plugin Not Loading
Section titled “Plugin Not Loading”- Check the plugin directory structure is correct
- Verify
index.jsis in the right place (not indist/) - Ensure
package.jsonnamematches directory name - Check logs for schema validation errors
Type Errors
Section titled “Type Errors”- Ensure
@agent-detective/sdkversion is compatible - Run
pnpm run buildto generate.d.tsfiles - Use
import typefor type-only imports
Container Won’t Start
Section titled “Container Won’t Start”- Verify volume mount path is correct
- Ensure plugin files are readable
- Check config syntax in
default.json