---
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` | Deprecated compatibility export (`300_000`); waiting_input/blocked no longer expire by wallclock |
| `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>
