AO

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

  • name must match the suffix of your package name. @acme/ao-plugin-notifier-pagerdutyname: "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

ao plugin create

You 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:

PromptExample input
Plugin display namePagerDuty
Plugin slotnotifier (selected from a list of all 7 slots)
Short descriptionRoute urgent alerts to PagerDuty
AuthorAlice
Package nameao-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:

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 build

Register 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

MethodSignatureRequired
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:

MethodPurposeReturns null?
getLaunchCommandShell command to start the agentNo
getEnvironmentEnv vars for the process (must include ~/.ao/bin in PATH)No
detectActivityTerminal-output activity classification (deprecated but required)No
getActivityStateJSONL/API-based activity detectionYes (if no data)
isProcessRunningCheck whether the process is still aliveNo (returns false)
getSessionInfoExtract summary, cost, session ID from agent dataYes

Optional:

MethodPurposeWhen to skip
getRestoreCommandResume a previous sessionAgent has no resume capability
setupWorkspaceHooksInstall metadata hooks for PR trackingNever — required for the dashboard
postLaunchSetupPost-launch configOnly if no post-launch work is needed
recordActivityWrite terminal-derived activity to JSONLAgent 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

MethodSignatureRequired
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

MethodSignatureRequired
getIssue(identifier: string, project: ProjectConfig) => Promise<Issue>yes
isCompleted(identifier: string, project: ProjectConfig) => Promise<boolean>yes
issueUrl(identifier: string, project: ProjectConfig) => stringyes
branchName(identifier: string, project: ProjectConfig) => stringyes
generatePrompt(identifier: string, project: ProjectConfig) => Promise<string>yes
issueLabel(url: string, project: ProjectConfig) => stringoptional
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

MethodSignatureRequired
notify(event: OrchestratorEvent) => Promise<void>yes
notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]) => Promise<void>optional
post(message: string, context?: NotifyContext) => Promise<string | null>optional

Terminal

MethodSignatureRequired
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/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.

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

CommandDescription
ao plugin listList 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:

ExportPurpose
shellEscapeSafely escape command-line arguments. Use for every argument passed to child processes.
validateUrlValidate a webhook URL and throw a descriptive error on failure.

Activity detection (agent plugins):

ExportPurpose
readLastJsonlEntryEfficiently read the last entry from an agent's native JSONL log.
readLastActivityEntryRead the last entry from the AO activity JSONL ({workspace}/.ao/activity.jsonl).
checkActivityLogStateExtract waiting_input or blocked from an activity entry (with staleness cap). Returns null for other states.
getActivityFallbackStateAge-based decay fallback: converts an activity entry into active / ready / idle using entry timestamp.
recordTerminalActivityShared recordActivity implementation — classifies, deduplicates, and appends to the activity JSONL.
classifyTerminalActivityClassify terminal output via a detectActivity function.
appendActivityEntryLow-level JSONL append for activity entries.

Workspace setup (agent plugins):

ExportPurpose
setupPathWrapperWorkspaceInstall ~/.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).
buildAgentPathPrepend ~/.ao/bin to a PATH string.
normalizeAgentPermissionModeNormalize legacy permission mode aliases (e.g. "skip""permissionless").

Constants:

ExportValue
DEFAULT_READY_THRESHOLD_MS300_000 (5 min) — ready → idle threshold
DEFAULT_ACTIVE_WINDOW_MS30_000 (30 s) — active → ready window
ACTIVITY_INPUT_STALENESS_MS300_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_STATUSFull 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

PitfallCorrect approach
Hardcoded secrets (API keys, tokens)Read from process.env, throw if required and missing
Shell injectionUse shellEscape() for every argument passed to child processes
Reading large log files in fullUse readLastJsonlEntry() or stream the tail
Config validation inside methodsValidate once in create(), capture validated values in a closure
Silently swallowing errorsEither 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.


Next steps