Agent Adapter Contract
June 26, 2026 · View on GitHub
This document defines the contract between Agent Orchestrator and CLI-based coding agent adapters. Every agent must implement the contract defined in backend/internal/ports/agent.go to be usable by AO.
Table of Contents
- Overview
- Agent Contract
- Hook System
- Session Metadata
- Agent Lifecycle
- Implementation Guide
- Supported Agents
- Testing
Overview
Agent adapters let AO run and observe different CLI coding agents without hardcoding agent-specific behavior into the spawn engine. The adapter contract provides:
- Agent discovery — Find and validate the agent binary
- Launch configuration — Build the native agent command
- Session restoration — Resume existing sessions
- Hook integration — Install and manage agent hooks
- Metadata extraction — Surface session title, summary, and native session ID
graph TB
AO[Agent Orchestrator] -->|uses| Adapter[Agent Adapter]
Adapter -->|implements| Contract[ports.Agent Contract]
Contract --> Methods[Required Methods]
Methods --> GetConfig[GetConfigSpec]
Methods --> GetLaunch[GetLaunchCommand]
Methods --> GetStrategy[GetPromptDeliveryStrategy]
Methods --> GetHooks[GetAgentHooks]
Methods --> GetRestore[GetRestoreCommand]
Methods --> SessionInfo[SessionInfo]
Methods --> Uninstall[UninstallHooks]
Adapter --> Agent[Native CLI Agent]
Adapter --> Hooks[Agent Hook Config]
Agent Contract
Port Interface
The ports.Agent interface (defined in backend/internal/ports/agent.go) specifies the required adapter methods:
// Agent is the interface all coding agent adapters must implement.
type Agent interface {
// GetConfigSpec describes user-facing agent configuration.
GetConfigSpec() ConfigSpec
// GetLaunchCommand builds the native agent command for spawning a new session.
GetLaunchCommand(ctx context.Context, cfg LaunchConfig) ([]string, error)
// GetPromptDeliveryStrategy reports how the prompt is delivered.
GetPromptDeliveryStrategy() PromptDeliveryStrategy
// GetAgentHooks installs or merges AO hooks into the agent's workspace-local config.
GetAgentHooks(ctx context.Context, cfg WorkspaceHookConfig) error
// GetRestoreCommand builds a command to resume an existing session.
GetRestoreCommand(ctx context.Context, cfg RestoreConfig) ([]string, bool, error)
// SessionInfo returns normalized session metadata from the session record.
SessionInfo(ctx context.Context, session SessionRef) (SessionInfo, bool, error)
// UninstallHooks removes AO hooks from the workspace.
UninstallHooks(ctx context.Context, workspacePath string) error
}
Method Details
GetConfigSpec()
Returns a specification of user-facing configuration options:
type ConfigSpec struct {
// Permissions is the permission mode the agent supports.
Permissions PermissionMode
// Model is the optional model identifier (e.g., "claude-3-5-sonnet-20241022").
Model string
// SupportsSystemPrompt indicates the agent accepts a system prompt.
SupportsSystemPrompt bool
// SupportsRestore indicates the agent supports session restoration.
SupportsRestore bool
}
GetLaunchCommand()
Builds the native agent launch command:
flowchart LR
Input[LaunchConfig] --> Validate[Validate Config]
Validate --> Resolve[Resolve Binary]
Resolve --> Build[Build Command]
Build --> Apply[Apply Permissions]
Apply --> Model[Add Model Flag]
Model --> System[Add System Prompt]
System --> Prompt[Add Prompt]
Prompt --> Output[Launch Command]
Input (LaunchConfig):
SessionID— AO session identifierProjectID— AO project identifierPrompt— User prompt to sendConfig— Agent-specific configurationPermissions— Permission mode overrideWorkspacePath— Path to session worktree
Output: Executable command line as argv array
GetPromptDeliveryStrategy()
Reports how the prompt is delivered to the agent:
type PromptDeliveryStrategy string
const (
// StrategyArgv means the prompt is passed as a command-line argument.
StrategyArgv PromptDeliveryStrategy = "argv"
// StrategyStdin means the prompt is written to stdin after launch.
StrategyStdin PromptDeliveryStrategy = "stdin"
)
GetAgentHooks()
Installs AO hooks into the agent's workspace-local configuration:
flowchart TD
Input[WorkspaceHookConfig] --> Read[Read Existing Config]
Read --> Merge[Merge AO Hooks]
Merge --> Dedup[Deduplicate Entries]
Dedupe --> Preserve[Preserve User Hooks]
Preserve --> Write[Write Updated Config]
Write --> Gitignore[Update .gitignore]
Requirements:
- Preserve all user hooks
- Deduplicate AO hook entries
- Make AO hooks machine-portable (use
ao hooks ...command) - Ensure all written files are covered by
.gitignore
GetRestoreCommand()
Builds a command to resume an existing session:
Input (RestoreConfig):
Session— Complete session record with metadataPermissions— Permission mode to applySystemPrompt— System prompt to re-apply
Output:
cmd— Restore command argvok— True if restore is supportederr— Error if restore fails
SessionInfo()
Returns normalized session metadata from the stored session record:
type SessionInfo struct {
// AgentSessionID is the native agent's session identifier.
AgentSessionID string
// Title is the display title derived from the first user prompt.
Title string
// Summary is the display summary derived from the final assistant message.
Summary string
// Metadata contains adapter-specific extra fields.
Metadata map[string]any
}
Important: SessionInfo must read from the session.Metadata map (populated by hooks), not from scanning agent transcript/cache files.
UninstallHooks()
Removes AO hooks from the workspace while preserving user hooks.
Hook System
Hook Overview
AO uses agent hooks to receive activity signals and session metadata from running agents:
sequenceDiagram
participant AO as AO Daemon
participant Adapter as Agent Adapter
participant Agent as Native Agent
participant Hook as Hook Command
Note over AO: Spawning session
AO->>Adapter: GetAgentHooks(config)
Adapter->>Hook: Write hook config
Hook-->>Adapter: Config written
AO->>Agent: Launch agent
Agent->>Agent: Running in worktree
Note over Agent: Agent triggers event
Agent->>Hook: Execute hook callback
Hook->>Hook: Read stdin payload
Hook->>AO: POST /api/v1/sessions/{id}/activity
AO->>AO: Update activity_state
AO->>Hook: 200 OK
Hook-->>Agent: Exit 0
Agent->>Agent: Continue work
Hook Events
AO supports hooks for these agent events:
| Event | Purpose | Activity State |
|---|---|---|
SessionStart | Session initialized | - |
UserPromptSubmit | User submitted prompt | active |
AssistantMessage | Assistant response | - |
ToolUse | Agent used a tool | active |
Stop | Session stopped | idle or exited |
Hook Contract
All hook callbacks follow this contract:
# Hook command format
ao hooks <agent-adapter> <event> <session-id>
# Input (stdin)
# JSON payload from agent (agent-specific format)
# Output (stdout)
# None (discarded)
# Exit code
# 0 = success (always, even on delivery failure)
Hook Behavior
flowchart TD
Trigger[Agent Event Triggered] --> Execute[Execute Hook Command]
Execute --> ReadEnv[Read AO_SESSION_ID<br/>from environment]
ReadEnv --> CheckSession{Is AO Session?}
CheckSession -->|No| Exit[Exit 0 - ignore]
CheckSession -->|Yes| ReadStdin[Read JSON Payload<br/>from stdin]
ReadStdin --> Derive[Derive Activity State<br/>from event]
Derive --> Post[POST to Daemon<br/>/api/v1/sessions/{id}/activity]
Post --> Success{Success?}
Success -->|Yes| Update[Daemon updates<br/>activity_state]
Success -->|No| Log[Log to hooks.log<br/>under AO_DATA_DIR]
Update --> Exit
Log --> Exit
Critical rules:
- Always exit 0 — Hook failure must never break the user's agent
- Log failures — Append to
hooks.logunderAO_DATA_DIR - Best-effort delivery — The daemon may be temporarily unavailable
- Idempotent — Duplicate hook deliveries are safe
Hook Installation Patterns
Different agents use different hook mechanisms:
Claude Code / Compatible Agents
// .claude/hooks.json
{
"SessionStart": ["ao hooks claude-code SessionStart"],
"UserPromptSubmit": ["ao hooks claude-code UserPromptSubmit"],
"Stop": ["ao hooks claude-code Stop"]
}
Factory Droid
// .factory/hooks.json
{
"hooks": [
{
"event": "agent:beforeThinking",
"command": "ao hooks droid beforeThinking"
},
{
"event": "agent:afterThinking",
"command": "ao hooks droid afterThinking"
}
]
}
Codex (Session Flags)
# Codex passes hooks as session flags
codex -c 'hooks.SessionStart=["ao hooks codex SessionStart"]' \
-c 'hooks.Stop=["ao hooks codex Stop"]' \
--dangerously-bypass-hook-trust
Session Metadata
Metadata Keys
Hook callbacks persist normalized keys in the session metadata JSON blob:
const (
// MetadataKeyAgentSessionID is the native agent's session identifier.
MetadataKeyAgentSessionID = "agentSessionId"
// MetadataKeyTitle is the display title from the first user prompt.
MetadataKeyTitle = "title"
// MetadataKeySummary is the display summary from the final assistant message.
MetadataKeySummary = "summary"
)
Metadata Flow
sequenceDiagram
participant Hook as Hook Callback
participant Daemon as AO Daemon
participant Store as SQLite Store
participant UI as Dashboard
Note over Hook: User submits first prompt
Hook->>Daemon: POST /activity<br/>{state: "active", title: "..."}
Daemon->>Store: Update session metadata<br/>metadata.title = "..."
Store->>Store: CDC: session.updated
Store->>UI: SSE: session.updated
UI->>UI: Update session title
Note over Hook: Agent finishes work
Hook->>Daemon: POST /activity<br/>{state: "idle", summary: "..."}
Daemon->>Store: Update session metadata<br/>metadata.summary = "..."
Store->>Store: CDC: session.updated
Store->>UI: SSE: session.updated
UI->>UI: Update session summary
Metadata Persistence
flowchart LR
Hook[Hook Callback] --> POST[POST Activity]
POST --> LCM[Lifecycle Manager]
LCM --> Update[Update Session Record]
Update --> Metadata[Set Metadata Fields]
Metadata --> CDC[CDC Broadcast]
CDC --> UI[Dashboard Updates]
Agent Lifecycle
Spawn Flow
sequenceDiagram
participant User as User
participant UI as Dashboard
participant API as HTTP API
participant Mgr as Session Manager
participant Adapter as Agent Adapter
participant Runtime as Runtime
participant Agent as Agent Process
User->>UI: Click "Spawn Session"
UI->>API: POST /sessions
API->>Mgr: Spawn(config)
Note over Mgr: 1. Create session row
Mgr->>Mgr: Insert session with empty metadata
Note over Mgr: 2. Prepare workspace
Mgr->>Adapter: GetAgentHooks(config)
Adapter->>Adapter: Write hook config
Adapter-->>Mgr: Hooks installed
Note over Mgr: 3. Build launch command
Mgr->>Adapter: GetLaunchCommand(config)
Adapter-->>Mgr: Launch command argv
Note over Mgr: 4. Execute in runtime
Mgr->>Runtime: Execute(agent command)
Runtime->>Agent: Spawn process
Note over Agent: Agent starts
Agent->>Agent: Execute SessionStart hook
Agent->>API: POST /activity (agentSessionId)
API->>API: Update metadata
Agent-->>Runtime: Running
Runtime-->>Mgr: Session active
Mgr-->>API: Session response
API-->>UI: Session created
Restore Flow
sequenceDiagram
participant User as User
participant UI as Dashboard
participant API as HTTP API
participant Mgr as Session Manager
participant Adapter as Agent Adapter
participant Runtime as Runtime
participant Agent as Agent Process
User->>UI: Click "Restore Session"
UI->>API: POST /sessions/{id}/restore
API->>Mgr: Restore(session)
Note over Mgr: Check adapter supports restore
Mgr->>Adapter: GetRestoreCommand(config)
alt Restore supported
Adapter-->>Mgr: Restore command
Mgr->>Runtime: Execute(restore command)
Runtime->>Agent: Spawn process
Agent-->>Mgr: Running
Mgr-->>API: Session restored
else Restore not supported
Adapter-->>Mgr: (cmd, false, nil)
Mgr->>Mgr: Fresh spawn instead
Mgr-->>API: Session created (new)
end
API-->>UI: Session response
Activity Flow
stateDiagram-v2
[*] --> Spawning: Spawn()
Spawning --> Idle: Agent starts
Idle --> Active: User prompt
Active --> Active: Agent working
Active --> Waiting: Needs input
Waiting --> Active: User responds
Active --> Idle: Agent completes
Idle --> Exited: Agent exits
Active --> Exited: Agent crashes
Exited --> [*]
note right of Active
Hook: UserPromptSubmit
Hook: ToolUse
activity_state = "active"
end note
note right of Waiting
Hook: WaitingForInput
activity_state = "waiting_input"
end note
note right of Idle
Hook: Stop
activity_state = "idle"
end note
note right of Exited
Hook: Stop
activity_state = "exited"
is_terminated = true
end note
Implementation Guide
Adapter Template
package myagent
import (
"context"
"fmt"
)
// Plugin is the MyAgent adapter.
type Plugin struct {
// Adapter state (e.g., resolved binary path)
}
// New returns a ready-to-register MyAgent adapter.
func New() *Plugin {
return &Plugin{}
}
// GetConfigSpec describes the agent configuration.
func (p *Plugin) GetConfigSpec() ports.ConfigSpec {
return ports.ConfigSpec{
Permissions: ports.PermissionReadWrite, // or ReadOnly, WriteOnly
SupportsSystemPrompt: true,
SupportsRestore: true,
}
}
// GetLaunchCommand builds the native agent command.
func (p *Plugin) GetLaunchCommand(ctx context.Context, cfg ports.LaunchConfig) ([]string, error) {
// 1. Validate config
if err := cfg.Config.Validate(); err != nil {
return nil, fmt.Errorf("myagent: %w", err)
}
// 2. Resolve binary
binary, err := p.resolveBinary(ctx)
if err != nil {
return nil, err
}
// 3. Build command
cmd := []string{binary}
// Add flags (model, permissions, etc.)
if cfg.Config.Model != "" {
cmd = append(cmd, "--model", cfg.Config.Model)
}
// Add system prompt if supported
if cfg.SystemPrompt != "" {
cmd = append(cmd, "--system-prompt", cfg.SystemPrompt)
}
// Add prompt
if cfg.Prompt != "" {
cmd = append(cmd, "--prompt", cfg.Prompt)
}
return cmd, nil
}
// GetPromptDeliveryStrategy reports how the prompt is delivered.
func (p *Plugin) GetPromptDeliveryStrategy() ports.PromptDeliveryStrategy {
return ports.StrategyArgv // or StrategyStdin
}
// GetAgentHooks installs AO hooks.
func (p *Plugin) GetAgentHooks(ctx context.Context, cfg ports.WorkspaceHookConfig) error {
// 1. Read existing config
config, err := p.readConfig(cfg.WorkspacePath)
if err != nil {
return err
}
// 2. Add AO hooks (preserving user hooks)
config = p.addHooks(config, cfg.SessionID)
// 3. Write updated config
if err := p.writeConfig(cfg.WorkspacePath, config); err != nil {
return err
}
// 4. Ensure .gitignore coverage
if err := p.ensureGitignore(cfg.WorkspacePath); err != nil {
return err
}
return nil
}
// GetRestoreCommand builds a restore command.
func (p *Plugin) GetRestoreCommand(ctx context.Context, cfg ports.RestoreConfig) ([]string, bool, error) {
// 1. Extract native session ID from metadata
sessionID := cfg.Session.Metadata[ports.MetadataKeyAgentSessionID]
if sessionID == "" {
return nil, false, nil // Restore not supported
}
// 2. Build restore command
binary, err := p.resolveBinary(ctx)
if err != nil {
return nil, false, err
}
cmd := []string{
binary,
"--resume", sessionID,
// Re-apply permissions and system prompt
}
return cmd, true, nil
}
// SessionInfo returns normalized metadata.
func (p *Plugin) SessionInfo(ctx context.Context, session ports.SessionRef) (ports.SessionInfo, bool, error) {
info := ports.SessionInfo{
AgentSessionID: session.Metadata[ports.MetadataKeyAgentSessionID],
Title: session.Metadata[ports.MetadataKeyTitle],
Summary: session.Metadata[ports.MetadataKeySummary],
}
// Return hasData=true if any field is populated
hasData := info.AgentSessionID != "" || info.Title != "" || info.Summary != ""
return info, hasData, nil
}
// UninstallHooks removes AO hooks.
func (p *Plugin) UninstallHooks(ctx context.Context, workspacePath string) error {
// Remove AO hook entries while preserving user hooks
config, err := p.readConfig(workspacePath)
if err != nil {
return err
}
config = p.removeHooks(config)
return p.writeConfig(workspacePath, config)
}
Hook Implementation
// Add hooks to the agent's config
func (p *Plugin) addHooks(config map[string]any, sessionID string) map[string]any {
// Define AO hook commands
aoHooks := map[string]string{
"SessionStart": "ao hooks myagent SessionStart",
"UserPromptSubmit": "ao hooks myagent UserPromptSubmit",
"Stop": "ao hooks myagent Stop",
}
// Merge with existing config, preserving user hooks
for event, command := range aoHooks {
if config[event] == nil {
config[event] = []string{command}
} else {
// Deduplicate: check if AO hook already exists
hooks := config[event].([]string)
found := false
for _, h := range hooks {
if strings.HasPrefix(h, "ao hooks myagent") {
found = true
break
}
}
if !found {
config[event] = append(hooks, command)
}
}
}
return config
}
Gitignore Enforcement
import "github.com/aoagents/agent-orchestrator/backend/internal/adapters/agent/hookutil"
func (p *Plugin) ensureGitignore(workspacePath string) error {
// Every file the adapter writes must be gitignored
filesToIgnore := []string{
".myagent/config.json",
".myagent/hooks.json",
}
return hookutil.EnsureWorkspaceGitignore(workspacePath, ".myagent/")
}
Supported Agents
Current Adapters
AO currently supports 23+ agent adapters:
mindmap
root((Agent Adapters))
Anthropic
Claude Code
OpenAI
Codex
Cursor
Cursor
OpenCode
OpenCode
Aider
Aider
Amp
Amp Code
Goose
Goose
GitHub
Copilot
xAI
Grok
Alibaba
Qwen Code
Moonshot
Kimi Code
Reverb
Crush
Cline
Cline
Factory
Droid
Devin
Augment
Auggie
Continue
Continue
Kiro
Kiro
Kilo
Kilo Code
Agy
Agy
Roo
Roo Code
Windsurf
Windsurf
Adapter Locations
backend/internal/adapters/agent/
├── agy/
├── aider/
├── amp/
├── augie/
├── claudecode/
├── clone/
├── codex/
├── continue/
├── cursor/
├── devin/
├── droid/
├── grok/
├── goose/
├── kilo/
├── kimi/
├── kiro/
├── opencode/
├── qwen/
├── roo/
├── crush/
├── windsurf/
└── ...
Adapter Capabilities
| Agent | Permissions | System Prompt | Restore | Hooks |
|---|---|---|---|---|
| Claude Code | ✓ | ✓ | ✓ | ✓ |
| Codex | ✓ | ✓ | ✓ | ✓ (session flags) |
| Cursor | ✓ | ✓ | ✗ | ✗ |
| OpenCode | ✓ | ✓ | ✗ | ✗ |
| Aider | ✓ | ✓ | ✓ | ✓ |
| Amp | ✓ | ✓ | ✓ | ✓ |
| Grok | ✓ | ✓ | ✓ | ✓ (Claude compat) |
| ... | ... | ... | ... | ... |
Testing
Unit Tests
func TestGetLaunchCommand(t *testing.T) {
p := New()
cfg := ports.LaunchConfig{
SessionID: "test-session",
Prompt: "Write a function",
Config: domain.AgentConfig{
Model: "gpt-4",
},
}
cmd, err := p.GetLaunchCommand(context.Background(), cfg)
assert.NoError(t, err)
assert.Contains(t, cmd, "myagent")
assert.Contains(t, cmd, "--model", "gpt-4")
assert.Contains(t, cmd, "Write a function")
}
Integration Tests
func TestAgentE2E(t *testing.T) {
// Skip if agent not installed
if !agentInstalled(t) {
t.Skip("myagent not installed")
}
// Create test project
ctx := context.Background()
project := createTestProject(t)
// Spawn session
session := spawnSession(t, ctx, project.ID, "myagent", "Hello")
// Wait for activity
waitForActivity(t, ctx, session.ID, "active")
// Verify metadata
info := getSessionInfo(t, ctx, session.ID)
assert.NotEmpty(t, info.AgentSessionID)
assert.NotEmpty(t, info.Title)
// Kill session
killSession(t, ctx, session.ID)
}
Conformance Tests
// TestGetAgentHooksFootprintIsGitignored verifies all adapter-written
// files are covered by .gitignore.
func TestGetAgentHooksFootprintIsGitignored(t *testing.T) {
registry := agentRegistry.New()
for _, adapter := range registry.List() {
t.Run(adapter, func(t *testing.T) {
tmpDir := t.TempDir()
cfg := ports.WorkspaceHookConfig{
WorkspacePath: tmpDir,
SessionID: "test-session",
}
agent := registry.Get(adapter)
err := agent.GetAgentHooks(context.Background(), cfg)
assert.NoError(t, err)
// Verify all written files are gitignored
verifyGitignore(t, tmpDir)
})
}
}
Hook Tests
func TestHookDelivery(t *testing.T) {
// Mock daemon endpoint
server := mockActivityServer(t)
defer server.Close()
// Simulate hook callback
payload := map[string]any{
"state": "active",
"title": "Test session",
}
cmd := exec.Command("ao", "hooks", "myagent", "UserPromptSubmit", "test-session")
cmd.Env = append(cmd.Env,
fmt.Sprintf("AO_SESSION_ID=%s", "test-session"),
fmt.Sprintf("AO_DAEMON_URL=%s", server.URL),
)
stdin, _ := cmd.StdinPipe()
json.NewEncoder(stdin).Encode(payload)
stdin.Close()
err := cmd.Run()
assert.NoError(t, err)
// Verify daemon received the activity
assert.ActivityReceived(t, server, "active")
}
Summary
Key points:
- Port contract — All agents implement
ports.Agent - Hooks are critical — Activity signals and metadata flow through hooks
- Always gitignore — Every adapter-written file must be covered
- Metadata from hooks —
SessionInforeads from metadata, not files - Preserve user hooks — Never delete user configuration
- Exit 0 always — Hook failure must never break the agent
Implementation checklist:
- Implement
ports.Agentinterface - Write hook installation/removal
- Ensure all files are gitignored
- Implement
SessionInfofrom metadata - Add unit tests
- Add integration tests
- Add conformance tests
- Register in daemon wiring