Skip to main content

Creating an Adapter

Build a custom adapter to connect Vagaris to any agent runtime.

Tip

If you're using Claude Code, the .agents/skills/create-agent-adapter skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step.

Two Paths​

Built-inExternal Plugin
SourceInside this repoSeparate npm package
DistributionShips with VagarisIndependent npm publish
UI parserStatic importDynamic load from API
RegistrationEdit 3 registriesAuto-loaded at startup
Best forCore adapters, contributorsThird-party adapters, internal tools

For most cases, build an external adapter plugin. It's cleaner, independently versioned, and doesn't require modifying Vagaris's source. See External Adapters for the full guide.

The rest of this page covers the shared internals that both paths use.

Package Structure​

packages/adapters/<name>/    # built-in
── or ──
my-adapter/ # external plugin
package.json
tsconfig.json
src/
index.ts # Shared metadata
server/
index.ts # Server exports (createServerAdapter)
execute.ts # Core execution logic
parse.ts # Output parsing
test.ts # Environment diagnostics
ui/
index.ts # UI exports (built-in only)
parse-stdout.ts # Transcript parser (built-in only)
build-config.ts # Config builder
ui-parser.ts # Self-contained UI parser (external — see [UI Parser Contract](/adapters/adapter-ui-parser))
cli/
index.ts # CLI exports
format-event.ts # Terminal formatter

Step 1: Root Metadata​

src/index.ts is imported by all three consumers. Keep it dependency-free.

export const type = "my_agent";        // snake_case, globally unique
export const label = "My Agent (local)";
export const models = [
{ id: "model-a", label: "Model A" },
];
export const agentConfigurationDoc = `# my_agent configuration
Use when: ...
Don't use when: ...
Core fields: ...
`;

// Required for external adapters (plugin-loader convention)
export { createServerAdapter } from "./server/index.js";

Step 2: Server Execute​

src/server/execute.ts is the core. It receives an AdapterExecutionContext and returns an AdapterExecutionResult.

Key responsibilities:

  1. Read config using safe helpers (asString, asNumber, etc.) from @vagris/adapter-utils/server-utils
  2. Build environment with buildPaperclipEnv(agent) plus context vars
  3. Resolve session state from runtime.sessionParams
  4. Render prompt with renderTemplate(template, data)
  5. Spawn the process with runChildProcess() or call via fetch()
  6. Parse output for usage, costs, session state, errors
  7. Handle unknown session errors (retry fresh, set clearSession: true)

Available Helpers​

HelperSourcePurpose
runChildProcess(cmd, opts)@vagris/adapter-utils/server-utilsSpawn with timeout, grace, streaming
buildPaperclipEnv(agent)@vagris/adapter-utils/server-utilsInject PAPERCLIP_* env vars
renderTemplate(tpl, data)@vagris/adapter-utils/server-utils{{variable}} substitution
asString(v)@vagris/adapter-utilsSafe config value extraction
asNumber(v)@vagris/adapter-utilsSafe number extraction

AdapterExecutionContext​

interface AdapterExecutionContext {
runId: string;
agent: { id: string; companyId: string; name: string; adapterConfig: unknown };
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
config: Record<string, unknown>; // agent's adapterConfig
context: Record<string, unknown>; // task, wake reason, etc.
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
}

AdapterExecutionResult​

interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
errorMessage?: string | null;
usage?: { inputTokens: number; outputTokens: number };
sessionParams?: Record<string, unknown> | null; // persist across heartbeats
sessionDisplayId?: string | null;
provider?: string | null;
model?: string | null;
costUsd?: number | null;
clearSession?: boolean; // set true to force fresh session on next wake
}

Step 3: Environment Test​

src/server/test.ts validates the adapter config before running.

Return structured diagnostics:

LevelMeaningEffect
errorInvalid or unusable setupBlocks execution
warnNon-blocking issueShown with yellow indicator
infoSuccessful checkShown in test results
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
return {
adapterType: ctx.adapterType,
status: "pass", // "pass" | "warn" | "fail"
checks: [
{ level: "info", message: "CLI v1.2.0 detected", code: "cli_detected" },
{ level: "warn", message: "No API key found", hint: "Set ANTHROPIC_API_KEY", code: "no_key" },
],
testedAt: new Date().toISOString(),
};
}

Step 4: UI Module (Built-in Only)​

For built-in adapters registered in Vagaris's source:

  • parse-stdout.ts — converts stdout lines to TranscriptEntry[] for the run viewer
  • build-config.ts — converts form values to adapterConfig JSON
  • Config fields React component in ui/src/adapters/<name>/config-fields.tsx

For external adapters, use a self-contained ui-parser.ts instead. See the UI Parser Contract.

Step 5: CLI Module​

format-event.ts — pretty-prints the adapter's event stream in the terminal (used when the CLI streams a run) using picocolors.

export function formatStdoutEvent(line: string, debug: boolean): void {
if (line.startsWith("[tool-done]")) {
console.log(chalk.green(` ✓ ${line}`));
} else {
console.log(` ${line}`);
}
}

Step 6: Register (Built-in Only)​

Add the adapter to all three registries:

  1. server/src/adapters/registry.ts
  2. ui/src/adapters/registry.ts
  3. cli/src/adapters/registry.ts

For external adapters, registration is automatic — the plugin loader handles it.

Session Persistence​

If your agent runtime supports conversation continuity across heartbeats:

  1. Return sessionParams from execute() (e.g., { sessionId: "abc123" })
  2. Read runtime.sessionParams on the next wake to resume
  3. Optionally implement a sessionCodec for validation and display
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw) { /* validate raw session data */ },
serialize(params) { /* serialize for storage */ },
getDisplayId(params) { /* human-readable session label */ },
};

Quota Walls and Rate Limits — you inherit these​

You do not need to write anything for your adapter to be protected when its provider closes the door. When a run fails, the server reads the error text through one vendor-neutral vocabulary (server/src/adapters/provider-signals.ts) and:

  • suppresses new dispatch and retries for your provider — and only yours. Each engine is judged by its own wall, so another provider's exhaustion never sheds your runs, and yours never sheds theirs.
  • holds dispatch until the provider's own stated reset passes, when the error names one. extractQuotaResetAt (in @vagris/adapter-utils) reads the usual shapes without knowing which vendor is speaking — try again at Aug 10th, 2026 7:38 AM, resets at 9:00 AM, retry after 45 minutes, or a bare ISO instant.
  • meters the condition onto the executor-wall gauge operators watch.

This is deliberate: quota handling is a floor every adapter stands on, not something each one re-implements. Three adapters once had bespoke parsers and the other ten had nothing, so most engines oscillated against multi-day walls — dispatch, fail, back off, dispatch again — while a dashboard showed the account was closed.

Only add your own parsing if the generic read is not precise enough for your provider, and then return retryNotBefore from execute(). Your value always wins; the generic one only fills a null.

return {
// ...
retryNotBefore: myVendorSpecificReset?.toISOString() ?? null,
};

If your provider says something the shared vocabulary has never seen, add the phrase to PROVIDER_SIGNAL_PHRASES rather than to your adapter — every surface learns it at once. Note the flags there answer three different questions: gate (may this stop dispatch?), meter (is this an account wall an operator should see?) and cooldown (does the provider state an instant to wait for?). A transient throttle gates but neither meters nor cools down; a plan/tier failure meters but must never gate, because it does not clear on its own.

