Skip to content

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.

  1. Overview
  2. Plugin Package Structure
  3. Plugin Implementation
  4. Building Your Plugin
  5. Distributing Your Plugin
  6. 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');
}
});

scope is a Fastify instance already encapsulated under /plugins/agent-detective-my-plugin. The route above mounts at POST /plugins/agent-detective-my-plugin/webhook automatically — do not hard-code the prefix.

MemberTypeDescription
agentRunnerAgentRunnerExecute AI agent prompts
registerService<T>(name, service)functionRegister a service for other plugins to consume
getService<T>(name)functionGet a registered service by name with type safety
getServiceFromPlugin<T>(name, providerPluginName)functionGet a service from a specific provider plugin
registerCapability(name)functionRegister a capability provided by this plugin
hasCapability(name)functionCheck if a capability is registered
configobjectValidated plugin configuration
loggerLoggerStructured logging
enqueuefunctionQueue tasks for sequential execution
  • Use services (registerService / getService) for concrete APIs shared across plugins.\n- Use dependsOn when 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 the config.plugins[] order as a stable tie-break. Use getServiceFromPlugin(...) if you need a specific provider.\n

Terminal window
mkdir my-plugin && cd my-plugin
pnpm init
Terminal window
pnpm add @agent-detective/sdk zod
pnpm add -D typescript tsx
Terminal window
pnpm run build

After building, dist/ contains:

dist/
├── index.js # ES module bundle
└── index.d.ts # Type declarations

Section titled “Option A: npm Registry (Recommended for Public Plugins)”
Terminal window
# Build
pnpm run build
# Publish to npm
npm publish --access public

Users can then install it via:

Terminal window
npm install @myorg/agent-detective-my-plugin
Terminal window
# Create a release on GitHub
git tag v1.0.0
git push origin v1.0.0
# Users download and extract the dist/ folder

Distribute the dist/ folder directly within your organization:

Terminal window
# Copy dist/ to a shared location
scp -r dist/ user@server:/path/to/plugins/my-plugin/

See extending-with-plugins.md for:

  • package specifiers (npm, path, monorepo packages/*)
  • dependsOn and load order
  • private registry / .npmrc
  • Path-based plugins/ directory and absolute package paths 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;
FieldTypeDescription
bodyz.ZodTypeValidates request.body; rejects with 400 when invalid
querystringz.ZodTypeValidates request.query
paramsz.ZodTypeValidates URL params
headersz.ZodTypeValidates request headers
responseRecord<number, z.ZodType>Per-status response schemas; used for serialization (drops unknown fields) and OpenAPI
tagsstring[]Groups the route under tags in /docs
summary / descriptionstringSurfaced in OpenAPI
operationIdstringStable id for the operation
deprecatedbooleanMarks the operation deprecated
securityRecord<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 /docs directly
  • With auth: Set X-API-KEY header or configure DOCS_AUTH_REQUIRED=true and DOCS_API_KEY
VariableDescription
DOCS_AUTH_REQUIRED=trueRequire 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"
}

  1. Follow semver - Use meaningful version numbers
  2. Document configuration - Clear schema with defaults
  3. Handle errors gracefully - Don’t crash the host app
  4. Use logging - Help users debug issues
  5. Support hot reload - Design for development ease
  6. Test thoroughly - Mock external dependencies

  1. Check the plugin directory structure is correct
  2. Verify index.js is in the right place (not in dist/)
  3. Ensure package.json name matches directory name
  4. Check logs for schema validation errors
  1. Ensure @agent-detective/sdk version is compatible
  2. Run pnpm run build to generate .d.ts files
  3. Use import type for type-only imports
  1. Verify volume mount path is correct
  2. Ensure plugin files are readable
  3. Check config syntax in default.json