Skip to content
PricingReleases

Adapters

Adapters connect Recursive to agent CLIs and model providers — they define how to spawn an agent process, feed it instructions, and translate its output into Recursive’s event stream. Adapters ship inside plugins: install the plugin and the adapter appears in the model picker, onboarding, and session execution with no changes to Recursive itself.

Build an adapter when you want Recursive to orchestrate an agent CLI or model provider that isn’t already supported. The bundled adapters cover Claude Code, Cursor (CLI + SDK), Codex, Copilot, Droid, Kimi, Grok, Antigravity, OpenCode, GLM, Ollama, OpenRouter, Osaurus, and a generic Process adapter. If your provider isn’t in that list, write a plugin.

Two common shapes:

  • CLI wrapper — spawn the provider’s CLI in non-interactive mode with JSON output (claude --print, opencode run --format json, …) and normalize its event stream.
  • HTTP provider — call an OpenAI-compatible chat API directly (see the openrouter and ollama plugins).

An adapter is a TypeScript module inside a plugin. The manifest declares the adapter directory plus the metadata that drives onboarding:

my-agent-plugin/
plugin.json
adapters/
my-agent.ts # the adapter module
normalizer.ts # optional: event translation, kept separate for testing
tests/
normalizer.test.ts
{
"id": "my-agent-plugin",
"name": "My Agent",
"version": "1.0.0",
"adapters": "./adapters/",
"cli": { "bin": "my-agent", "versionArgs": ["--version"] },
"installCommand": "curl -fsSL https://my-agent.dev/install | bash",
"installUrl": "https://my-agent.dev/docs",
"loginCommand": "my-agent auth login",
"setupSequence": [
{ "kind": "install", "check": "cli", "title": "Install the my-agent CLI" },
{ "kind": "auth", "check": "auth", "title": "Sign in", "description": "Run `my-agent auth login`." }
],
"settings": {
"defaults": { "approvalMode": "inherit" },
"schema": [
{
"key": "approvalMode",
"type": "enum",
"options": ["inherit", "skip", "prompt"],
"label": "Tool permissions",
"section": "Permissions"
}
]
}
}

Everything onboarding-related is declarative: cli drives the installed-CLI probe, setupSequence renders the setup checklist, and settings.schema renders the plugin’s settings UI. There are no per-adapter switch statements in the app — new adapters get the same onboarding surfaces as the built-ins.

An adapter module uses named exports (not a class, not a default export). Required:

ExportTypePurpose
typestringUnique adapter id (e.g. 'opencode'). Model selections are stored as model@type.
labelstringDisplay name for the model picker and settings.
execute(agent, context) => { pid, process }Spawn one turn of the agent.
cancel(process) => voidTerminate a running turn (SIGTERM, then SIGKILL after a grace period).

Optional capability exports — the platform gates features on their presence:

ExportPurpose
discoverModels(ctx?)Async list of available models ({ id, name, description?, efforts?, defaultEffort? }[]). Also used as the auth probe: models present ⇒ authenticated.
bundledModelsStatic fallback model list when discovery isn’t possible.
createNormalizer()Factory returning a stateful per-run event normalizer (preferred).
normalizeEvent(parsed)Stateless per-event normalizer (fallback when no factory).
syncabletrue if Recursive should sync rules/MCP config into the provider’s format before each run.
configSyncHow to write MCP servers / rules into the provider’s config files (see below).
permissionsInteractive tool-permission gating (supportsPrompt, settingsDir, classifyTool, buildPersistRule). See Permission posture.
terminalInteractive-CLI support: command (launchable TUI), transcript (tail-able transcript format), drive (platform-driven PTY execution). See Terminal mode.
capabilitiesExecution-path optimizations the platform should apply to this adapter. See Execution capabilities.
fetchUsage()Provider usage/quota metrics for the usage dashboard.

All of these have TypeScript definitions you can import — @recursive/core-adapters is the single import point for the contract types and every shared helper:

