Adapters
The adapter layer is a single AgentAdapter interface that sits between the control plane and every LLM backend. The control plane dispatches a task and receives a result — it never knows or cares whether the work ran inside Claude Code CLI, an Ollama server, an OpenAI REST call, or a customer’s private GPU cluster. Adding a new provider means writing one adapter class; the control plane needs no changes.
Unified Interface [Live]
Each adapter is a package that exports a single execute() function:
// packages/adapters/<name>/src/server/execute.ts
export type ProgressEvent =
| { type: "tool_use"; toolName: string }
| { type: "text"; preview: string };
export async function execute(
ctx: AdapterExecutionContext,
onProgress?: (event: ProgressEvent) => void,
): Promise<AdapterExecutionResult> { ... }interface AdapterExecutionContext {
config: AdapterConfig; // { cwd, model, timeoutSec, graceSec, ... }
prompt: string;
sessionId?: string; // resume prior session (Claude only)
skillsDir?: string; // temp dir with skills files (Claude only)
agentId: string;
tenantId: string;
runId: string;
agentName: string;
tenantName: string;
}
interface AdapterExecutionResult {
success: boolean;
output?: string;
error?: string;
sessionId?: string;
costUsd?: number;
durationMs?: number;
}Adapter Factory [Live]
AgentConfig in the DB stores the adapter type and model per agent role. Workers dynamically require the correct adapter package at runtime — no class hierarchy, no DI container:
// packages/agents/src/workers/setup.worker.ts
type AdapterExecuteFn = typeof import("@leadmetrics/adapter-claude-local/server").execute;
async function getAdapter(adapterType: string): Promise<AdapterExecuteFn> {
if (adapterType === "codex_local") {
const mod = require("@leadmetrics/adapter-codex-local/server") as { execute: AdapterExecuteFn };
return mod.execute;
}
if (adapterType === "gemini_local") {
const mod = require("@leadmetrics/adapter-gemini-local/server") as { execute: AdapterExecuteFn };
return mod.execute;
}
// default: claude_local
const mod = require("@leadmetrics/adapter-claude-local/server") as { execute: AdapterExecuteFn };
return mod.execute;
}Adapter-specific config differs by type:
const isCodex = adapterType === "codex_local";
const isGemini = adapterType === "gemini_local";
const config = isCodex
? { cwd, model, dangerouslyBypassApprovalsAndSandbox: true, timeoutSec: 900 }
: isGemini
? { cwd, model, yolo: true, timeoutSec: 900 }
: { cwd, model, dangerouslySkipPermissions: true, timeoutSec: 900 };
// Skills injected only for Claude (Codex + Gemini don't support --add-dir)
const skillsDir = isCodex || isGemini ? undefined : await createSkillsDir(tenantId, agentRole);Event Publishing & Run Tracking [Live]
Workers publish real-time events via Redis pub/sub. The API server subscribes and re-emits to the correct tenant room via Socket.IO:
// packages/agents/src/agent-events.ts
await publishAgentEvent({
type: "agent:started",
adapter: adapterType, // "claude_local" | "codex_local" | "gemini_local"
model,
tenantId, agentRole, runId, skills, ...
});| Event type | What triggers it | DB side effect |
|---|---|---|
agent:started | Before execute() is called | Creates AgentRun row (status: running) |
agent:progress | Each tool call inside the subprocess | Updates Redis live-progress key (TTL 30 min) |
agent:completed | After execute() resolves | Updates AgentRun (status: completed, costUsd, durationMs) |
agent:failed | On error or timeout | Updates AgentRun (status: failed, error) |
The AgentRun model stores adapter and model fields so the Manage portal can show per-adapter cost breakdowns.
Fallback & Resilience [To Build]
Planned: each adapter will have a configurable fallback provider. If the primary fails (outage, rate limit, auth error) the layer retries with a fallback automatically. Enterprise tenants on local_only mode cannot fall back to cloud providers. Not yet implemented.
Model Allocation by Task Type
| Task | Default provider | Default model | Alternative |
|---|---|---|---|
| Campaign strategy, decomposition | Claude | Sonnet 4.6 | GPT-4o |
| Long-form copywriting | Claude | Sonnet 4.6 | GPT-4o |
| Ad copy (Google, Meta) | Claude | Sonnet 4.6 | GPT-4o |
| Email copy | Claude | Sonnet 4.6 | GPT-4o |
| SEO content brief | Claude | Sonnet 4.6 | GPT-4o |
| Social media posts | Claude | Sonnet 4.6 | GPT-4o-mini |
| Performance report | Claude | Sonnet 4.6 | GPT-4o |
| Code generation (scripts) | OpenAI | o1 / Codex | Codex Local (gpt-5.4) |
| Agentic code tasks (ChatGPT account) | Codex Local | gpt-5.4 | gpt-5.4-mini |
| Long-context AI tasks (Gemini) | Gemini Local | auto (Gemini 3) | gemini-2.5-pro |
| Research extraction / scraping | Ollama | gemma3:4b | — |
| Task classification / routing | Ollama | gemma3:4b | GPT-4o-mini |
| Session summarisation | Ollama | gemma3:4b | GPT-4o-mini |
| Privacy-sensitive tasks | Ollama | any local | — |
| Enterprise custom runtime | Webhook | (tenant’s) | — |
Cost Tracking Per Provider
Cost is recorded on the AgentRun DB model (costUsd field) after each run completes.
| Provider | Cost source | Calculation |
|---|---|---|
claude_local | Token counts from NDJSON result event | MODEL_PRICING[model] × tokens |
codex_local | Token counts from turn.completed usage events | MODEL_PRICING[model] × tokens (TODO: currently shows —) |
gemini_local | Token counts from final stream event | MODEL_PRICING[model] × tokens (TODO: currently shows —) |
ollama [To Build] | eval_count + prompt_eval_count | $0.00 always |
openai [To Build] | usage field in final streaming chunk | MODEL_PRICING[model] × tokens |
webhook [To Build] | Usage object returned in callback payload | MODEL_PRICING[model] × tokens if known |
Pricing tables live inside each adapter package (e.g. packages/adapters/codex-local/src/server/execute.ts).
Adding a New Provider
- Create
packages/adapters/<name>/src/server/execute.tsexportingexecute(ctx, onProgress)matchingAdapterExecuteFn - Add a
/serversubpath export in the package’spackage.json - Add a branch to
getAdapter()inpackages/agents/src/workers/setup.worker.ts(and any other workers that need it) - Add model pricing inside the new adapter’s
execute.ts - Add the adapter key to the
AgentConfig.adapterenum inpackages/db/prisma/schema.prisma(string field — no enum constraint) - Add adapter dropdown option in the Manage portal agent edit page
- Write unit + integration tests for the new adapter
Supported Adapters
| Status | Key | Doc | Backend | When to use |
|---|---|---|---|---|
| [Live] | claude_local | claude.md | Claude Code CLI subprocess (NDJSON on stdout) | Default; high-value reasoning, copywriting, strategy, brand-sensitive tasks |
| [Live] | codex_local | codex-local.md | OpenAI Codex CLI subprocess (NDJSON on stdout) | Code-heavy tasks, GPT-5 family; client-researcher uses this by default |
| [Live] | gemini_local | gemini-local.md | Google Gemini CLI subprocess (NDJSON on stdout) | Large-context runs (Gemini 2.5 Pro), cost-optimised Flash runs |
| [To Build] | openai | openai.md | OpenAI REST API (SSE streaming) | Tenant-preferred OpenAI without local CLI |
| [To Build] | ollama | ollama.md | Local Ollama REST API (NDJSON streaming) | Zero-cost tasks, data-local tenants, offline/air-gapped |
| [To Build] | webhook | webhook.md | Async HTTP POST + phone-home callback | Enterprise internal runtimes, any HTTP-capable agent |