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