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:
Understand the interface - Read packages/core/src/types.ts to understand the interface you’ll implement
Study examples - Look at existing plugins in packages/plugins/ for patterns
Set up TypeScript - Use TypeScript with strict mode enabled
Plugin Module Structure
Every plugin must export a PluginModule with two properties:
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
};
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
};
}
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
Implementation Example: tmux Runtime
// 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
Implementation Example: Agent Activity Detection
// 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
Implementation Example: Git Worktree
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)
Implementation Example: Desktop Notifier
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
ESM modules - Use "type": "module" in package.json
.js extensions - Include in all local imports: import { foo } from "./bar.js"
node: prefix - Use for built-ins: import { readFile } from "node:fs/promises"
Strict mode - Enable "strict": true in tsconfig
Type imports - Use import type for type-only imports
No any - Use unknown + type guards instead
Prefer const - Use let only for reassignment, never var
Security Requirements
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 /
Always add timeouts
Set timeouts for all external commands: await execFileAsync ( "gh" , [ "pr" , "view" , prNumber ], {
timeout: 30_000 ,
maxBuffer: 10 * 1024 * 1024 ,
});
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 } ` ]);
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
Throw typed errors - Don’t return error codes
Plugins throw - If they can’t do their job
Core services catch - Handle plugin errors gracefully
Wrap JSON.parse - Corrupted data shouldn’t crash
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
Local testing - Install your plugin locally:
cd ~/my-plugin
pnpm link --global
cd ~/agent-orchestrator
pnpm link --global @ao-plugin/runtime-my-plugin
Configure - Add to agent-orchestrator.yaml:
defaults :
runtime : my-plugin
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
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:
Common Patterns
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:
Check existing plugin implementations for patterns
Review packages/core/src/types.ts for interface details
Open an issue on GitHub for questions
Join discussions in the community
Contributions are welcome! Consider submitting your plugin to the official plugin repository via pull request.