Skip to main content

Plugin Development Guide

Creating a plugin for Agent Orchestrator is straightforward: implement one of the core interfaces and export it as a PluginModule. This guide walks you through the process.

Prerequisites

Before creating a plugin:
  1. Understand the interface - Read packages/core/src/types.ts to understand the interface you’ll implement
  2. Study examples - Look at existing plugins in packages/plugins/ for patterns
  3. Set up TypeScript - Use TypeScript with strict mode enabled

Plugin Module Structure

Every plugin must export a PluginModule with two properties:
1

Manifest

Plugin metadata describing the plugin:
export const manifest = {
  name: "my-plugin",              // Plugin name (unique within slot)
  slot: "runtime" as const,       // Which slot this plugin fills
  description: "My custom plugin", // Human-readable description
  version: "0.1.0",               // Semantic version
};
2

Create Function

Factory function that returns the plugin implementation:
export function create(config?: Record<string, unknown>): Runtime {
  // Optional: extract config values
  const timeout = config?.timeout as number ?? 30_000;
  
  // Return interface implementation
  return {
    name: "my-plugin",
    async create(config) { /* ... */ },
    async destroy(handle) { /* ... */ },
    // ... implement all interface methods
  };
}
3

Default Export with Type Safety

Export the module with type checking:
export default { manifest, create } satisfies PluginModule<Runtime>;
The satisfies operator ensures compile-time type safety without type widening.

Complete Plugin Template

Here’s a complete plugin template for a Runtime plugin:
import type { 
  PluginModule, 
  Runtime,
  RuntimeCreateConfig,
  RuntimeHandle,
  RuntimeMetrics,
  AttachInfo,
} from "@composio/ao-core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

// =============================================================================
// Manifest
// =============================================================================

export const manifest = {
  name: "my-runtime",
  slot: "runtime" as const,
  description: "My custom runtime plugin",
  version: "0.1.0",
};

// =============================================================================
// Implementation
// =============================================================================

export function create(config?: Record<string, unknown>): Runtime {
  // Extract and validate config
  const timeout = typeof config?.timeout === "number" ? config.timeout : 30_000;
  
  return {
    name: "my-runtime",

    async create(config: RuntimeCreateConfig): Promise<RuntimeHandle> {
      // 1. Create execution environment
      // 2. Launch the agent with config.launchCommand
      // 3. Return handle for future operations
      
      return {
        id: "unique-session-id",
        runtimeName: "my-runtime",
        data: {
          createdAt: Date.now(),
          // Store runtime-specific data here
        },
      };
    },

    async destroy(handle: RuntimeHandle): Promise<void> {
      // Clean up the execution environment
      // Best-effort cleanup - don't throw if already dead
    },

    async sendMessage(handle: RuntimeHandle, message: string): Promise<void> {
      // Send a message to the running agent
      // This is how the orchestrator communicates with agents
    },

    async getOutput(handle: RuntimeHandle, lines = 50): Promise<string> {
      // Capture recent output from the session
      // Used for activity detection and debugging
      return "";
    },

    async isAlive(handle: RuntimeHandle): Promise<boolean> {
      // Check if the session environment is still running
      return false;
    },

    async getMetrics(handle: RuntimeHandle): Promise<RuntimeMetrics> {
      // Optional: return resource usage metrics
      return {
        uptimeMs: Date.now() - (handle.data.createdAt as number),
      };
    },

    async getAttachInfo(handle: RuntimeHandle): Promise<AttachInfo> {
      // Optional: return info for Terminal plugin to attach humans
      return {
        type: "process",
        target: handle.id,
        command: `attach-to ${handle.id}`,
      };
    },
  };
}

// =============================================================================
// Plugin Export
// =============================================================================

export default { manifest, create } satisfies PluginModule<Runtime>;

Interface Requirements by Slot

Runtime Plugin

Required methods:
  • create(config) - Create a new execution environment
  • destroy(handle) - Destroy the environment
  • sendMessage(handle, message) - Send input to the agent
  • getOutput(handle, lines?) - Capture recent output
  • isAlive(handle) - Check if environment is alive
Optional methods:
  • getMetrics(handle) - Return resource metrics
  • getAttachInfo(handle) - Return attachment info for Terminal plugin