import type {
AdapterModule, // the full module contract
AdapterExecutionContext, // what execute() receives
AdapterExecutionCapabilities,
AdapterPermissions,
AdapterTerminal,
AdapterTerminalDrive,
ModelInfo,
} from '@recursive/core-adapters';

execute(agent, context) is called once per turn. It must spawn the process, wire the stream callbacks, and return synchronously:

import { spawn } from 'node:child_process';
const type = 'my_agent';
const label = 'My Agent';
const syncable = true;
const BIN = 'my-agent';
function execute(agent: any, context: any) {
const {
workspace, // cwd for the run — always spawn here
prompt, // the user/turn message
model, // resolved model id ('auto' means let the CLI pick)
effort, // optional reasoning effort
sessionId, // provider-native session id when resuming, else undefined
images, // optional attachment paths
allowFullAccess,
onStdout, onStderr, onClose, onError, // stream callbacks — wire all four
} = context;
const args = ['run', '--output-format', 'json', prompt];
if (model && model !== 'auto') args.push('--model', model);
if (sessionId) args.push('--resume', sessionId);
const proc = spawn(BIN, args, {
cwd: workspace,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
detached: true,
});
if (onStdout && proc.stdout) proc.stdout.on('data', onStdout);
if (onStderr && proc.stderr) proc.stderr.on('data', onStderr);
if (onClose) proc.on('close', onClose);
if (onError) proc.on('error', onError);
return { pid: proc.pid, process: proc };
}
function cancel(proc: any) {
if (!proc?.pid) return;
try { process.kill(-proc.pid, 'SIGTERM'); } catch { try { proc.kill('SIGTERM'); } catch {} }
const timer = setTimeout(() => {
try { process.kill(-proc.pid, 'SIGKILL'); } catch { try { proc.kill('SIGKILL'); } catch {} }
}, 5000);
timer.unref();
}
export { type, label, syncable, execute, cancel };

Resolve the CLI binary — don’t trust PATH

Section titled “Resolve the CLI binary — don’t trust PATH”

Curl-installed CLIs land in directories only interactive shells have on PATH (~/.opencode/bin, ~/.local/bin, …). The Recursive server runs with a minimal PATH, so a bare spawn('my-agent') fails with ENOENT even when the readiness check says the CLI is installed. Use the shared resolver:

import {
resolveCliBinSync,
resolveCliBin,
prependBinDirToPath,
} from '@recursive/core-adapters/bin-resolver';
// inside execute() — synchronous, never blocks:
const bin = resolveCliBinSync(BIN);
const env = prependBinDirToPath({ ...process.env }, bin);
const proc = spawn(bin, args, { cwd: workspace, env, /* … */ });
// inside discoverModels() — async adds a login-shell fallback and warms the cache:
const bin = await resolveCliBin(BIN);

Three utilities every CLI adapter needs ship in @recursive/core-adapters/cli-helpers — use them instead of re-deriving:

import { parseAdapterConfig, stripAnsi, resolveImagePaths } from '@recursive/core-adapters/cli-helpers';
const adapterConfig = parseAdapterConfig(agent.adapter_config); // object | JSON string | undefined → object
const clean = stripAnsi(ptyOutput); // strip ANSI/CSI/OSC control sequences
const paths = resolveImagePaths(context.images); // attachment paths → absolute, existing files

By default the platform treats an adapter conservatively: every turn gets the full briefing, MCP config is re-synced on each spawn, and nothing is kept warm between turns. The optional capabilities export lets an adapter opt into the same optimizations the bundled Cursor adapters use — all flags default to off:

const capabilities = {
// Resume ids are opaque provider-side ids the adapter validates itself;
// Recursive must not clear a saved session id when no local transcript exists.
trustsSavedSessionId: true,
// The provider natively replays conversation context on resume, so
// follow-up turns receive a slim prompt instead of the full briefing.
contextRetention: true,
// Config-resolver provider name whose FULL sync must run during warm-lease
// prewarm (for providers whose MCP config needs stdio bridges); also lets a
// prewarmed session skip the redundant MCP sync at spawn.
mcpSyncProvider: 'my_agent',
// Eligible for warm-idle leases: after an interactive turn the session parks
// as `idle` and the next message skips repeat bootstrap work.
warmIdle: true,
// SDK adapters only: a live in-process agent handle can be parked at turn
// end and reused by the next turn.
warmProcessParking: true,
};
export { type, label, execute, cancel, capabilities };

Only declare contextRetention when resume genuinely replays prior context — with it set, the platform stops re-sending conversation history and trusts the provider.

Permission posture and settings vocabulary

Section titled “Permission posture and settings vocabulary”

Use approvalMode as the settings key for tool-permission posture, with the canonical values:

ValueMeaning
inherit (default)Follow the workspace-level Allow full access toggle.
skipAlways auto-approve (pass the CLI’s skip-permissions flag).
promptNever auto-approve; tool calls gate on the provider’s native prompting, or on Recursive’s PermissionCard when the adapter declares permissions.supportsPrompt.
function resolveSkipPermissions(adapterConfig: Record<string, any>, allowFullAccess: boolean | undefined): boolean {
const mode = typeof adapterConfig.approvalMode === 'string' ? adapterConfig.approvalMode : 'inherit';
if (mode === 'skip') return true;
if (mode === 'prompt') return false;
return allowFullAccess !== false;
}

Declare the same enum in your manifest’s settings.schema (see the manifest example above) so the settings UI renders it. Some bundled plugins predate this vocabulary (permissionMode, approvalPolicy, sandboxMode) — new adapters should use approvalMode; extra provider-specific knobs (e.g. a separate sandbox level) are fine as additional keys.

An adapter that wants Recursive’s interactive PermissionCard (instead of blanket skip/native gating) additionally exports permissions:

const permissions = {
supportsPrompt: true, // gated tool calls route through the permission_prompt MCP tool
settingsDir: '.my-agent', // provider config dir whose local settings file Recursive manages
// optional: classifyTool(toolName), buildPersistRule(grant)
};

The terminal export opts an adapter into up to three tiers of interactive-CLI support:

  • Tier 1 — command: a “My Agent — Interactive” entry appears in the model picker; picking it drops the chat into the real TUI running in a PTY.
  • Tier 2 — + transcript: Recursive also tails the CLI’s on-disk transcript through your normalizer, so tool cards, file edits, and turn history stay live alongside the TUI. transcript: 'claude-jsonl' matches any CLI using the ~/.claude/projects/<cwd-slug>/<uuid>.jsonl append-as-you-go convention.
  • Tier 3 — + drive: the platform can run whole sessions through the interactive CLI (“terminal execution mode”): it spawns the PTY server-side, injects each prompt, mirrors the transcript through your normalizer, and detects turn completion.

The drive contract is fully declarative so the generic driver in core-server stays CLI-agnostic:

const terminal = {
command: 'my-agent',
transcript: 'claude-jsonl',
drive: {
modelArgs: (model, effort) => (model !== 'auto' ? ['--model', model] : []),
permissionArgs: (adapterConfig, allowFullAccess) =>
allowFullAccess ? ['--dangerously-skip-permissions'] : [],
resumeArgs: (cliSessionId) => ['--resume', cliSessionId],
// Patterns match ANSI-STRIPPED PTY output. TUI redraws collapse spaces
// ("? for shortcuts" → "?forshortcuts") — write \s* between words.
readyPattern: { source: '\\?\\s*for\\s*shortcuts' },
busyPattern: { source: 'esc\\s*to\\s*interrupt' },
// Transient TUI banners never written to the transcript (rate limits,
// overload retries) — surfaced as chat notices, deduped per episode.
noticePatterns: [
{ pattern: { source: 'rate\\s*limited' }, level: 'warning', label: 'rate-limit' },
],
// Launch-phase dialogs answered once, before the first prompt injection.
launchPrompts: [
{ pattern: { source: 'trust\\s*this\\s*folder' }, response: 'y', label: 'trust' },
],
interrupt: '\u001b', // bytes that cancel the in-flight turn (Esc)
exitCommand: '/exit',
// Classify parsed transcript lines: 'turn-end' | 'skip' | 'forward'
classifyTranscriptLine: (parsed) =>
parsed.type === 'result' ? 'turn-end' : 'forward',
},
};

