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 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 identifier
  • ProjectID — AO project identifier
  • Prompt — User prompt to send
  • Config — Agent-specific configuration
  • Permissions — Permission mode override
  • WorkspacePath — 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 metadata
  • Permissions — Permission mode to apply
  • SystemPrompt — System prompt to re-apply

Output:

  • cmd — Restore command argv
  • ok — True if restore is supported
  • err — 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:

EventPurposeActivity State
SessionStartSession initialized-
UserPromptSubmitUser submitted promptactive
AssistantMessageAssistant response-
ToolUseAgent used a toolactive
StopSession stoppedidle 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:

  1. Always exit 0 — Hook failure must never break the user's agent
  2. Log failures — Append to hooks.log under AO_DATA_DIR
  3. Best-effort delivery — The daemon may be temporarily unavailable
  4. 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

AgentPermissionsSystem PromptRestoreHooks
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:

  1. Port contract — All agents implement ports.Agent
  2. Hooks are critical — Activity signals and metadata flow through hooks
  3. Always gitignore — Every adapter-written file must be covered
  4. Metadata from hooksSessionInfo reads from metadata, not files
  5. Preserve user hooks — Never delete user configuration
  6. Exit 0 always — Hook failure must never break the agent

Implementation checklist:

  • Implement ports.Agent interface
  • Write hook installation/removal
  • Ensure all files are gitignored
  • Implement SessionInfo from metadata
  • Add unit tests
  • Add integration tests
  • Add conformance tests
  • Register in daemon wiring