// packages/plugins/runtime-tmux/src/index.ts
export function create(): Runtime {
  return {
    name: "tmux",
    
    async create(config: RuntimeCreateConfig): Promise<RuntimeHandle> {
      const sessionName = config.sessionId;
      
      // Build environment flags
      const envArgs: string[] = [];
      for (const [key, value] of Object.entries(config.environment ?? {})) {
        envArgs.push("-e", `${key}=${value}`);
      }
      
      // Create tmux session
      await tmux("new-session", "-d", "-s", sessionName, 
                 "-c", config.workspacePath, ...envArgs);
      
      // Send launch command
      await tmux("send-keys", "-t", sessionName, 
                 config.launchCommand, "Enter");
      
      return {
        id: sessionName,
        runtimeName: "tmux",
        data: { createdAt: Date.now() },
      };
    },
    
    async destroy(handle: RuntimeHandle): Promise<void> {
      try {
        await tmux("kill-session", "-t", handle.id);
      } catch {
        // Session may already be dead
      }
    },
    
    // ... other methods
  };
}

Agent Plugin

Required methods:
  • getLaunchCommand(config) - Generate shell command to launch agent
  • getEnvironment(config) - Return environment variables
  • detectActivity(terminalOutput) - Classify activity from output (deprecated)
  • getActivityState(session, threshold?) - Get current activity state
  • isProcessRunning(handle) - Check if agent process is alive
  • getSessionInfo(session) - Extract summary and cost info
Optional methods:
  • getRestoreCommand(session, project) - Generate command to resume session
  • postLaunchSetup(session) - Run setup after agent launches
  • setupWorkspaceHooks(workspacePath, config) - Install hooks for metadata updates
Properties:
  • name - Plugin name
  • processName - Process name to look for (e.g., “claude”, “aider”)
  • promptDelivery - “inline” or “post-launch” for prompt delivery
// Activity detection using agent-native mechanisms
async getActivityState(
  session: Session,
  readyThresholdMs = DEFAULT_READY_THRESHOLD_MS
): Promise<ActivityDetection | null> {
  // Check if process is running
  if (!session.runtimeHandle) {
    return { state: "exited", timestamp: new Date() };
  }
  
  const running = await this.isProcessRunning(session.runtimeHandle);
  if (!running) {
    return { state: "exited", timestamp: new Date() };
  }
  
  // Read agent's session file (JSONL, SQLite, etc.)
  const sessionFile = await findLatestSessionFile(session.workspacePath);
  if (!sessionFile) return null;
  
  const entry = await readLastJsonlEntry(sessionFile);
  if (!entry) return null;
  
  const ageMs = Date.now() - entry.modifiedAt.getTime();
  const timestamp = entry.modifiedAt;
  
  // Classify based on last entry type
  switch (entry.lastType) {
    case "user":
    case "tool_use":
      return { state: ageMs > readyThresholdMs ? "idle" : "active", timestamp };
    
    case "assistant":
    case "result":
      return { state: ageMs > readyThresholdMs ? "idle" : "ready", timestamp };
    
    case "permission_request":
      return { state: "waiting_input", timestamp };
    
    case "error":
      return { state: "blocked", timestamp };
    
    default:
      return { state: "active", timestamp };
  }
}

Workspace Plugin

Required methods:
  • create(config) - Create isolated workspace
  • destroy(workspacePath) - Remove workspace
  • list(projectId) - List existing workspaces
Optional methods:
  • postCreate(info, project) - Run hooks after creation (symlinks, installs)
  • exists(workspacePath) - Check if workspace is valid
  • restore(config, workspacePath) - Recreate workspace from existing data
export function create(config?: Record<string, unknown>): Workspace {
  const worktreeBaseDir = config?.worktreeDir 
    ? expandPath(config.worktreeDir as string)
    : join(homedir(), ".worktrees");
  
  return {
    name: "worktree",
    
    async create(cfg: WorkspaceCreateConfig): Promise<WorkspaceInfo> {
      const repoPath = expandPath(cfg.project.path);
      const worktreePath = join(worktreeBaseDir, cfg.projectId, cfg.sessionId);
      
      // Fetch latest
      await git(repoPath, "fetch", "origin", "--quiet");
      
      const baseRef = `origin/${cfg.project.defaultBranch}`;
      
      // Create worktree with new branch
      await git(repoPath, "worktree", "add", "-b", cfg.branch, 
                worktreePath, baseRef);
      
      return {
        path: worktreePath,
        branch: cfg.branch,
        sessionId: cfg.sessionId,
        projectId: cfg.projectId,
      };
    },
    
    async destroy(workspacePath: string): Promise<void> {
      const gitCommonDir = await git(workspacePath, "rev-parse", 
                                     "--git-common-dir");
      const repoPath = resolve(gitCommonDir, "..");
      await git(repoPath, "worktree", "remove", "--force", workspacePath);
    },
    
    // ... other methods
  };
}

Tracker Plugin