See AdapterTerminalDrive in the typed contract for the full field reference, and the claude-code plugin for the complete working implementation.

Recursive parses each stdout line as JSON and passes it through your normalizer, which returns an array of internal events. The internal vocabulary:

Internal eventShape
Session init{ type: 'system', subtype: 'init', session_id } — capture the provider-native session id here; it’s stored and passed back as context.sessionId on resume.
Assistant text{ type: 'assistant', message: { content }, session_id } (add _thinking: true for reasoning blocks)
Tool call{ type: 'tool_call', subtype: 'started' | 'completed', tool_call, session_id, uuid }
Turn result{ type: 'result', subtype: 'success', session_id, usage: { inputTokens, outputTokens, cachedInputTokens } }
Error{ type: 'stderr', text }

Build tool_call payloads with the shared helpers so tool cards render natively:

import { buildToolCallStarted, buildToolCallCompleted } from '@recursive/core-adapters/normalizer-helpers';
const toolCall = buildToolCallCompleted({ name: 'Bash', input: { command: 'ls' } }, output);

Prefer exporting createNormalizer() (a factory instantiated once per run) over the stateless normalizeEvent — it lets you accumulate state across events, e.g. summing token usage across multi-step turns or pairing started/completed tool events.

Recursive gives every session access to its platform tools by injecting its MCP server into the provider’s config. Declare syncable = true and a configSync export:

const configSync = {
providerDir: '.my-agent', // provider config dir inside the workspace
homeDir: '.config/my-agent', // provider's global config dir (optional)
supportsNativeRules: false,
syncMcp(ctx, targetDir, servers, claimNames) {
// Write `servers` (name → { url, headers } or { command, args }) into the
// provider's MCP config format. Stamp entries with ctx.buildManagedByTag()
// and only touch entries owned by the app (ctx.isOwnedByApp) so user
// config is never clobbered.
},
checkMcpSync(ctx, targetDir, expected) {
// Return { appMcpPresent, shape: 'ok' | 'missing' | 'incomplete' | 'unmanaged' }
},
cleanTargets: { dirs: [], files: [] },
};

See plugins/shared/opencode-cli/adapters/opencode.ts for a complete, compact implementation (writes .opencode/opencode.json).

discoverModels() powers both the model picker and the authenticated-check in onboarding:

async function discoverModels() {
const bin = await resolveCliBin(BIN);
// spawn `my-agent models`, parse ids, return [{ id, name }]
}

Readiness is served at GET /api/adapters/<type>/readiness — it probes the CLI (via the manifest’s cli.bin), then discovery, and drives the onboarding modal’s install → auth → ready checklist from your setupSequence.

  • Develop locally under ~/.recursive/plugins/local/<id>/ or install from GitHub with plugin_install({ repo: "user/repo" }). Adapter modules are require()d directly — no build step — and hot-reload on plugin install/update.
  • If your adapter doesn’t appear in GET /api/adapters, check GET /api/adapters/load-errors — it reports modules that failed to load or violated the contract (missing type, missing execute, require errors), per plugin.
  • Session spawn failures surface as bootstrap_failed events on the session with the adapter error attached.

Unit-test the normalizer with vitest against captured fixtures (see plugins/shared/opencode-cli/tests/normalizer.test.ts):

Terminal window
npx vitest run plugins/shared/my-agent/tests/normalizer.test.ts

Then verify the full loop with a real session: create a chat, pick your model (<model>@<type>), and confirm streaming text, tool cards, token usage, and resume (second message in the same chat should pass context.sessionId).