agent-orchestrator/website/content/docs/plugins/authoring.mdx

594 lines
21 KiB
Plaintext

---
title: Authoring a plugin
description: Write a custom runtime, agent, workspace, tracker, SCM, notifier, or terminal plugin for AO.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Card, Cards } from "fumadocs-ui/components/card";
AO's seven user-pluggable slots (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal) are all resolved at startup from the plugin registry. Each slot is backed by a TypeScript interface defined in `packages/core/src/types.ts`. Adding a new tracker, notifier, or any other integration is a matter of publishing an npm package — or pointing to a local path — that exports a manifest, a `create()` factory, and an optional `detect()` probe as its default export.
---
## The contract
Every plugin module must satisfy `PluginModule<T>`, where `T` is the interface for its slot:
```typescript
export interface PluginModule<T = unknown> {
manifest: PluginManifest;
create(config?: Record<string, unknown>): T;
detect?(): boolean;
}
export interface PluginManifest {
name: string; // must match the package-name suffix (see below)
slot: PluginSlot; // use "as const" to preserve the literal type
description: string;
version: string;
displayName?: string;
}
```
### `manifest`
The static description of your plugin. Every field is required except `displayName`.
### `create(config?)`
Called once during startup. `config` contains whatever key-value pairs appear under your plugin's YAML block. Validate everything here; store validated values in a closure. Return an object that implements the slot interface.
### `detect()` (optional)
Return `true` when the system has what your plugin needs — a binary on PATH, an environment variable, a running process. AO uses this to warn about missing dependencies at startup rather than at first use.
### Default export
```typescript
import type { PluginModule, Notifier } from "@aoagents/ao-core";
export const manifest = {
name: "my-notifier",
slot: "notifier" as const,
description: "Send alerts to My Service",
version: "0.1.0",
};
export function create(config?: Record<string, unknown>): Notifier {
// validate here, return interface implementation
}
export function detect(): boolean {
return Boolean(process.env["MY_SERVICE_TOKEN"]);
}
export default { manifest, create, detect } satisfies PluginModule<Notifier>;
```
---
## Manifest rules
- **`name`** must match the suffix of your package name. `@acme/ao-plugin-notifier-pagerduty` → `name: "pagerduty"`. The registry looks up plugins by `slot:name` key.
- **`slot`** must use `as const` to preserve the string literal type — `"notifier" as const`, not `"notifier"`.
- **`version`** follows semver. Start at `0.1.0`.
- **`displayName`** is optional. When omitted, `name` is used in log output.
---
## Create a plugin with `ao plugin create`
The fastest way to start is `ao plugin create`. It runs an interactive prompt (using `@clack/prompts`) and writes a ready-to-build package.
<Steps>
<Step>
### Run the scaffold command
```bash
ao plugin create
```
You can also pass `[directory]` as a positional argument, or supply any field with flags to skip individual prompts:
```bash
ao plugin create ./my-plugins/pagerduty \
--slot notifier \
--name "PagerDuty" \
--description "Route urgent alerts to PagerDuty" \
--author "Alice" \
--package-name "ao-plugin-notifier-pagerduty"
```
Add `--non-interactive` to require all fields to be provided via flags (useful in CI).
</Step>
<Step>
### Answer the interactive prompts
The CLI asks for four things, in order:
| Prompt | Example input |
|--------|--------------|
| Plugin display name | `PagerDuty` |
| Plugin slot | `notifier` (selected from a list of all 7 slots) |
| Short description | `Route urgent alerts to PagerDuty` |
| Author | `Alice` |
| Package name | `ao-plugin-notifier-pagerduty` (pre-filled from slot + name) |
</Step>
<Step>
### Inspect the generated files
The scaffold is written to `./{normalized-name}/` (or the directory you provided). The generated structure is:
```
pagerduty/
├── package.json # type: module, main: dist/index.js, exports map
├── tsconfig.json # ES2022 + Node16 modules, strict mode
├── README.md # AO config snippets for local and npm installs
├── .gitignore # dist/, node_modules/
└── src/
└── index.ts # manifest + stub create() + default export
```
The generated `src/index.ts` looks like this:
```typescript
import type { PluginModule } from "@aoagents/ao-core";
export const manifest = {
name: "pagerduty",
slot: "notifier" as const,
description: "Route urgent alerts to PagerDuty",
version: "0.1.0",
displayName: "PagerDuty",
};
const plugin: PluginModule = {
manifest,
create(config?: Record<string, unknown>) {
return {
name: manifest.name,
config: config ?? {},
// TODO: replace this placeholder with a real notifier implementation.
};
},
};
export default plugin;
```
</Step>
<Step>
### Implement the slot interface
Replace the placeholder `create()` body with a real implementation. Import the slot interface from `@aoagents/ao-core`:
```typescript
import type { PluginModule, Notifier } from "@aoagents/ao-core";
import { validateUrl } from "@aoagents/ao-core";
```
See [Slot interfaces at a glance](#slot-interfaces-at-a-glance) for the methods you need to implement.
Then build:
```bash
npm install
npm run build
```
</Step>
<Step>
### Register it in `agent-orchestrator.yaml`
For local development, use `source: local`:
```yaml
plugins:
- name: pagerduty
source: local
path: ./pagerduty
notifiers:
pagerduty-urgent:
plugin: pagerduty
routing_key: "your-integration-key"
notificationRouting:
urgent: [pagerduty-urgent]
```
Once published to npm, switch to `source: registry` (or `source: npm`):
```yaml
plugins:
- name: pagerduty
source: registry
package: "ao-plugin-notifier-pagerduty"
version: "^1.0.0"
```
</Step>
</Steps>
---
## Slot interfaces at a glance
All interfaces are defined in `packages/core/src/types.ts` and exported from `@aoagents/ao-core`.
### Runtime
| Method | Signature | Required |
|--------|-----------|---------|
| `create` | `(config: RuntimeCreateConfig) => Promise<RuntimeHandle>` | yes |
| `destroy` | `(handle: RuntimeHandle) => Promise<void>` | yes |
| `sendMessage` | `(handle: RuntimeHandle, message: string) => Promise<void>` | yes |
| `getOutput` | `(handle: RuntimeHandle, lines?: number) => Promise<string>` | yes |
| `isAlive` | `(handle: RuntimeHandle) => Promise<boolean>` | yes |
| `getMetrics` | `(handle: RuntimeHandle) => Promise<RuntimeMetrics>` | optional |
| `getAttachInfo` | `(handle: RuntimeHandle) => Promise<AttachInfo>` | optional |
### Agent
Agent plugins have the richest interface. Methods are split into required and optional:
**Required:**
| Method | Purpose | Returns `null`? |
|--------|---------|----------------|
| `getLaunchCommand` | Shell command to start the agent | No |
| `getEnvironment` | Env vars for the process (must include `~/.ao/bin` in PATH) | No |
| `detectActivity` | Terminal-output activity classification (deprecated but required) | No |
| `getActivityState` | JSONL/API-based activity detection | Yes (if no data) |
| `isProcessRunning` | Check whether the process is still alive | No (returns `false`) |
| `getSessionInfo` | Extract summary, cost, session ID from agent data | Yes |
**Optional:**
| Method | Purpose | When to skip |
|--------|---------|-------------|
| `getRestoreCommand` | Resume a previous session | Agent has no resume capability |
| `setupWorkspaceHooks` | Install metadata hooks for PR tracking | Never — required for the dashboard |
| `postLaunchSetup` | Post-launch config | Only if no post-launch work is needed |
| `recordActivity` | Write terminal-derived activity to JSONL | Agent has native JSONL (Claude Code) |
<Callout type="warn">
`setupWorkspaceHooks` is marked optional in the TypeScript interface but is critical in practice. Without it, PRs created by your agent will never appear in the dashboard. See [Agent plugin specifics](#agent-plugin-specifics) for the two patterns (agent-native hooks vs PATH wrappers).
</Callout>
### Workspace
| Method | Signature | Required |
|--------|-----------|---------|
| `create` | `(config: WorkspaceCreateConfig) => Promise<WorkspaceInfo>` | yes |
| `destroy` | `(workspacePath: string) => Promise<void>` | yes |
| `list` | `(projectId: string) => Promise<WorkspaceInfo[]>` | yes |
| `postCreate` | `(info: WorkspaceInfo, project: ProjectConfig) => Promise<void>` | optional |
| `exists` | `(workspacePath: string) => Promise<boolean>` | optional |
| `restore` | `(config: WorkspaceCreateConfig, workspacePath: string) => Promise<WorkspaceInfo>` | optional |
### Tracker
| Method | Signature | Required |
|--------|-----------|---------|
| `getIssue` | `(identifier: string, project: ProjectConfig) => Promise<Issue>` | yes |
| `isCompleted` | `(identifier: string, project: ProjectConfig) => Promise<boolean>` | yes |
| `issueUrl` | `(identifier: string, project: ProjectConfig) => string` | yes |
| `branchName` | `(identifier: string, project: ProjectConfig) => string` | yes |
| `generatePrompt` | `(identifier: string, project: ProjectConfig) => Promise<string>` | yes |
| `issueLabel` | `(url: string, project: ProjectConfig) => string` | optional |
| `listIssues` | `(filters: IssueFilters, project: ProjectConfig) => Promise<Issue[]>` | optional |
| `updateIssue` | `(identifier: string, update: IssueUpdate, project: ProjectConfig) => Promise<void>` | optional |
| `createIssue` | `(input: CreateIssueInput, project: ProjectConfig) => Promise<Issue>` | optional |
### SCM
The richest interface — covers PR lifecycle, CI tracking, review tracking, and merge readiness.
**Required:** `detectPR`, `getPRState`, `mergePR`, `closePR`, `getCIChecks`, `getCISummary`, `getReviews`, `getReviewDecision`, `getPendingComments`, `getAutomatedComments`, `getMergeability`
**Optional:** `verifyWebhook`, `parseWebhook`, `resolvePR`, `assignPRToCurrentUser`, `checkoutPR`, `getPRSummary`, `enrichSessionsPRBatch`
### Notifier
| Method | Signature | Required |
|--------|-----------|---------|
| `notify` | `(event: OrchestratorEvent) => Promise<void>` | yes |
| `notifyWithActions` | `(event: OrchestratorEvent, actions: NotifyAction[]) => Promise<void>` | optional |
| `post` | `(message: string, context?: NotifyContext) => Promise<string \| null>` | optional |
### Terminal
| Method | Signature | Required |
|--------|-----------|---------|
| `openSession` | `(session: Session) => Promise<void>` | yes |
| `openAll` | `(sessions: Session[]) => Promise<void>` | yes |
| `isSessionOpen` | `(session: Session) => Promise<boolean>` | optional |
---
## Config validation in `create()`
Validate all config once at load time. Store validated values in a closure. Never re-validate inside individual methods.
```typescript
export function create(config?: Record<string, unknown>): Notifier {
// Resolve config or fall back to an environment variable.
const url = (config?.url as string | undefined) ?? process.env["WEBHOOK_URL"];
if (!url) {
// Warn for missing optional config — don't throw.
// Throw only when a required field is missing at method call time.
console.warn("[notifier-webhook] No url configured — notifications will be no-ops");
} else {
validateUrl(url, "notifier-webhook"); // throws on malformed URL
}
// Custom headers are optional — silently ignore non-string values.
const customHeaders: Record<string, string> = {};
const rawHeaders = config?.headers;
if (rawHeaders && typeof rawHeaders === "object" && !Array.isArray(rawHeaders)) {
for (const [k, v] of Object.entries(rawHeaders)) {
if (typeof v === "string") customHeaders[k] = v;
}
}
return {
name: "webhook",
async notify(event) {
if (!url) return;
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...customHeaders },
body: JSON.stringify({ type: "notification", event }),
});
},
};
}
```
---
## Loading paths
AO resolves plugins through four mechanisms, tried in this order:
### 1. Bundled
Plugins under `packages/plugins/` in the AO monorepo are registered automatically. No config is required.
### 2. Registry / npm (`source: registry` or `source: npm`)
An npm package. AO installs it into its internal plugin cache on first use and keeps the version pinned in `agent-orchestrator.yaml`.
```yaml
plugins:
- name: pagerduty
source: registry
package: "ao-plugin-notifier-pagerduty"
version: "^1.0.0"
```
Use `ao plugin install ao-plugin-notifier-pagerduty` to add this block automatically.
### 3. Local path (`source: local`)
A filesystem path. Useful during development. Path is resolved relative to `agent-orchestrator.yaml`.
```yaml
plugins:
- name: pagerduty
source: local
path: ./plugins/pagerduty
```
AO will import `dist/index.js` (falling back to `index.js`) from that directory.
### 4. Inline shortcut
For `tracker`, `scm`, and `notifier` blocks you can embed the `package` or `path` key directly instead of adding a separate `plugins[]` entry. `collectExternalPluginConfigs` in `packages/core/src/config.ts` auto-promotes these inline references to the `plugins[]` array at load time.
```yaml
projects:
myapp:
tracker:
package: "@acme/ao-plugin-tracker-jira"
version: "^1.0.0"
projectKey: "MYAPP"
```
You can also pass `plugin:` to assert that the loaded plugin's `manifest.name` must match a specific value:
```yaml
projects:
myapp:
tracker:
plugin: jira
package: "@acme/ao-plugin-tracker-jira"
version: "^1.0.0"
```
---
## `ao plugin` subcommands
| Command | Description |
|---------|-------------|
| `ao plugin list` | List plugins from the bundled marketplace catalog. Add `--installed` to show only what's in your config. Filter by slot with `--type <slot>`. Add `--refresh` to pull the latest catalog from the registry. |
| `ao plugin search <query>` | Search the bundled catalog by name, package, description, or slot. |
| `ao plugin create [dir]` | Scaffold a new plugin package interactively. |
| `ao plugin install <reference>` | Install a plugin by marketplace ID, package name, or local path. Writes the entry to `agent-orchestrator.yaml`. |
| `ao plugin update [reference]` | Update an installer-managed plugin. Pass `--all` to update everything. |
| `ao plugin uninstall <reference>` | Remove a plugin from the config. |
---
## Core utilities available to plugins
All utilities are exported from `@aoagents/ao-core`. The source lives in `packages/core/src/index.ts`.
**Shell safety and HTTP:**
| Export | Purpose |
|--------|---------|
| `shellEscape` | Safely escape command-line arguments. Use for every argument passed to child processes. |
| `validateUrl` | Validate a webhook URL and throw a descriptive error on failure. |
**Activity detection (agent plugins):**
| Export | Purpose |
|--------|---------|
| `readLastJsonlEntry` | Efficiently read the last entry from an agent's native JSONL log. |
| `readLastActivityEntry` | Read the last entry from the AO activity JSONL (`{workspace}/.ao/activity.jsonl`). |
| `checkActivityLogState` | Extract `waiting_input` or `blocked` from an activity entry (with staleness cap). Returns `null` for other states. |
| `getActivityFallbackState` | Age-based decay fallback: converts an activity entry into `active` / `ready` / `idle` using entry timestamp. |
| `recordTerminalActivity` | Shared `recordActivity` implementation — classifies, deduplicates, and appends to the activity JSONL. |
| `classifyTerminalActivity` | Classify terminal output via a `detectActivity` function. |
| `appendActivityEntry` | Low-level JSONL append for activity entries. |
**Workspace setup (agent plugins):**
| Export | Purpose |
|--------|---------|
| `setupPathWrapperWorkspace` | Install `~/.ao/bin/gh` and `~/.ao/bin/git` PATH wrappers and write `.ao/AGENTS.md` in the workspace. Required for PATH-wrapper agents (Codex, Aider, OpenCode). |
| `buildAgentPath` | Prepend `~/.ao/bin` to a PATH string. |
| `normalizeAgentPermissionMode` | Normalize legacy permission mode aliases (e.g. `"skip"` → `"permissionless"`). |
**Constants:**
| Export | Value |
|--------|-------|
| `DEFAULT_READY_THRESHOLD_MS` | `300_000` (5 min) — ready → idle threshold |
| `DEFAULT_ACTIVE_WINDOW_MS` | `30_000` (30 s) — active → ready window |
| `ACTIVITY_INPUT_STALENESS_MS` | `300_000` (5 min) — waiting_input/blocked expiry |
| `PREFERRED_GH_PATH` | `/usr/local/bin/gh` |
| `CI_STATUS` | `{ PENDING, PASSING, FAILING, NONE }` |
| `ACTIVITY_STATE` | `{ ACTIVE, READY, IDLE, WAITING_INPUT, BLOCKED, EXITED }` |
| `SESSION_STATUS` | Full session status constant map |
**Types:** `Session`, `ProjectConfig`, `RuntimeHandle` (and everything else in `types.ts`).
---
## Testing
Tests live in `src/__tests__/index.test.ts` and run with Vitest.
Minimum test coverage for every plugin:
- Manifest values (`name`, `slot`, `version`)
- Shape of the object returned by `create()`
- Every public method: happy path, not-found case (returns `null`), error path (throws)
- Config validation: missing required field, invalid value
Mock everything external — CLI binaries, HTTP calls, file I/O:
```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import pluginModule from "../index.js";
vi.mock("node:fs/promises", () => ({ readFile: vi.fn() }));
describe("manifest", () => {
it("has correct slot and name", () => {
expect(pluginModule.manifest.slot).toBe("notifier");
expect(pluginModule.manifest.name).toBe("pagerduty");
});
});
describe("create()", () => {
beforeEach(() => vi.clearAllMocks());
it("throws when routing_key is missing", () => {
expect(() => pluginModule.create({})).toThrow("routing_key");
});
it("returns a notifier with notify()", () => {
const notifier = pluginModule.create({ routing_key: "test-key" });
expect(typeof notifier.notify).toBe("function");
});
});
```
For agent plugins, the required `getActivityState` tests are listed in the CLAUDE.md "Agent Plugin Implementation Standards" section.
---
## Common pitfalls
| Pitfall | Correct approach |
|---------|-----------------|
| Hardcoded secrets (API keys, tokens) | Read from `process.env`, throw if required and missing |
| Shell injection | Use `shellEscape()` for every argument passed to child processes |
| Reading large log files in full | Use `readLastJsonlEntry()` or stream the tail |
| Config validation inside methods | Validate once in `create()`, capture validated values in a closure |
| Silently swallowing errors | Either throw with `{ cause: err }` or return `null` — never no-op |
---
## Agent plugin specifics
Agent plugins have significantly more surface area than other slot types. The most critical method is `getActivityState`, which powers the dashboard, stuck-detection, and the lifecycle manager's reaction engine.
<Callout type="warn">
**Step 4 of the `getActivityState` cascade (`getActivityFallbackState`) is mandatory.** If you skip it, `getActivityState` returns `null` whenever the native API is unavailable (binary not found, session lookup failed, timeout). The dashboard shows no activity state and stuck-detection stops working entirely. This was a real production bug in the OpenCode plugin.
</Callout>
The required 4-step cascade:
```typescript
async getActivityState(session, readyThresholdMs?): Promise<ActivityDetection | null> {
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
// 1. PROCESS CHECK — always first
const running = await this.isProcessRunning(session.runtimeHandle!);
if (!running) return { state: "exited", timestamp: new Date() };
// 2. ACTIONABLE STATES — waiting_input / blocked from JSONL
const activityResult = await readLastActivityEntry(session.workspacePath);
const actionable = checkActivityLogState(activityResult);
if (actionable) return actionable;
// 3. NATIVE SIGNAL — agent-specific API (preferred when available)
try {
const nativeTimestamp = await this.getNativeTimestamp(session);
if (nativeTimestamp) {
const age = Date.now() - nativeTimestamp.getTime();
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
const state = age < activeWindowMs ? "active" : age < threshold ? "ready" : "idle";
return { state, timestamp: nativeTimestamp };
}
} catch {
// fall through to JSONL fallback
}
// 4. JSONL ENTRY FALLBACK — mandatory safety net
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
if (fallback) return fallback;
// 5. No data at all
return null;
}
```
For agents that don't have a native session API (most new agents), skip step 3 and rely entirely on the AO activity JSONL written by `recordActivity`. The full specification — including hook patterns, `isProcessRunning` requirements, and the 7 required test cases — is in the "Agent Plugin Implementation Standards" section of `CLAUDE.md` in the project root.
---
## Next steps
<Cards>
<Card title="Architecture" description="Understand the plugin registry, session lifecycle, and how slots wire together." href="/docs/architecture" />
<Card title="Plugin catalog" description="Browse every bundled plugin grouped by slot." href="/docs/plugins" />
<Card title="Configuration reference" description="How plugins are referenced in agent-orchestrator.yaml." href="/docs/configuration" />
</Cards>