| title | Plugin Development Guide | ||
|---|---|---|---|
| description | Complete plugin API reference with patterns, TaskEvent, schema system, and examples. | ||
| sidebar |
|
Plugins extend agent-detective to connect any event source (Jira, Telegram, Slack, etc.). This guide covers the plugin APIs and patterns for authors. For where to point config.plugins[].package and how to use npm or filesystem paths for custom plugins, see extending-with-plugins.md first.
Bundled plugin options (Zod → JSON Schema): after changing the Zod options schema in @agent-detective/jira-adapter, @agent-detective/linear-adapter, @agent-detective/local-repos-plugin, or @agent-detective/pr-pipeline (packages/*/src/application/options-schema.ts), run pnpm docs:plugins and commit generated/plugin-options.md. See configuration.md.
- Plugin Anatomy
- Core Context Reference
2b. Task queue (
TaskQueue) - Schema System
- TaskEvent Interface
- Example: Jira-Style Webhook Plugin
- Example: Interactive Question Plugin (Telegram-Style)
- Example: Slash Command Plugin
- Example: Polling Plugin
- Publishing a Plugin as an npm Package
- Core API Reference
- Error Handling
- Testing Patterns
- Third-Party Plugins
- Official Bundled Plugins
A plugin is an ES module that exports a plain object with the following structure:
// packages/my-adapter/src/index.ts
import { definePlugin, type PluginContext } from '@agent-detective/sdk';
export default definePlugin({
name: '@myorg/my-adapter', // unique package name
version: '1.0.0', // semver version
schemaVersion: '1.0', // must be '1.0'
schema: { // JSON Schema for config validation
type: 'object',
properties: {
enabled: { type: 'boolean', default: true },
webhookPath: { type: 'string', default: '/webhook/my-plugin' },
},
required: []
},
register(scope, context: PluginContext) {
// scope: encapsulated Fastify instance mounted at /plugins/{sanitized-name}
// context: core dependencies (see Section 2)
}
});| Field | Type | Description |
|---|---|---|
name |
string |
Unique plugin identifier (e.g., @agent-detective/jira-adapter) |
version |
string |
Semver version (e.g., 1.0.0) |
register |
function |
Called on load with (scope, context), where scope is a Fastify instance encapsulated under /plugins/{sanitized-name} |
schemaVersion |
string |
Must be '1.0' |
| Field | Type | Description |
|---|---|---|
schema |
object |
JSON Schema for config validation (see Section 3) |
The register function receives a context object with all core services:
register(scope, context: PluginContext) {
const {
agentRunner, // run AI agents
config, // validated plugin config with defaults applied
logger, // logger with plugin prefix
} = context;
// Accessing repository context from local-repos-plugin
try {
const localReposService = context.getService<LocalReposService>('@agent-detective/local-repos-plugin');
const localRepos = localReposService.localRepos;
// ...
} catch (err) {
logger.warn('Local repos service not available');
}
}| Member | Type | Description |
|---|---|---|
agentRunner |
AgentRunner |
Executes AI agent prompts (see Section 10) |
config |
object |
Plugin config validated against schema, with defaults merged |
logger |
Logger |
Logger with .info(), .warn(), .error() |
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 |
registerAgent(agent) |
function |
Register a new AI agent provider |
registerTaskQueue(queue) |
function |
Replace the global TaskQueue backend (same contract as enqueue; use for Redis/SQLite workers) |
enqueue |
function |
Enqueue tasks to be executed sequentially per key (delegates to the active queue) |
The host builds the plugin system before the orchestrator and HTTP API, and passes createPluginSystem(...).enqueue into those components so every code path shares one queue.
createPluginSystem({ agentRunner, events, logger?, taskQueue? }): iftaskQueueis omitted, the core uses an in-memoryTaskQueue(same behavior as before). PasstaskQueuefrom tests or from a custom bootstrap if you need a specific initial backend.- Plugins call
context.registerTaskQueue(queue)to replace the backend at runtime (for example a Redis-backed package loaded viaconfig.plugins). The previous queue’s optionalshutdown()is invoked (async errors are logged). EnqueueFnis still in-process:enqueue(key, fn)runs the givenfnin this Node process. Surviving process restarts requires a plugin that persists serialized work and replays it (a different design than only swappingTaskQueue).
There is no enqueue option on createPluginSystem; use taskQueue or the default memory queue.
Plugins can share functionality by registering services. This is preferred over accessing the plugins dictionary directly as it provides better type safety and error handling.
Capabilities are a standardized feature vocabulary that answers: “is some loaded plugin providing this feature?”\n\n- Use dependsOn when you require a specific plugin to be loaded first (usually because you call getService(...) for a service it registers).\n- Use requiresCapabilities when you only care that someone provides a feature, regardless of which plugin.\n- Prefer SDK-owned constants from @agent-detective/sdk (StandardCapabilities.*) over ad-hoc strings.\n- Third-party plugins that define custom capabilities should use stable, namespaced strings (e.g. acme.example/repo-matching) to avoid collisions.\n\nWhen multiple plugins register the same capability-backed service (like CODE_ANALYSIS_SERVICE), the host selects a default provider by:\n\n- preferring first-party plugins (@agent-detective/*)\n- otherwise using the config.plugins[] order as a stable tie-break\n+\n+The host validates capability-backed contracts at boot. In strict mode it can abort startup when a plugin requires a capability but the mapped service key is missing.\n\nTo fail startup on any plugin load/config error (import failure, schema/options validation error, or register exception), enable pluginSystem.failOnPluginLoadErrors.\n
// In your plugin's index.ts
export interface MyService {
doSomething(): string;
}
const myPlugin: Plugin = {
name: 'my-provider-plugin',
// ...
register(app, context) {
const service: MyService = {
doSomething: () => 'Hello from service!'
};
context.registerService<MyService>('my-service', service);
}
};// In the consumer plugin
import type { MyService } from 'my-provider-plugin';
const consumerPlugin: Plugin = {
name: 'my-consumer-plugin',
dependsOn: ['my-provider-plugin'],
register(app, context) {
// getService will throw if the service is not found
const myService = context.getService<MyService>('my-service');
console.log(myService.doSomething());
}
};interface LocalReposContext {
repos: ValidatedRepo[];
getRepo(name: string): ValidatedRepo | null;
getAllRepos(): ValidatedRepo[];
}
interface ValidatedRepo {
name: string;
path: string;
exists: boolean;
description?: string;
techStack: string[];
summary: string;
}Plugins define their configuration schema using a subset of JSON Schema. The core validates the config against the schema when the plugin loads.
schema: {
type: 'object',
properties: {
// String values
webhookPath: { type: 'string', default: '/webhook/my' },
apiKey: { type: 'string', default: '' },
// Boolean values
enabled: { type: 'boolean', default: true },
mockMode: { type: 'boolean', default: false },
// Number values
timeoutMs: { type: 'number', default: 30000 },
maxRetries: { type: 'number', default: 3 },
// Array values
allowedChannels: { type: 'array', default: [] },
// Object values
nestedConfig: { type: 'object', default: {} },
},
required: ['webhookPath'] // fields that must be present
}The plugin system automatically merges defaults from the schema into config before calling register. You don't need to apply defaults manually:
// schema defines: webhookPath: { type: 'string', default: '/webhook/my' }
// config in default.json: { package: 'my-adapter', options: {} }
// In register, config.webhookPath will be '/webhook/my' even though
// the user didn't specify it in default.json
register(app, { config }) {
console.log(config.webhookPath); // '/webhook/my' - default was applied
}Include schemaVersion: '1.0' in your plugin. This allows future schema versions without breaking existing plugins.
export default {
name: '@agent-detective/my-adapter',
version: '1.0.0',
schemaVersion: '1.0', // required - must be '1.0'
// ...
};All plugins produce a normalized TaskEvent object that the core processes identically regardless of source.
const taskEvent: TaskEvent = {
id: 'PROJ-123', // Unique task ID (e.g., Jira issue key)
type: 'incident', // 'incident' | 'question' | 'command'
source: '@agent-detective/my-adapter', // Plugin name
message: 'User reported login failure...', // Original text to process
context: {
repoPath: '/path/to/project', // Repository path (null = no repo access)
threadId: null, // Session ID (null = new session)
cwd: process.cwd(), // Working directory
},
replyTo: {
type: 'issue', // 'issue' | 'channel' | 'user'
id: 'PROJ-123', // Target identifier
},
metadata: { // Source-specific data
labels: ['backend', 'auth'],
issueType: 'Bug',
reporter: 'john@example.com',
}
};| Type | Description | Typical Reply |
|---|---|---|
incident |
Something broke, needs investigation | Root cause analysis comment |
question |
User asking a question | Conversational response |
command |
Bot command (e.g., /analyze) |
Command output |
| Type | Description |
|---|---|
issue |
Jira issue, GitHub PR, etc. |
channel |
Chat channel (Telegram group, Slack channel) |
user |
Direct message to a user |
This is the most common plugin pattern. The plugin receives HTTP POST webhooks, normalizes them into TaskEvent, and posts analysis comments back to the source.
packages/my-jira/
├── src/
│ ├── index.ts # Plugin entry point
│ ├── normalizer.ts # Payload → TaskEvent
│ └── jira-client.ts # Jira API client
├── test/
│ └── normalizer.test.ts
├── package.json
└── tsconfig.json
{
"name": "@myorg/agent-detective-jira",
"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",
"test": "tsx --test"
},
"dependencies": {
"@agent-detective/sdk": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"tsx": "^4.19.0"
}
}Use context.getService to obtain the local-repos service (or the REPO_MATCHER_SERVICE matcher's matchByLabels when you only need a path). Use context.enqueue for serialized work. Plugin routes are registered via defineRoute() + registerRoutes(scope, ...) from @agent-detective/sdk (see plugin-development.md); the effective URL is under /plugins/{sanitized-name}/.
The official jira-adapter implements the full Jira + fan-out flow — treat it as the reference, not the stale snippet below. This sketch shows the correct PluginContext surface:
// Sketch — not a full drop-in. See packages/jira-adapter for production code.
import {
REPO_MATCHER_SERVICE,
type Plugin,
type PluginContext,
type TaskEvent,
type RepoMatcher,
type LocalReposService,
} from '@agent-detective/sdk';
type JiraClient = { addComment(issueKey: string, text: string): Promise<void> };
const plugin: Plugin = {
name: '@myorg/my-jira',
version: '0.1.0',
schemaVersion: '1.0',
dependsOn: ['@agent-detective/local-repos-plugin'],
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true } }, required: [] },
register(_app, context: PluginContext) {
const { config, agentRunner, enqueue, logger } = context;
if (!config.enabled) return;
const matcher = context.getService<RepoMatcher>(REPO_MATCHER_SERVICE);
let local: LocalReposService;
try {
local = context.getService<LocalReposService>('@agent-detective/local-repos-plugin');
} catch {
logger.error('local-repos-plugin is required for this example');
return;
}
const jira = makeJiraClient();
_app.post('/webhook/jira', async (req, res) => {
const task = normalizePayload(req.body) as TaskEvent;
const labels = (task.metadata?.labels as string[] | undefined) ?? [];
const m = matcher.matchByLabels(labels);
task.context = { ...task.context, repoPath: m?.path ?? null };
void enqueue(task.id, async () => {
let ctxText = '';
if (task.context.repoPath) {
const built = await local.buildRepoContext(task.context.repoPath, { maxCommits: 50 });
ctxText = local.formatRepoContextForPrompt(built);
}
await agentRunner.runAgentForChat(task.id, buildPrompt(task, ctxText), {
onFinal: async (t) => {
await jira.addComment(task.replyTo.id, t);
},
});
});
res.json({ status: 'queued' });
});
},
};
function buildPrompt(task: TaskEvent, repo: string) {
return [task.message, repo ? `### Repository context\n${repo}` : ''].filter(Boolean).join('\n\n');
}
function normalizePayload(body: unknown): TaskEvent {
void body;
throw new Error('See your normalizer / packages/jira-adapter');
}
function makeJiraClient(): JiraClient {
return { async addComment() {} };
}
export default plugin;// packages/my-jira/src/normalizer.ts
import type { TaskEvent } from '@agent-detective/sdk';
export function normalizePayload(payload: JiraPayload): TaskEvent {
const issue = payload.issue || payload;
return {
id: issue.key || String(Date.now()),
type: 'incident',
source: '@myorg/agent-detective-jira',
message: buildIncidentMessage(issue),
context: {
repoPath: null,
threadId: null,
cwd: process.cwd(),
},
replyTo: {
type: 'issue',
id: issue.key,
},
metadata: {
labels: issue.fields?.labels || [],
projectKey: issue.fields?.project?.key || '',
issueType: issue.fields?.issuetype?.name || 'Task',
reporter: issue.fields?.reporter?.displayName || 'unknown',
},
};
}
function buildIncidentMessage(issue: JiraIssue): string {
const desc = issue.fields?.description || '';
return `## Incident: ${issue.fields?.summary || 'No title'}\n\n### Description\n${desc}`;
}This pattern handles conversational messages where a user asks a question. The plugin maintains conversation threads via threadId, and supports streaming progress updates.
type: 'question'instead ofincidentreplyTo.type: 'user'or'channel'- Uses
threadIdto continue conversations - Supports streaming progress via
onProgresscallback - Agent can respond without repo context (optional analysis)
// packages/my-telegram/src/index.ts
import type { Plugin, PluginContext, TaskEvent } from '@agent-detective/sdk';
const plugin: Plugin = {
name: '@myorg/agent-detective-telegram',
version: '0.1.0',
schemaVersion: '1.0',
schema: {
type: 'object',
properties: {
enabled: { type: 'boolean', default: true },
botToken: { type: 'string', default: '' },
allowedChannels: { type: 'array', default: [] },
defaultRepoPath: { type: 'string', default: null },
},
required: ['botToken'],
},
register(app, context: PluginContext) {
const { config, agentRunner, enqueue, logger } = context;
if (!config.enabled) return;
// Optional: resolve a default repo from local-repos (or another service you register).
// There is no context.repoMapping — use getService<RepoMatcher>(REPO_MATCHER_SERVICE) or
// getService<LocalReposService>('@agent-detective/local-repos-plugin') and your own rules.
const telegram = createTelegramBot(config.botToken as string);
telegram.on('message', async (msg) => {
const { chatId, text, messageId } = msg;
const defaultPath = (config.defaultRepoPath as string) || null;
// There is no context.repoMapping. Resolve paths via your own config,
// or getService<RepoMatcher>(REPO_MATCHER_SERVICE) / LocalReposService.
const repoPath = defaultPath;
const taskEvent: TaskEvent = {
id: `${chatId}:${messageId}`,
type: 'question',
source: '@myorg/agent-detective-telegram',
message: text.replace(/proj:\S+\s*/, '').trim(),
context: {
repoPath,
threadId: String(chatId),
cwd: process.cwd(),
},
replyTo: {
type: 'user',
id: String(chatId),
},
metadata: {
messageId,
chatId,
username: msg.from?.username,
},
};
void enqueue(taskEvent.context.threadId ?? 'default', async () => {
const out = await agentRunner.runAgentForChat(taskEvent.id, taskEvent.message, {
contextKey: taskEvent.context.threadId ?? taskEvent.id,
repoPath: taskEvent.context.repoPath,
});
logger.info('Reply', { out: out.slice(0, 200) });
// await telegram.sendMessage(chatId, out);
});
});
logger.info('Telegram adapter registered');
},
};
export default plugin;Handles bot commands like /analyze, /status, /help. Commands are typically prefixed and parsed from regular messages.
// packages/my-slash-command/src/index.ts
import type { Plugin, PluginContext, TaskEvent } from '@agent-detective/sdk';
const plugin: Plugin = {
name: '@myorg/agent-detective-slack',
version: '0.1.0',
schemaVersion: '1.0',
schema: {
type: 'object',
properties: {
enabled: { type: 'boolean', default: true },
webhookPath: { type: 'string', default: '/webhook/slack' },
signingSecret: { type: 'string', default: '' },
},
required: ['webhookPath'],
},
register(app, context: PluginContext) {
const { config, agentRunner, logger } = context;
if (!config.enabled) return;
app.post(config.webhookPath as string, async (req, res) => {
res.json({ status: 'ok' });
const { command, text, user_id, channel_id } = req.body;
if (command === '/analyze') {
await handleAnalyze({ command, text, user_id, channel_id }, { config, agentRunner, logger });
} else if (command === '/status') {
await handleStatus({ command, user_id, channel_id }, { config, agentRunner, logger });
} else {
logger.warn(`Unknown command: ${command}`);
}
});
logger.info('Slash command adapter registered');
},
};
export default plugin;Polls an external API periodically instead of receiving webhooks. Useful for checking status, monitoring, or periodic reporting.
// packages/my-poller/src/index.ts
import type { Plugin, PluginContext } from '@agent-detective/sdk';
const plugin: Plugin = {
name: '@myorg/agent-detective-poller',
version: '0.1.0',
schemaVersion: '1.0',
schema: {
type: 'object',
properties: {
enabled: { type: 'boolean', default: true },
pollIntervalMs: { type: 'number', default: 60000 },
apiEndpoint: { type: 'string', default: '' },
apiKey: { type: 'string', default: '' },
},
required: ['apiEndpoint'],
},
register(app, context: PluginContext) {
const { config, agentRunner, logger } = context;
if (!config.enabled) return;
let intervalId: ReturnType<typeof setInterval> | null = null;
app.post('/control/poller/start', (req, res) => {
startPolling();
res.json({ status: 'started' });
});
app.post('/control/poller/stop', (req, res) => {
stopPolling();
res.json({ status: 'stopped' });
});
async function poll() {
try {
const events = await fetchExternalEvents(config);
for (const rawEvent of events) {
const taskEvent = normalizeEvent(rawEvent);
await processTask(taskEvent, { config, agentRunner, logger });
}
} catch (err) {
logger.error(`Polling error: ${(err as Error).message}`);
}
}
function startPolling() {
if (intervalId) return;
logger.info(`Starting poller with interval ${config.pollIntervalMs}ms`);
poll();
intervalId = setInterval(poll, config.pollIntervalMs as number);
}
function stopPolling() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
logger.info('Poller stopped');
}
}
startPolling();
process.on('SIGTERM', stopPolling);
},
};
export default plugin;For development within the monorepo, put your plugin in packages/:
agent-detective/
├── packages/
│ ├── jira-adapter/ # Official
│ └── my-adapter/ # Your plugin
│ ├── package.json
│ ├── src/
│ │ └── index.ts
│ └── dist/ # Built output
To publish your plugin as a standalone npm package:
1. Create the plugin package structure:
my-adapter/
├── src/
│ ├── index.ts # Plugin entry point
│ └── normalizer.ts
├── test/
│ └── normalizer.test.ts
├── package.json
└── tsconfig.json
2. package.json:
{
"name": "@myorg/agent-detective-my-adapter",
"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",
"test": "tsx --test"
},
"dependencies": {
"@agent-detective/sdk": "^1.0.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"tsx": "^4.19.0"
},
"keywords": ["agent-detective", "plugin"]
}3. Publish to npm:
cd my-adapter
pnpm install
pnpm run build
pnpm publish --access public4. Install in agent-detective:
cd agent-detective
pnpm add @myorg/agent-detective-my-adapter5. Configure in config/default.json:
{
"plugins": [
{
"package": "@myorg/agent-detective-my-adapter",
"options": {
"enabled": true,
"webhookPath": "/webhook/my"
}
}
]
}The plugin system tries to load plugins in this order:
- If
packagestarts with./,../, or/→ treat as file path relative to project root - Try
import(packageName)from node_modules - Try
import(packages/{name}/src/index.js)where@agent-detective/Xmaps topackages/X/src/index.js
Created by createAgentRunner() with shell execution utilities. Available in plugins via context.agentRunner.
Runs an AI agent with a prompt.
Parameters:
| Parameter | Type | Description |
|---|---|---|
taskId |
string |
Unique task identifier (used for queuing) |
prompt |
string |
Full prompt to send to the agent |
options.contextKey |
string |
Context key for conversation continuity (default: taskId) |
options.repoPath |
string|null |
Repository path for context |
options.cwd |
string |
Working directory (default: process.cwd()) |
options.agentId |
string |
Override agent (default: from config) |
options.onProgress |
function |
Called with progress updates (streaming agents) |
options.onFinal |
function |
Called with final response text |
Returns: Promise<string> - the agent's response text
Example:
await agentRunner.runAgentForChat(taskEvent.id, fullPrompt, {
contextKey: taskEvent.context.threadId,
repoPath: taskEvent.context.repoPath,
onProgress: (messages) => {
for (const msg of messages) {
sendMessage(userId, `Thinking: ${msg}`);
}
},
onFinal: async (responseText) => {
await jiraClient.addComment(issueId, responseText);
},
});Stops an active agent run.
Returns: Promise<{ status: 'idle' | 'stopping' }>
PluginContext does not include repoMapping or buildRepoContext directly.
REPO_MATCHER_SERVICE— register/consume aRepoMatcher(matchByLabels,matchAllByLabels,listConfiguredLabels). The bundled local-repos-plugin provides the implementation; the jira-adapter consumes it for label → repo resolution.@agent-detective/local-repos-pluginservice — aLocalReposServicewithlocalRepos,buildRepoContext(repoPath, options?), andformatRepoContextForPrompt.BuildRepoContextOptions(re-exported from@agent-detective/sdk) only supports{ maxCommits?: number }(file search was removed; agents search the tree themselves).
import {
REPO_MATCHER_SERVICE,
type RepoMatcher,
type LocalReposService,
} from '@agent-detective/sdk';
const matcher = context.getService<RepoMatcher>(REPO_MATCHER_SERVICE);
const m = matcher.matchByLabels(['my-repo-name']);
const local = context.getService<LocalReposService>('@agent-detective/local-repos-plugin');
if (m) {
const built = await local.buildRepoContext(m.path, { maxCommits: 50 });
const text = local.formatRepoContextForPrompt(built);
}The plugin system catches errors during loading and logs them as warnings. The server continues running.
try {
plugin.register(app, pluginContext);
} catch (err) {
logger.warn(`Failed to load plugin ${pluginName}: ${(err as Error).message}. Continuing...`);
return null;
}Best Practice: Use config.enabled to cleanly disable a plugin without errors:
register(app, context: PluginContext) {
if (!context.config.enabled) {
context.logger.info('Plugin is disabled');
return;
}
// normal setup
}Wrap each task in try-catch to prevent one failing task from affecting others:
enqueueTask(taskId, async () => {
try {
await processTask(taskEvent, context);
} catch (err) {
logger.error(`Task ${taskId} failed: ${(err as Error).message}`);
}
});createPluginSystem requires events (an EventBus). Pass a no-op bus and a minimal AgentRunner (registerAgent, listAgents, runAgentForChat, stopActiveRun). Omit taskQueue to use the default in-memory queue, or pass a stub { enqueue } to assert queue behavior. Use the enqueue property on the return value to run work the same way the production app does.
Create an in-memory mock client that stores data instead of making external API calls:
// src/mock-jira-client.ts
export interface MockJiraClient {
comments: Map<string, MockComment[]>;
addComment(issueKey: string, commentText: string): Promise<{ success: boolean; issueKey: string }>;
}
export function createMockJiraClient(): MockJiraClient {
const comments = new Map<string, MockComment[]>();
return {
comments,
async addComment(issueKey: string, commentText: string) {
if (!comments.has(issueKey)) {
comments.set(issueKey, []);
}
comments.get(issueKey)!.push({
text: commentText,
createdAt: new Date().toISOString(),
});
console.info(`[MOCK] Added comment to ${issueKey}`);
return { success: true, issueKey };
},
};
}Store sample payloads as JSON fixtures:
// test/fixtures/issue-created.json
{
"issue": {
"key": "PROJ-123",
"fields": {
"summary": "Login fails for users",
"description": "Users cannot login after latest deployment",
"labels": ["backend", "auth"],
"project": { "key": "PROJ" },
"issuetype": { "name": "Bug" }
}
}
}// test/normalizer.test.ts
import { strict as assert } from 'assert';
import { normalizePayload } from '../src/normalizer.js';
import issueCreated from './fixtures/issue-created.json';
test('normalizePayload extracts correct fields', () => {
const taskEvent = normalizePayload(issueCreated);
assert.equal(taskEvent.id, 'PROJ-123');
assert.equal(taskEvent.type, 'incident');
assert.equal(taskEvent.source, '@myorg/agent-detective-jira');
assert.ok(taskEvent.message.includes('Login fails'));
assert.deepEqual(taskEvent.metadata.labels, ['backend', 'auth']);
assert.equal(taskEvent.replyTo.type, 'issue');
assert.equal(taskEvent.replyTo.id, 'PROJ-123');
});
test('mock client stores comments', () => {
const mockClient = createMockJiraClient();
mockClient.addComment('PROJ-123', 'Root cause analysis...');
const comments = mockClient.comments.get('PROJ-123');
assert.equal(comments?.length, 1);
assert.ok(comments?.[0].text.includes('Root cause'));
});Installing and wiring custom plugins (npm, private registry, path or bind-mounted directory) is covered in one place: extending-with-plugins.md. The rest of this document describes APIs, patterns, and the official bundles. For a step-by-step TypeScript template and long examples, see plugin-development.md.
The default distribution includes these first-party plugins (enable/disable via config.plugins):
Manages local repository configuration with validation, tech stack detection, and summary generation.
Package: @agent-detective/local-repos-plugin
Can Disable: Yes ("enabled": false in config)
Configuration:
{
"plugins": [
{
"package": "@agent-detective/local-repos-plugin",
"options": {
"enabled": true,
"repos": [
{ "name": "backend", "path": "/repos/backend" }
]
}
}
]
}Handles Jira webhooks and dispatches deterministic, label-based analysis via
the RepoMatcher service exposed by local-repos-plugin.
Package: @agent-detective/jira-adapter
Can Disable: Yes ("enabled": false in config)
Operator guide: jira-adapter.md — webhooks vs OAuth vs Basic, trigger phrases, OAuth install (start/callback), token copy + rotation, and identity notes.
Webhook URL (fixed): POST …/plugins/agent-detective-jira-adapter/webhook/jira.
OAuth routes (optional): GET …/oauth/start and GET …/oauth/callback when oauthClientId, oauthClientSecret, and oauthRedirectBaseUrl are set.
The webhookBehavior option lets you define what action to take for each Jira webhook event type:
{
"plugins": [
{
"package": "@agent-detective/jira-adapter",
"options": {
"enabled": true,
"mockMode": false,
"baseUrl": "https://your-domain.atlassian.net",
"email": "bot@example.com",
"apiToken": "your-api-token",
"webhookBehavior": {
"defaults": {
"action": "ignore",
"acknowledgmentMessage": "Thanks for the update! I will review this issue shortly."
},
"events": {
"jira:issue_created": { "action": "analyze" },
"jira:comment_created": { "action": "analyze" }
}
}
}
}
]
}| Action | Description |
|---|---|
analyze |
Match the issue's labels against configured repos; on matches, fan out one analysis per repo. On issue_created without a match, post a "please add a matching label and comment <trigger>" reminder. On jira:comment_created, run the match only when the comment body contains retryTriggerPhrase and wasn't authored by the adapter itself. No automatic retry on issue_updated. |
acknowledge |
Post a fixed acknowledgment comment (no matching, no analysis) |
ignore |
Log the event and skip processing |
| Option | Description |
|---|---|
webhookBehavior.defaults.action |
Default action for unhandled events |
webhookBehavior.defaults.acknowledgmentMessage |
Default message for acknowledge action |
webhookBehavior.events.{eventType}.action |
Action for a specific event type |
webhookBehavior.events.{eventType}.acknowledgmentMessage |
Override message for a specific event |
webhookBehavior.events.{eventType}.analysisPrompt |
Custom analysis prompt template |
analysisReadOnly |
When true (default), analyze tasks run with write/edit/shell tools denied |
missingLabelsMessage |
Markdown template posted when no label matches on issue_created or on a comment-triggered retry. Supports {available_labels}, {issue_key}, and {trigger_phrase} placeholders. |
maxReposPerIssue |
Safety cap on fan-out when an issue's labels match multiple repos. Default 5; 0 disables the cap. Extra matches are logged and noted in the acknowledgment. |
retryTriggerPhrase |
Case-insensitive substring that, when found in a jira:comment_created body authored by a non-adapter user, kicks off a fresh label match. Default #agent-detective analyze. Pick something unlikely to appear in normal conversation — any matching comment runs analysis. |
jiraUser.accountId / jiraUser.email |
Optional identity of the Jira account the adapter posts as. Used together with the visible "Posted by agent-detective" footer marker to filter out adapter-authored comments so the retry flow can't loop. Comments from this account are ignored even if the marker is stripped. |
fetchIssueComments |
When true, the adapter fetches all comments on the Jira ticket at PR-trigger time and passes human-authored ones (app comments excluded) to pr-pipeline as additional agent context. Default false. |
| Event Type | Default Action |
|---|---|
jira:issue_created |
analyze |
jira:comment_created |
analyze (gated: only runs when the comment contains retryTriggerPhrase and is not adapter-authored) |
jira:issue_updated |
ignore (falls to default) — no more changelog-based auto-retry |
jira:issue_deleted |
ignore (falls to default) |
Matching is label-only and deterministic. The Jira adapter consumes
the RepoMatcher service (REPO_MATCHER_SERVICE from
@agent-detective/sdk) which local-repos-plugin registers. The matcher
exposes two methods:
matchByLabels(labels) → MatchedRepo | null— first match (label-order).matchAllByLabels(labels) → MatchedRepo[]— every match, returned in configured-repo order for stable fan-out.
On a match, the adapter emits one TASK_CREATED per matched repo with
context.repoPath, context.cwd, and metadata.matchedRepo pre-set so the
downstream analyzer has no selection work to do. Task ids are composite —
<ISSUE-KEY>:<repo-name> — so parallel fan-out runs don't collapse in the
orchestrator queue. Result comments carry a ## Analysis for \``
heading so readers can tell them apart on the ticket.
Retries are user-initiated via jira:comment_created: if a ticket was
created without a matching label, the adapter posts a reminder listing
every configured label plus the exact retryTriggerPhrase to include in a
follow-up comment. Posting a comment that contains the phrase re-runs the
match against the ticket's current labels — no changelog parsing, no
delta bookkeeping. Adapter-authored comments carry a visible
"Posted by agent-detective · ad-v1" footer (rendered as a plain
Markdown --- + italic line so it round-trips reliably through Jira's
Markdown → ADF pipeline); the comment_created handler drops anything
containing that footer, optionally cross-checked against
jiraUser.accountId / jiraUser.email. Two last-resort circuit
breakers backstop the above: the adapter refuses to post more than one
missing-labels reminder to the same issue within a 60-second window,
and it refuses to auto-analyze the same (issue, repo) pair more than
once per 10 minutes on non-comment-triggered paths (explicit comment
retries bypass the cooldown because a human explicitly asked for the
re-run). The payload-shape event classifier also treats any
changelog.items / changelog.histories / changelog.total > 0
signal as issue_updated (default ignore) rather than
issue_created, so result comments that Jira Automation echoes back as
bare-issue payloads never get mis-routed into analyze. Together these
layers guarantee result comments and reminders can never loop back into
the retry handler. There is no agent-driven discovery fallback. See the "Matching
a ticket to a repository" section in
e2e/jira-manual-e2e.md for the full flow.
{
"analysis": {
"maxCommits": 50
}
}Handles Linear webhooks (Issues, Comments) and mirrors the Jira adapter’s
label-based analyze / PR fan-out using RepoMatcher from
local-repos-plugin. Supports signed webhooks, OAuth (install flow +
refresh token grant), and a personal API key for local testing.
Package: @agent-detective/linear-adapter
Can disable: Yes ("enabled": false).
Operator guide: linear-adapter.md — webhooks vs OAuth vs
trigger phrases, OAuth install (Linear app form, callback URLs, manual token
copy, apiKey vs refresh-only), comment attribution (actor=user vs
actor=app), FAQ (404, @mentions), dedup header, env tables, and links to the
generated options reference and manual E2E.
Webhook URL (fixed): POST …/plugins/agent-detective-linear-adapter/webhook/linear.
OAuth routes (optional): GET …/oauth/start and GET …/oauth/callback when
oauthClientId, oauthClientSecret, and oauthRedirectBaseUrl are set.
Canonical webhookBehavior.events keys include linear:Issue:create and
linear:Comment:create (defaults match Jira-style analyze). See
generated/plugin-options.md for
every option.