--- 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`, where `T` is the interface for its slot: ```typescript export interface PluginModule { manifest: PluginManifest; create(config?: Record): 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): Notifier { // validate here, return interface implementation } export function detect(): boolean { return Boolean(process.env["MY_SERVICE_TOKEN"]); } export default { manifest, create, detect } satisfies PluginModule; ``` --- ## 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. ### 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). ### 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) | ### 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) { return { name: manifest.name, config: config ?? {}, // TODO: replace this placeholder with a real notifier implementation. }; }, }; export default plugin; ``` ### 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 ``` ### 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" ``` --- ## 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` | yes | | `destroy` | `(handle: RuntimeHandle) => Promise` | yes | | `sendMessage` | `(handle: RuntimeHandle, message: string) => Promise` | yes | | `getOutput` | `(handle: RuntimeHandle, lines?: number) => Promise` | yes | | `isAlive` | `(handle: RuntimeHandle) => Promise` | yes | | `getMetrics` | `(handle: RuntimeHandle) => Promise` | optional | | `getAttachInfo` | `(handle: RuntimeHandle) => Promise` | 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) | `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). ### Workspace | Method | Signature | Required | |--------|-----------|---------| | `create` | `(config: WorkspaceCreateConfig) => Promise` | yes | | `destroy` | `(workspacePath: string) => Promise` | yes | | `list` | `(projectId: string) => Promise` | yes | | `postCreate` | `(info: WorkspaceInfo, project: ProjectConfig) => Promise` | optional | | `exists` | `(workspacePath: string) => Promise` | optional | | `restore` | `(config: WorkspaceCreateConfig, workspacePath: string) => Promise` | optional | ### Tracker | Method | Signature | Required | |--------|-----------|---------| | `getIssue` | `(identifier: string, project: ProjectConfig) => Promise` | yes | | `isCompleted` | `(identifier: string, project: ProjectConfig) => Promise` | yes | | `issueUrl` | `(identifier: string, project: ProjectConfig) => string` | yes | | `branchName` | `(identifier: string, project: ProjectConfig) => string` | yes | | `generatePrompt` | `(identifier: string, project: ProjectConfig) => Promise` | yes | | `issueLabel` | `(url: string, project: ProjectConfig) => string` | optional | | `listIssues` | `(filters: IssueFilters, project: ProjectConfig) => Promise` | optional | | `updateIssue` | `(identifier: string, update: IssueUpdate, project: ProjectConfig) => Promise` | optional | | `createIssue` | `(input: CreateIssueInput, project: ProjectConfig) => Promise` | 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` | yes | | `notifyWithActions` | `(event: OrchestratorEvent, actions: NotifyAction[]) => Promise` | optional | | `post` | `(message: string, context?: NotifyContext) => Promise` | optional | ### Terminal | Method | Signature | Required | |--------|-----------|---------| | `openSession` | `(session: Session) => Promise` | yes | | `openAll` | `(sessions: Session[]) => Promise` | yes | | `isSessionOpen` | `(session: Session) => Promise` | 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): 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 = {}; 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 `. Add `--refresh` to pull the latest catalog from the registry. | | `ao plugin search ` | Search the bundled catalog by name, package, description, or slot. | | `ao plugin create [dir]` | Scaffold a new plugin package interactively. | | `ao plugin install ` | 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 ` | 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. **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. The required 4-step cascade: ```typescript async getActivityState(session, readyThresholdMs?): Promise { 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