| title | Third-Party Plugin Development Guide | ||
|---|---|---|---|
| description | Build, package, and distribute third-party plugins with TypeScript and the SDK. | ||
| sidebar |
|
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.
- Overview
- Plugin Package Structure
- Plugin Implementation
- Building Your Plugin
- Distributing Your Plugin
- Installing Third-Party Plugins
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).
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 instructions
{
"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"
}
}{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}{
"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.
// src/index.ts
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.
| 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 |
- 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
mkdir my-plugin && cd my-plugin
pnpm initpnpm add @agent-detective/sdk zod
pnpm add -D typescript tsxpnpm run buildAfter building, dist/ contains:
dist/
├── index.js # ES module bundle
└── index.d.ts # Type declarations
# Build
pnpm run build
# Publish to npm
npm publish --access publicUsers can then install it via:
npm install @myorg/agent-detective-my-plugin# Create a release on GitHub
git tag v1.0.0
git push origin v1.0.0
# Users download and extract the dist/ folderDistribute the dist/ folder directly within your organization:
# Copy dist/ to a shared location
scp -r dist/ user@server:/path/to/plugins/my-plugin/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.
my-jira-plugin/
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── src/
│ └── index.ts
├── dist/
│ ├── index.js
│ └── index.d.ts
└── README.md
{
"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"
}
}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).
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".
First, add @agent-detective/sdk as a dependency:
{
"dependencies": {
"@agent-detective/sdk": "workspace:*"
}
}Then declare your routes with Zod schemas:
// src/my-routes.ts
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));
}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;| 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 |
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`);
},
});- Without auth: Visit
/docsdirectly - With auth: Set
X-API-KEYheader or configureDOCS_AUTH_REQUIRED=trueandDOCS_API_KEY
| 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"
}- 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
- 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
- Ensure
@agent-detective/sdkversion is compatible - Run
pnpm run buildto generate.d.tsfiles - Use
import typefor type-only imports
- Verify volume mount path is correct
- Ensure plugin files are readable
- Check config syntax in
default.json