Authoring a plugin
Write a custom runtime, agent, workspace, tracker, SCM, notifier, or terminal plugin for AO.
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:
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
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
namemust match the suffix of your package name.@acme/ao-plugin-notifier-pagerduty→name: "pagerduty". The registry looks up plugins byslot:namekey.slotmust useas constto preserve the string literal type —"notifier" as const, not"notifier".versionfollows semver. Start at0.1.0.displayNameis optional. When omitted,nameis 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
ao plugin createYou can also pass [directory] as a positional argument, or supply any field with flags to skip individual prompts:
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 exportThe generated src/index.ts looks like this:
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;Implement the slot interface
Replace the placeholder create() body with a real implementation. Import the slot interface from @aoagents/ao-core:
import type { PluginModule, Notifier } from "@aoagents/ao-core";
import { validateUrl } from "@aoagents/ao-core";See Slot interfaces at a glance for the methods you need to implement.
Then build:
npm install
npm run buildRegister it in agent-orchestrator.yaml
For local development, use source: local:
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):
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<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) |
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 for the two patterns (agent-native hooks vs PATH wrappers).
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.
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.
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.
plugins:
- name: pagerduty
source: local
path: ./plugins/pagerdutyAO 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.
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:
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:
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:
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.