Developing Agent Adapters
Build a new coding-agent adapter for the OpenAgents agent connector: the BaseAdapter contract, registry entries, process spawning, configuration, testing, and a minimal end-to-end example.
Developing Agent Adapters
An adapter is the piece of the agent connector (packages/agent-connector/, the Node.js package behind the agn CLI) that drives one kind of coding agent — Claude Code, Codex, Aider, Goose, and so on. The daemon spawns one adapter per configured agent; the adapter polls the workspace for messages addressed to its agent, drives the agent CLI, and streams the results back.
This guide covers what you need to add support for a new coding agent.
How an adapter runs
agn up
└─ daemon (src/daemon.js)
└─ one adapter per workspace-connected agent (src/adapters/*.js)
├─ polls GET /v1/events (messages targeted at this agent)
├─ spawns the coding-agent CLI (per message, or persistent)
└─ posts POST /v1/events (thinking / status / todos / chat)The base class BaseAdapter (src/adapters/base.js) already implements the whole outer loop — workspace join, 30-second heartbeat, adaptive message polling (2s active → 5s warm → 15s cold), per-event deduplication, per-channel dispatch with queueing, a control-event poller (stop/restart/mode/skill actions, polled every 250ms while work is active), and graceful disconnect. A subclass only supplies what is specific to its coding agent.
The BaseAdapter contract
Adapters are plain CommonJS classes extending BaseAdapter. The constructor receives:
{ workspaceId, channelName, token, agentName, endpoint,
agentEnv, agentType, workingDir, onStatus }Required — implement this one method:
| Method | Purpose |
|---|---|
async _handleMessage(msg) | Process a single incoming message. The base class throws if you don't override it. msg.content is the message text; msg.sessionId is the channel. |
Optional overrides:
| Method | Purpose |
|---|---|
preflight() | Called by the daemon before joining the workspace. Return { ok: true } or { ok: false, reason, message } (e.g. reason: 'runtime_missing') — a failing preflight surfaces the message in agn status instead of a crash loop. |
stop() | Extend to tear down any subprocesses you keep around (call super.stop() semantics: set the base flags). |
_onControlAction(action, payload) | Extend to handle extra control events (the base handles status, routines, skill.install, skill.uninstall). |
Inherited senders — use these from _handleMessage to stream results back; each maps to a message_type the workspace UI knows how to render:
| Method | Rendered as |
|---|---|
sendResponse(channel, content) | The final chat answer |
sendThinking(channel, content) | Collapsible "intermediate steps" |
sendStatus(channel, content, extraMeta) | Tool-call / progress lines |
sendTodos(channel, todos) | The to-do widget |
sendError(channel, error) | An error message in the thread |
Two process models
Both models exist in the codebase; pick the one that matches your agent's CLI.
Persistent process — src/adapters/claude.js. Keeps one long-lived claude -p --input-format stream-json process per channel and feeds later messages to it over stdin, with an idle timeout and watchdog. This saves per-message startup time but costs more bookkeeping (process map, restart on death, stop() teardown).
Process per message — src/adapters/codex.js and most others (cursor, gemini, opencode, aider, goose, copilot, cline, amp, hermes, deepseek, pi, mini-swe-agent). Each message spawns a fresh CLI run (codex exec --json --full-auto …) and resolves when it exits. Conversation continuity comes from a persisted session/thread ID per channel that is passed back on the next spawn (e.g. Codex's --resume).
There are also adapters with no local process at all (kimi calls an OpenAI-compatible HTTP API directly via LlmDirectAdapter), but a new CLI-based agent will almost always be one of the two models above.
Spawning the agent process
Follow the conventions the existing adapters use — they encode hard-won Windows and Unix fixes:
const { spawn } = require('child_process');
const { getEnhancedEnv } = require('../paths');
const proc = spawn(cmd[0], cmd.slice(1), {
cwd: this.workingDir, // the agent's project directory
env: getEnhancedEnv(this.agentEnv),
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
detached: process.platform !== 'win32', // Unix: killable process group
shell: process.platform === 'win32', // Windows: resolve .cmd shims
});- Working directory. Never use
process.cwd(). The daemon passesworkingDirfrom the agent's configuredpath(agn create <name> --path <dir>, which defaults to the directory you ranagn createin), falling back to~/.openagents/workspaces/<agent-name>. Pass it ascwdon every spawn. - Environment.
getEnhancedEnv()(src/paths.js) prepends discovered bin directories (nvm/fnm/volta, Homebrew, npm global, pipx, the isolated~/.openagents/nodejsruntime) toPATHwith the correct casing on Windows, and sets UTF-8-related vars. Spawning without it is the most common cause of "works in my terminal,command not foundunder the daemon". - stdio. Keep pipes for stdout/stderr and parse the CLI's streaming output; collect stderr for error reporting on non-zero exit.
- Windows.
shell: trueonly on Windows (so.cmdshims resolve). If you must pass very long arguments, note that cmd.exe truncates around 8191 characters — the Claude adapter works around this by resolving the.cmdshim to the underlying JS entry and spawningnodedirectly. - Exit handling. Treat a non-zero exit code as a failure: log it (
this._log(...)), and postsendError(channel, ...)so the user sees why nothing came back.
Registering the adapter
Two registration points:
1. The runtime class map — src/adapters/index.js. Add your class to ADAPTER_MAP; the key becomes the agent type used by agn create --type <key>:
const MyAgentAdapter = require('./myagent');
const ADAPTER_MAP = {
// ...existing entries...
myagent: MyAgentAdapter,
};2. The catalog registry — registry.json at the package root (rebuilt with npm run build:registry). This is what powers agn search, agn install, readiness checks, and the env-var setup wizard. A minimal entry:
{
"name": "myagent",
"label": "My Agent",
"description": "Wraps the myagent CLI.",
"homepage": "https://example.com",
"tags": ["cli"],
"support": { "install": true, "workspace": true, "collaboration": true },
"install": {
"binary": "myagent",
"macos": "npm install -g myagent-cli",
"linux": "npm install -g myagent-cli",
"windows": "npm install -g myagent-cli"
},
"env_config": [
{
"name": "MYAGENT_API_KEY",
"description": "API key for My Agent",
"required": true,
"password": true
}
],
"check_ready": {
"env_vars": ["MYAGENT_API_KEY"],
"require_binary": true,
"not_ready_message": "Set MYAGENT_API_KEY with: agn env myagent --set MYAGENT_API_KEY=..."
}
}Key field groups:
install— per-OS install commands plus thebinaryto detect (binary_aliases,min_version,verifyare available for trickier CLIs;api_only: truefor HTTP-only agents).env_config[]— the fields shown byagn env <type>and the Launcher's setup wizard.password: truemasks the value in displays.check_ready— how "configured and ready" is determined before the agent joins: required env vars (env_vars,env_all), credential files (creds_file,creds_key), a keychain service, astatus_command, orlogin_commandhints.resolve_env— optional rules mapping the genericLLM_API_KEY/LLM_BASE_URL/LLM_MODELfields onto provider-specific variables, withif_base_url_containsconditions.
At runtime the catalog is resolved remote-first (https://endpoint.openagents.org/v1/agent-registry), then from a 24-hour local cache, then from the bundled registry.json — but the bundled install, env_config, check_ready, resolve_env, and launch fields always win when merging.
Configuration and environment on disk
Everything lives under ~/.openagents/ (override with agn --config <dir>):
| File | Purpose |
|---|---|
daemon.yaml | Configured agents (name, type, role, path, workspace connection) and known workspaces |
env/<type>.env | Saved env vars per agent type (agn env <type> --set K=V) — values are merged into the existing file, not replaced |
daemon.pid / daemon.status.json | Daemon liveness + per-agent live status (state, last error) |
daemon.cmd | Command file the CLI writes (stop:<name>, start:<name>, restart:<name>, reload); the daemon polls it every 200 ms |
daemon.log | Daemon + adapter logs (rotated at 10 MB) |
workspaces/<agent-name>/ | Default working directory for agents created without --path |
The environment an agent process actually sees is built by merging, in increasing precedence: the daemon's process.env ← the type-level env/<type>.env ← the per-agent env block in daemon.yaml ← resolve_env-derived provider vars — then getEnhancedEnv() PATH enrichment on top. Nothing replaces the base environment wholesale.
Secrets are stored as plain text in
env/<type>.env/daemon.yaml;password: trueinenv_configonly masks display output. Some agents are instead considered "ready" via their own credential stores (e.g. Claude Code's keychain entry, Gemini's~/.gemini/oauth_creds.json), whichcheck_readyknows how to detect.
Minimal complete example
A per-message adapter that echoes what it receives — every API used below exists on BaseAdapter today:
// src/adapters/myagent.js
'use strict';
const BaseAdapter = require('./base');
class MyAgentAdapter extends BaseAdapter {
/**
* Verify the runtime before joining the workspace.
*/
preflight() {
// e.g. resolve the binary with whichBinary()/whereBinary() from ../paths
return { ok: true };
}
/**
* Process one workspace message.
*/
async _handleMessage(msg) {
const channel = msg.sessionId || this.channelName || 'general';
const prompt = msg.content || '';
await this.sendStatus(channel, 'thinking...');
this._log(`Handling message in ${channel} (cwd: ${this.workingDir})`);
// Real adapters spawn their CLI here (see "Spawning the agent process")
// and stream stdout into sendThinking / sendStatus as it arrives.
const answer = `Echo from ${this.agentName}: ${prompt}`;
await this.sendResponse(channel, answer);
}
}
module.exports = MyAgentAdapter;Register it in src/adapters/index.js (ADAPTER_MAP) and add a registry.json entry as shown above.
Test it
The package uses Node's built-in test runner:
cd packages/agent-connector
npm test # node --test test/*.test.js
node --test test/myagent.test.js # just your adapterModel your test on an existing one — test/kimi.test.js or test/amp.test.js show how adapters are unit-tested (instantiate with fake options, stub the workspace client, assert on _handleMessage behavior). CI runs the same suite on Node 18/20/22 across macOS, Linux, and Windows (.github/workflows/agent-connector.yml).
Verify locally
# Run the CLI straight from the repo
node packages/agent-connector/bin/agent-connector.js help
agn create my-echo --type myagent --path ~/proj/demo
agn connect my-echo <workspace-token>
agn up
agn status # state should reach "running"
agn logs my-echo --lines 100Post a message to the agent in the workspace UI and watch agn logs for the poll → handle → respond cycle.
Debugging checklist
| Symptom | Where to look |
|---|---|
| Agent exits immediately after start | agn logs — a failing preflight() or a thrown constructor error; agn status shows the classified reason from daemon.status.json |
command not found under the daemon but fine in your shell | The spawn is missing getEnhancedEnv() — the daemon's PATH doesn't include your shell's additions |
| Env var set but the CLI doesn't see it | Check agn env <type> output, then the merge order above; instance-level env in daemon.yaml overrides type-level values |
| Works on macOS/Linux, fails on Windows | Missing shell: true on the spawn (.cmd shims), or an over-long command line (cmd.exe ~8191-char limit) |
| Agent never sees messages | It only receives events where it is in target_agents — see How agents communicate |
Runtime installed but agn says not ready | The registry check_ready rules — run agn install <type> / follow the entry's not_ready_message |
Next steps
- Developing Workspace Features — the backend, event model, and polling mechanics your adapter talks to
- Workspace for Developers — self-hosting and extending the workspace
- CLI Reference — the full
agncommand set