Required methods:
  • getIssue(identifier, project) - Fetch issue details
  • isCompleted(identifier, project) - Check if issue is closed
  • issueUrl(identifier, project) - Generate issue URL
  • branchName(identifier, project) - Generate branch name from issue
  • generatePrompt(identifier, project) - Generate agent prompt
Optional methods:
  • issueLabel(url, project) - Extract human-readable label from URL
  • listIssues(filters, project) - List issues with filters
  • updateIssue(identifier, update, project) - Update issue state
  • createIssue(input, project) - Create new issue

SCM Plugin

Required methods:
  • detectPR(session, project) - Detect PR by branch name
  • getPRState(pr) - Get PR state (open/merged/closed)
  • mergePR(pr, method?) - Merge a PR
  • closePR(pr) - Close PR without merging
  • getCIChecks(pr) - Get individual CI checks
  • getCISummary(pr) - Get overall CI status
  • getReviews(pr) - Get all reviews
  • getReviewDecision(pr) - Get overall review decision
  • getPendingComments(pr) - Get unresolved comments
  • getAutomatedComments(pr) - Get bot comments
  • getMergeability(pr) - Check merge readiness
Optional methods:
  • getPRSummary(pr) - Get PR summary with stats

Notifier Plugin

Required methods:
  • notify(event) - Push notification to human
Optional methods:
  • notifyWithActions(event, actions) - Notification with action buttons
  • post(message, context?) - Post to channel (for team notifiers)
export function create(config?: Record<string, unknown>): Notifier {
  const soundEnabled = typeof config?.sound === "boolean" ? config.sound : true;
  
  return {
    name: "desktop",
    
    async notify(event: OrchestratorEvent): Promise<void> {
      const title = `Agent Orchestrator [${event.sessionId}]`;
      const message = event.message;
      const sound = event.priority === "urgent" && soundEnabled;
      
      if (platform() === "darwin") {
        const safeTitle = escapeAppleScript(title);
        const safeMessage = escapeAppleScript(message);
        const soundClause = sound ? ' sound name "default"' : "";
        const script = `display notification "${safeMessage}" with title "${safeTitle}"${soundClause}`;
        await execFileAsync("osascript", ["-e", script]);
      } else if (platform() === "linux") {
        const args = event.priority === "urgent" ? ["--urgency=critical"] : [];
        args.push(title, message);
        await execFileAsync("notify-send", args);
      }
    },
  };
}

Terminal Plugin

Required methods:
  • openSession(session) - Open single session for human
  • openAll(sessions) - Open all sessions for a project
Optional methods:
  • isSessionOpen(session) - Check if session is already open

Code Conventions

These conventions are enforced by ESLint and Prettier. Follow them to avoid CI failures.

TypeScript Standards

  1. ESM modules - Use "type": "module" in package.json
  2. .js extensions - Include in all local imports: import { foo } from "./bar.js"
  3. node: prefix - Use for built-ins: import { readFile } from "node:fs/promises"
  4. Strict mode - Enable "strict": true in tsconfig
  5. Type imports - Use import type for type-only imports
  6. No any - Use unknown + type guards instead
  7. Prefer const - Use let only for reassignment, never var

Security Requirements

1

Always use execFile

NEVER use exec - it’s vulnerable to shell injection.
// GOOD
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);

await execFileAsync("git", ["branch", branchName], { timeout: 30_000 });

// BAD - shell injection risk
exec(`git branch ${branchName}`); // branchName could contain ; rm -rf /
2

Always add timeouts

Set timeouts for all external commands:
await execFileAsync("gh", ["pr", "view", prNumber], { 
  timeout: 30_000,
  maxBuffer: 10 * 1024 * 1024,
});
3

Never interpolate user input

Pass user input as array arguments, not string templates:
// GOOD
await execFileAsync("git", ["checkout", "-b", branchName]);

// BAD
await execFileAsync("sh", ["-c", `git checkout -b ${branchName}`]);
4

Validate external data

Guard against malformed API responses:
try {
  const data: unknown = JSON.parse(raw);
  if (typeof data !== "object" || data === null) {
    throw new Error("Invalid response format");
  }
  // ... validate structure
} catch (err) {
  throw new Error(`Failed to parse response: ${err}`);
}

Error Handling

  1. Throw typed errors - Don’t return error codes
  2. Plugins throw - If they can’t do their job
  3. Core services catch - Handle plugin errors gracefully
  4. Wrap JSON.parse - Corrupted data shouldn’t crash
  5. Best-effort cleanup - Don’t throw during cleanup/destroy
