Appearance
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.
| id | Vendor | Implementation |
|---|---|---|
claude/compat | Anthropic Claude | provider-compat (Z.E.N.'s built-in claude provider wrapped as a harness) |
codex/compat | OpenAI Codex | provider-compat |
pi/compat | Pi | provider-compat (streaming chat) |
pi/agent_session | Pi | wraps Pi's multi-turn agent loop with tools + thinking |
gemini/agent_session | Gemini Vertex | multi-turn loop |
hermes/acp | Hermes Agent | Agent Client Protocol over stdio |
<implementation> is one of:
compat— wraps a legacy provider throughProviderCompatHarnessagent_session— wraps a bounded multi-turn tool-using loopsdk— SDK-native (uses the vendor's first-party SDK directly)acp— Agent Client Protocol over stdiocli— newline-delimited JSON over a CLI subprocesshttp— 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/* registeredWhen 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 SessionUpdate → RuntimeEvent, Claude SDK MessageChunk → RuntimeEvent, Pi AgentSessionEvent → RuntimeEvent. 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
| Surface | User-extensible without source? | How |
|---|---|---|
| Workflows | ✓ | Drop .yaml in ~/.zen/workflows/ or .zen/workflows/ |
| Recipes | ✓ | YAML with a recipe: block, same location |
| Skills | ✓ | Drop .md in ~/.zen/skills/ — frontmatter parsed |
| Connectors | ✓ | Manifest in ~/.zen/connectors/ |
| Tools | ✓ (via connector) | Discovered from connector adapter at boot |
| Harnesses | ✗ today | Each 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.
Related
- Agent nodes — YAML surface
- Hermes (ACP) — installing the most fully-featured ACP harness
- Pi — Pi as both
pi/compat(chat) andpi/agent_session(loop)