Skip to main content

Headless workers and adapters

contained_run is the one-shot reviewer surface in ContextRelay.

Renamed from headless_run

The tool used to be called headless_run. It collided with Headless, a separate product that ships an MCP tool of the same name, so an agent told to "use headless" would reach for the wrong one. The old name is still accepted for one version and still works in CONTEXTRELAY_MCP_TOOLS allowlists, but it is no longer advertised.

The feature is still called headless workers — only the tool identifier changed.

It does not start a paired Claude + Codex session. It runs a single read-only terminal-coder job, gives it a prompt plus optional context refs, records a headless_result artifact, and returns the worker output to the caller.

Use it when you want an independent pass:

Review this diff for release blockers. Findings first. Cite file:line evidence.

Use a normal handoff or deliberation when you want the live peer agent to own follow-up work.

How to invoke it

From Codex or Claude, call the MCP tool:

{
"agent": "codex",
"prompt": "Review the current diff for correctness regressions.",
"context_refs": ["git diff", "src/backup/runner.ts"],
"label": "runner-review",
"timeoutMs": 180000
}

From a shell, use the daemon-free CLI wrapper:

ctxrelay headless run \
--target codex \
--prompt "Review this diff for release blockers. Findings first."

For file-backed prompts:

ctxrelay headless run \
--target claude \
--prompt-file .contextrelay/review-prompt.txt \
--json

The CLI is for one job. If you want several reviewers at once, call contained_run multiple times from an agent workflow; the daemon runs them through a bounded pool.

Built-in adapters

contained_run.agent is not hard-coded to two choices. It is populated from registered, contained HeadlessAdapters.

Current built-ins:

AdapterRuntimePrompt deliveryContainment
codexcodex exec --json --sandbox read-only ... -stdinNative Codex read-only sandbox
claudeclaude -p --output-format stream-json --allowedTools Read,Grep,Glob,LSstdinRestricted Claude Code tool surface
opencodeopencode run --format json --pure --dir <repo>argvDarwin sandbox-exec write-denial profile plus opencode tool denies

opencode is conditional. It is registered only when:

  • the host is Darwin/macOS,
  • opencode is on PATH, and
  • the write-denial sandbox probe passes.

If you want to run ctxrelay headless run --target opencode, install and authenticate the opencode CLI first, then start ContextRelay from an environment where opencode is on PATH. If any condition is missing, the adapter is not registered and the target is rejected instead of falling back to an uncontained run.

To hide only the opencode adapter on a machine that otherwise supports it, set CONTEXTRELAY_OPENCODE_ENABLED=0 before starting the daemon or running the daemon-free CLI command. To disable the whole headless surface, use CONTEXTRELAY_HEADLESS_ENABLED=0.

That is why some machines expose only codex and claude, while supported macOS setups expose codex, claude, and opencode.

Adapter extensibility model

Adapters are not external runtime plugins today. There is no config setting, package hook, or local drop-in directory that lets an installed npm user add an adapter without changing ContextRelay source.

The supported extension path is a PR to this repository:

  1. Implement a HeadlessAdapter in src/backup.
  2. Register it in the in-process adapter registry.
  3. Prove read-only containment (containment.enforced === true) or keep it unavailable until a probe proves containment.
  4. Add tests for command construction, env allowlisting, output parsing, containment failure, and tool-schema exposure.
  5. Document install/auth prerequisites and any kill switches.

After review and release, the adapter ships through the normal npm package. This is intentional for now: a headless adapter launches subprocesses, filters credentials, delivers prompts, parses model output, and asserts read-only containment, so unreviewed runtime loading would need a separate trust model.

What happens when a reviewed adapter lands

The public tool does not need to change.

Once a new adapter is registered and its containment is enforced, it appears in the contained_run.agent enum:

containedHeadlessAdapterIds()

The dispatcher then validates the requested agent with:

isContainedHeadlessTarget(agent)

So a new contained adapter is invoked the same way as the built-ins:

{
"agent": "my-adapter",
"prompt": "Review this change for missing tests."
}

If an MCP server or daemon process is already running after upgrading to a release that includes a new adapter, restart it so the registered adapter list and generated tool schema are refreshed.

Adapter contract

Adapters implement HeadlessAdapter in src/backup/adapters.ts:

export interface HeadlessAdapter {
id: HeadlessTarget;
promptDelivery: "stdin" | "arg";
buildCommand: (request: BackupRunRequest) => { command: string; args: string[] };
buildEnv: (sourceEnv: NodeJS.ProcessEnv, mode: BackupRunMode) => NodeJS.ProcessEnv;
prepareRun?: (
request: BackupRunRequest,
sourceEnv: NodeJS.ProcessEnv,
mode: BackupRunMode,
) => {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
cleanup?: () => void;
};
parseOutput: (stdout: string) => {
output: string;
cost: number | null;
tokens: number | null;
};
credentialPrefixes: string[];
containment: Containment;
defaultMode: "read-only";
groundingPrompt?: string;
}

The important fields:

  • id: the contained_run.agent value.
  • promptDelivery: whether the prompt is sent over stdin or appended as the final argv value.
  • buildCommand: the default command shape.
  • buildEnv: the environment allowlist. Do not forward broad provider credentials unless the adapter needs them.
  • prepareRun: optional per-run setup for sandboxes, temporary homes, auth copying, profiles, and cleanup.
  • parseOutput: converts runtime stdout into user output, USD cost, and token counts.
  • containment: the proof that this adapter is safe for read-only dispatch.
  • groundingPrompt: optional preamble prepended in read-only mode before the user prompt.

contained_run rejects adapters whose containment.enforced is not true.

Minimal registration pattern

Create the adapter, then register it:

import { registerHeadlessAdapter, type HeadlessAdapter } from "./adapters";

const myAdapter: HeadlessAdapter = {
id: "my-adapter",
promptDelivery: "stdin",
credentialPrefixes: ["MY_ADAPTER_"],
containment: { kind: "native-readonly", enforced: true },
defaultMode: "read-only",
buildCommand: () => ({
command: "my-adapter",
args: ["run", "--json", "--read-only", "-"],
}),
buildEnv: (sourceEnv) => ({
PATH: sourceEnv.PATH,
HOME: sourceEnv.HOME,
CONTEXTRELAY_WORKER: "1",
}),
parseOutput: (stdout) => ({
output: stdout.trim(),
cost: null,
tokens: null,
}),
};

registerHeadlessAdapter(myAdapter);

That pattern is enough for a runtime that already has its own read-only containment.

If the runtime does not have native read-only containment, use prepareRun to wrap it in an OS-level sandbox and keep containment.enforced false until the probe proves the sandbox works. The opencode adapter is the reference implementation for that pattern.

Grounding prompts

groundingPrompt exists for runtimes that do not already self-ground their role and tools.

Only opencode currently sets it. opencode run --pure is intentionally minimal, so ContextRelay prepends a read-only preamble that names the real tools (read, glob, grep, list), denies edits/shell/web/subagents, and requires file:line evidence.

Codex and Claude do not set groundingPrompt; their native headless runtimes already receive their own mode/tool constraints.

Budgets and output shaping

The daemon path is pooled and optionally budgeted:

  • CONTEXTRELAY_HEADLESS_MAX_CONCURRENCY
  • CONTEXTRELAY_HEADLESS_MAX_QUEUE
  • CONTEXTRELAY_HEADLESS_TIMEOUT_MS
  • CONTEXTRELAY_HEADLESS_BUDGET_USD

Under usage-control lean or strict, ContextRelay budgets the worker prompt and caps the returned output with head/tail preservation so the final verdict is less likely to be truncated away.