async destroy(handle: RuntimeHandle): Promise<void> {
  try {
    await execFileAsync("docker", ["rm", "-f", handle.id]);
  } catch {
    // Container may already be gone - that's fine
  }
}

Testing Your Plugin

Unit Tests

Create tests in __tests__/ or co-located .test.ts files:
import { describe, it, expect } from "vitest";
import { create, manifest } from "./index.js";

describe("my-plugin", () => {
  it("exports correct manifest", () => {
    expect(manifest.name).toBe("my-plugin");
    expect(manifest.slot).toBe("runtime");
  });
  
  it("creates valid instance", () => {
    const plugin = create();
    expect(plugin.name).toBe("my-plugin");
  });
  
  // Add more tests...
});

Integration Testing

  1. Local testing - Install your plugin locally:
    cd ~/my-plugin
    pnpm link --global
    
    cd ~/agent-orchestrator
    pnpm link --global @ao-plugin/runtime-my-plugin
    
  2. Configure - Add to agent-orchestrator.yaml:
    defaults:
      runtime: my-plugin
    
  3. Test spawn - Spawn a session:
    ao spawn my-project 123
    ao status
    

Publishing Your Plugin

Package Structure

ao-plugin-runtime-my-plugin/
├── package.json
├── tsconfig.json
├── src/
│   └── index.ts
├── dist/           # Built output
│   └── index.js
└── README.md

package.json

{
  "name": "@ao-plugin/runtime-my-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest"
  },
  "dependencies": {
    "@composio/ao-core": "^0.1.0"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "vitest": "^1.0.0"
  },
  "peerDependencies": {
    "@composio/ao-core": "^0.1.0"
  }
}

Publishing to npm

1

Build

pnpm build
2

Test

pnpm test
3

Publish

npm publish --access public

Using Published Plugin

Users can install and use your plugin:
pnpm add @ao-plugin/runtime-my-plugin
# agent-orchestrator.yaml
defaults:
  runtime: my-plugin

Plugin Checklist

Before publishing, verify:
  • Implements complete interface (no missing methods)
  • Uses satisfies PluginModule<T> for type safety
  • Follows security conventions (execFile, timeouts, no interpolation)
  • Includes error handling (try/catch, typed errors)
  • Has unit tests covering core functionality
  • Tested locally with real Agent Orchestrator config
  • Has README with usage examples
  • Uses semantic versioning
  • Declares @composio/ao-core as peer dependency

Common Patterns

Config Extraction

export function create(config?: Record<string, unknown>): MyPlugin {
  // Extract with defaults
  const timeout = typeof config?.timeout === "number" ? config.timeout : 30_000;
  const apiKey = typeof config?.apiKey === "string" ? config.apiKey : process.env.MY_API_KEY;
  
  if (!apiKey) {
    throw new Error("API key required: set MY_API_KEY environment variable");
  }
  
  return { /* ... */ };
}

Process Execution

import { execFile } from "node:child_process";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);

async function runCommand(args: string[]): Promise<string> {
  try {
    const { stdout } = await execFileAsync("mycommand", args, { 
      timeout: 30_000,
      maxBuffer: 10 * 1024 * 1024,
    });
    return stdout.trim();
  } catch (err) {
    throw new Error(`Command failed: ${(err as Error).message}`, { cause: err });
  }
}

Caching

let cache: { data: string; timestamp: number } | null = null;
const CACHE_TTL_MS = 5_000;

async function getCachedData(): Promise<string> {
  const now = Date.now();
  if (cache && now - cache.timestamp < CACHE_TTL_MS) {
    return cache.data;
  }
  
  const data = await fetchExpensiveData();
  cache = { data, timestamp: now };
  return data;
}

Path Safety

const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/;

function assertSafePathSegment(value: string, label: string): void {
  if (!SAFE_PATH_SEGMENT.test(value)) {
    throw new Error(`Invalid ${label} "${value}": must match ${SAFE_PATH_SEGMENT}`);
  }
}

// Usage
assertSafePathSegment(sessionId, "sessionId");
const path = join(baseDir, sessionId); // Safe - no directory traversal

Resources

Core Types

Complete interface definitions

Example Plugins

Browse built-in plugin implementations

CLAUDE.md

Code conventions and architecture

Community Plugins

Discover community plugins

Getting Help

If you run into issues:
  1. Check existing plugin implementations for patterns
  2. Review packages/core/src/types.ts for interface details
  3. Open an issue on GitHub for questions
  4. Join discussions in the community
Contributions are welcome! Consider submitting your plugin to the official plugin repository via pull request.

Build docs developers (and LLMs) love