When your engine goes down, the survival reflex handles it — you write nothing for this either. executor-reflex.ts runs in the server control loop (not as a seat, so it survives the outage it is handling) and, per executor: fails portable work over to a healthy engine, parks the rest, raises exactly ONE operator issue for the engine rather than one per seat, then auto-resumes and closes it on recovery. It reacts to two shapes of death:

  • observed — the engine ran and failed, and the failures classify as auth-dead or quota-walled.
  • pre-emptive — the engine is over its declared budget, so the dispatch gate denies it before it can execute. There are no failures to observe here, which is why health is also overlaid from the budget for any adapter holding queued work.

To make the budget half apply to your provider, declare a cap for it in PAPERCLIP_FINOPS_SPEND_CAPS. Without one the weekly leg has no opinion, so your engine is never throttled and never reported budget-dead — which is exactly how one engine came to carry most of the fleet's token load completely unwatched.

Capability Flags​

Adapters can declare what "local" capabilities they support by setting optional fields on the ServerAdapterModule. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.).

FlagTypeDefaultWhat it controls
supportsLocalAgentJwtbooleanfalseWhether heartbeat generates a local JWT for the agent
supportsInstructionsBundlebooleanfalseManaged instructions bundle (AGENTS.md) — server-side resolution + UI editor
instructionsPathKeystring"instructionsFilePath"The adapterConfig key that holds the instructions file path
requiresMaterializedRuntimeSkillsbooleanfalseWhether runtime skill entries must be written to disk before execution

These flags are exposed via GET /api/adapters in a capabilities object, along with a derived supportsSkills flag (true when listSkills or syncSkills is defined).

Example​

export function createServerAdapter(): ServerAdapterModule {
return {
type: "my_k8s_adapter",
execute: myExecute,
testEnvironment: myTestEnvironment,
listSkills: myListSkills,
syncSkills: mySyncSkills,

// Capability flags
supportsLocalAgentJwt: true,
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: true,
};
}

With these flags set, the Vagaris UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Vagaris source changes required.

If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to false for all capabilities.

Skills Injection​

Make Vagaris skills discoverable to your agent runtime without writing to the agent's working directory:

  1. Best: tmpdir + flag — create tmpdir, symlink skills, pass via CLI flag, clean up after
  2. Acceptable: global config dir — symlink to the runtime's global plugins directory
  3. Acceptable: env var — point a skills path env var at the repo's skills/ directory
  4. Last resort: prompt injection — include skill content in the prompt template

Cross-run workspace persistence (no-remote-git contract)​

The local execution-workspace cwd is the only persistence boundary across runs. No adapter may depend on a git remote for cross-run state.

The supported round-trip:

  • Per-run, on the remote side. prepareWorkspaceForSshExecution (in packages/adapter-utils/src/ssh.ts) git-bundles the local worktree and ships it to the run's remote dir. No git remote is set anywhere; the bundle is the transport.
  • End-of-run, in the adapter's finally block. The adapter invokes restoreRemoteWorkspace (e.g. claude-local's execute.ts), which calls restoreWorkspaceFromSshExecution → exportGitWorkspaceFromSsh → integrateImportedGitHead. Remote commits made during the run land back in the local Mac worktree with no git push and no remote configured.

The invariant adapters must preserve:

  • Never git push from adapter or runtime code. Operator-supplied configuration may opt in, but the default contract is no remote operations.
  • Never assume a remote exists. The local cwd is the source of truth between runs.
  • Surface restore failures. A failed sync-back must propagate as a run-level error, not a silent warning. The heartbeat records a workspace_finalize row (succeeded/failed) around adapter.execute so dependent issues do not wake on a stale worktree.

The invariant is pinned by the "no-remote-git contract" case in packages/adapter-utils/src/ssh-fixture.test.ts: it asserts git remote is empty before and after the round-trip and that a remote-only commit still lands locally via restore alone.

Security​

  • Treat agent output as untrusted (parse defensively, never execute)
  • Inject secrets via environment variables, not prompts
  • Configure network access controls if the runtime supports them
  • Always enforce timeout and grace period
  • The UI parser module runs in a browser sandbox — zero runtime imports, no side effects

Next Steps​