Skip to content

Harnesses

A harness is the layer that knows how to drive one agent runtime from Z.E.N. — start a session, send a turn, stream events back, cancel, resume. The harness registry is what agent: { harness: <id> } workflow nodes look up.

This page covers the registry concept, the canonical id convention, what capabilities mean, and what's user-extensible vs source-only.

If you just want to use one, see Agent nodes. If you want to install a specific one, see the integration pages (e.g. Hermes (ACP)).

Canonical id: <vendor>/<implementation>

Every harness is registered under an id of the form <vendor>/<implementation>. The vendor is who made the runtime; the implementation is how Z.E.N. talks to it.

idVendorImplementation
claude/compatAnthropic Claudeprovider-compat (Z.E.N.'s built-in claude provider wrapped as a harness)
codex/compatOpenAI Codexprovider-compat
pi/compatPiprovider-compat (streaming chat)
pi/agent_sessionPiwraps Pi's multi-turn agent loop with tools + thinking
gemini/agent_sessionGemini Vertexmulti-turn loop
hermes/acpHermes AgentAgent Client Protocol over stdio

<implementation> is one of:

  • compat — wraps a legacy provider through ProviderCompatHarness
  • agent_session — wraps a bounded multi-turn tool-using loop
  • sdk — SDK-native (uses the vendor's first-party SDK directly)
  • acp — Agent Client Protocol over stdio
  • cli — newline-delimited JSON over a CLI subprocess
  • http — HTTP/WS transport (spec WIP upstream; not shipped yet)

The split exists because the same vendor can register multiple implementations. Future: pi/sdk ships alongside pi/compat. Both stay in the registry; bare-name lookup (pi) becomes ambiguous and the registry forces you to spell the canonical id.

Bare-name aliases

Lookup tolerates the bare vendor name when it resolves unambiguously:

yaml
agent:
  harness: pi      # resolves to pi/agent_session if that's the only pi/* registered

When two implementations share a vendor (pi/compat + pi/agent_session), the bare name throws AmbiguousHarnessAliasError and you must spell the canonical id.

Workflows written before Phase 7 used bare names. They still work as long as the vendor stays single-implementation. The deprecation policy: bare names log a soft warning when used, full removal happens in a later major.

The capability matrix

Each harness publishes a grouped capability declaration:

typescript
{
  session: { resume, fork, cancel },
  tools:   { mcp, shell, restrictions },
  prompt:  { skills, subagents, structuredOutput },
  runtime: { envInjection, sandbox, approvalPause },
  model:   { selection, costControl, effortControl, thinkingControl, fallback }
}

Each flag is true/false. The Settings → Harnesses card chips them so you can see at a glance what each harness supports.

Workflows can opt into features the harness doesn't have — Z.E.N. validates at compile time and rejects unsupported combos with a clear error. For example: a workflow that sets resume_session: on an agent: node whose harness has session.resume: false fails to load.

The rule for compiler enforcement: only the groups relevant to the selected node are enforced. The rest is advisory metadata for UI affordances. Don't write code that branches on every boolean combo — the matrix is for humans deciding what's possible, not for the compiler reasoning about every cell.

Version + vendor metadata

Every harness registers a version string and a vendor slug. Both surface on /api/harnesses and Settings → Harnesses.

For runtimes whose binary is probed at boot (ACP-style: spawn <bin> --version), the version is captured live — Settings shows Hermes Agent v0.13.0 (2026.5.7) exactly as the binary reports.

When the binary is missing or fails to spawn, the version field reads unavailable: binary 'X' not found on PATH. PATH=… so the failure mode is legible.

For runtimes that don't have a probe path (provider-compat harnesses), version is a fixed COMPAT_HARNESS_VERSION = '1.0.0' bumped when the compat adapter contract changes — provider-specific version data lives on the underlying provider registration.

RuntimeEvent stream

Every harness emits the same normalized event stream:

typescript
type RuntimeEvent =
  | { type: 'message'; role: 'assistant'|'user'|'system'; text; chunk? }
  | { type: 'thought'; text; chunk? }
  | { type: 'tool_start'; id; name; input? }
  | { type: 'tool_update'; id; name; input?; output? }
  | { type: 'tool_done'; id; name; output?; error? }
  | { type: 'plan'; items: PlanItem[] }
  | { type: 'usage'; input?; output?; total?; cost? }
  | { type: 'session'; sessionId; status }
  | { type: 'done'; reason; sessionId? }
  | { type: 'error'; message; subtype? }

The translation is the harness's job — ACP SessionUpdateRuntimeEvent, Claude SDK MessageChunkRuntimeEvent, Pi AgentSessionEventRuntimeEvent. Everything downstream — workflow_events DB persistence, the Agent tab on Run Detail, the per-conversation SSE stream — only sees RuntimeEvent.

This is the load-bearing decision in the harness architecture. Every consumer is one switch on event.type; adding a new harness type is a new translator, not a new consumer surface.

What's user-extensible

SurfaceUser-extensible without source?How
WorkflowsDrop .yaml in ~/.zen/workflows/ or .zen/workflows/
RecipesYAML with a recipe: block, same location
SkillsDrop .md in ~/.zen/skills/ — frontmatter parsed
ConnectorsManifest in ~/.zen/connectors/
Tools✓ (via connector)Discovered from connector adapter at boot
Harnesses✗ todayEach harness ships as source in @zen/runtimes

If you need a harness Z.E.N. doesn't ship — a different ACP agent (Claude-Code-via-ACP, Gemini-CLI, OpenCode), an SDK-native variant — open an issue. The contract is small (~300 LOC for a new ACP harness, ~50 LOC if you subclass AcpHarness) but it has to land in the repo.

The .zenpkg distribution path will eventually allow shipping third-party harnesses as binary packages installable via zen install <pkg>.zenpkg — that's how Z.E.N. plans to deliver e.g. claude-code/acp without users cloning source. Tracked separately.

Programmatic API

typescript
import { registerHarness, getHarness, getHarnessInfoList } from '@zen/runtimes';

registerHarness({
  id: 'mycompany/sdk',
  displayName: 'My Company',
  transport: 'sdk',
  capabilities: { /* … */ },
  builtIn: false,
  version: '0.1.0',
  vendor: 'mycompany',
  factory: () => new MyHarness(),
});

const harness = getHarness('mycompany/sdk');
const session = await harness.startSession({ cwd, model });
for await (const ev of harness.send(session.sessionId, { text: 'hi' })) {
  console.log(ev);
}

registerHarness throws if the id is already registered. Bootstrap order matters: the daemon registers built-ins (providers → harnesses → connectors → skills) before workflow discovery. Third-party harnesses would need to register before any workflow that references them runs.

  • Agent nodes — YAML surface
  • Hermes (ACP) — installing the most fully-featured ACP harness
  • Pi — Pi as both pi/compat (chat) and pi/agent_session (loop)

AI that follows a recipe, not a conversation.