The agent harness¶
The harness is keryx's own agent runtime: the loop that lets a model operate on a project through controlled tools, with a policy engine above it, an OS sandbox below it, and an append-only record of what happened beside it.
This page is the feature-level tour. Architecture has the internals and the seam-by-seam citations; CLI reference has every flag.
Four doors, one set of rules¶
| Door | Command | Shape |
|---|---|---|
| CLI | keryx harness run\|exec\|extension\|wave\|replay |
Scriptable, CI-facing, one prompt or one contained command per invocation |
| JSONL / RPC | runViaRpc (src/harness/rpc.ts) |
The same assembly framed as JSON envelopes, for embedding rather than a CLI verb |
| Loopback HTTP | keryx serve |
Authenticated, loopback-bound; a bot or another product drives turns |
| Interactive | keryx shell |
A TUI on the same tool registry and the same policy profile |
CLI and RPC delegate to the same runOffline, so a transport cannot upgrade a
decision — framing carries data, the policy engine decides policy. The
interactive shell runs its own turn loop over the same registry and profile; it
is a different loop, not a different rulebook.
Providers¶
anthropic, ollama, a deterministic offline fake, and the
OpenAI-compatible gateways: openrouter, deepseek, zai, zai-coding,
cerebras, groq, moonshot, grok. keryx shell offers the same set through
its picker, listing each with the environment variable it reads.
Swapping the model changes neither the loop, the tool registry, nor the policy.
The fake provider replays a recorded transcript, which is what makes the loop
testable with no network at all.
Sessions¶
Sessions are per project — isolated by git root, or by absolute cwd outside a repository — and durable on disk as JSONL:
context.jsonl— the model window a resume loadsarchive.jsonl— the full audit log, which survives/compact
User messages and tool results are checkpointed immediately; streamed assistant text is journaled at most every 300 ms and is flushed when a turn ends or is interrupted. An interrupted turn therefore remains resumable with its latest partial answer.
keryx shell -c # continue the last session in this project
keryx shell -r <id> # resume by id, short id, or title
keryx sessions list # newest first; forks are marked ↳
keryx sessions fork <id> # branch a conversation
keryx sessions export <id> # Markdown transcript
keryx sessions path # where they live on disk
Forking creates a new session that starts from the source's context and
archive, with parentSessionId recording where it came from. The copy is a copy:
writing to the fork never touches its source, so you can take a conversation in a
second direction without losing the first. Merging branches is deliberately out of
scope.
Compaction (/compact) shortens the model window and keeps the archive intact —
an entry never disappears; the compactor raises EvidenceDeletionError if one
would.
The policy engine — three answers, not two¶
allow, ask, deny over seven risk classes: read, write, shell, network,
credential, delegate, destructive. Path and command rules sit underneath. Shell
and destructive actions are default-deny.
Four properties are worth knowing before you rely on it:
- A hard deny is terminal. No approval, role, or interactivity flips it.
- An approval authorizes exactly one action, bound to that action's fingerprint, and a single-use grant is spent once consumed.
- Headless never silently allows. An
askwith no live approver becomes adeny, which is why a remote turn's recorded denial is correct rather than incidental. - Structural safety runs before policy.
guardActionrefuses malformed or unsafe shapes ahead of any allow/ask/deny question.
Interactive session: ask / trust / auto¶
The properties above are unconditional — they hold for keryx harness run,
keryx harness exec, and keryx serve no matter what. keryx shell sits on
top of that same approval gate and adds a session-level convenience layer
with three modes: ask (default, unchanged), trust (safe calls run without
asking; a destructive one still asks), and auto (nothing asks except a
credentials-touching command, which no mode ever auto-approves). Set it with
keryx shell --trust/--auto, or the /mode command once inside a session.
Full reference, including exactly where the per-project default is stored:
Choose an approval mode.
This layer never reaches harness run/harness exec/keryx serve or the
MCP server — property 3 above (headless never silently allows) is
untouched by it.
Containment underneath¶
The OS sandbox sits below the policy engine — Seatbelt on macOS, bubblewrap on
Linux — and is reached through keryx harness exec:
keryx harness exec --allow-env HOME --max-runtime-ms 30000 \
--allowed-domains api.example.com --mask-env TOKEN@api.example.com \
--tls-terminate -- ./script.sh
| Capability | macOS | Linux |
|---|---|---|
| Filesystem containment | yes | yes (bwrap on PATH) |
| Network off/on | yes | yes |
| Domain allowlist, credential masking, TLS termination | yes | refuses to run |
The Linux refusal is the point. A domain allowlist that quietly became "all
network" would be worse than one that says it cannot run, so a
network: "restricted" profile fails closed there — and it does so at the spawn
point, which means KERYX_SANDBOX_ALLOW_UNSANDBOXED cannot reach it. That escape
hatch still covers the case it was written for: a missing launcher, where running
uncontained is a degradation an operator knowingly accepts.
See Limitations for the full platform matrix.
Evidence, redaction, and the completion gate¶
Every recorded tool result is scanned and redacted before it is persisted. A
failed scan blocks persistence entirely and emits only a reason — no preview, no
hash, no category. The scanner is the same deterministic floor keryx security
scan reports from: rules plus entropy, over the committed evaluation corpus.
Session history is append-only and content-addressed; entries are deep-frozen.
The completion gate is the single authority on whether a run passed. It reaches
pass only when all of these hold:
- every required gate reports
pass - every required evidence ref is present
- no undisposed blocker remains
- a final message was emitted
A final message alone never passes. The caller supplies the first two — a run driven by a flow with frozen acceptance criteria states what it must show — and a run with no requirements stated evaluates the last two, which is the right default for an ad-hoc run with no flow behind it.
The gate reports; it never advances flow state itself. The single route from a
gate verdict into Task Manager is ManagedFlowPort.completeFromGate, and the
harness never writes flow.json — the policy engine denies that target even with
a matching approval.
Child agents with budgets¶
Dispatch runs over the canonical subagent-dispatch / subagent-result
contracts, with a token budget per child and bounded parallel scheduling. The
child path accepts no FlowService and no filesystem handle, so nothing in it can
reach flow state structurally.
External children: a vendor CLI as a child agent¶
keryx can hand a bounded, read-only piece of work to a coding CLI you already
have installed — codex exec or claude -p — as a child of this same harness.
The vendor's own client authenticates itself from its own configuration and does
the work on your subscription; keryx supplies the isolation, the budget, the
supervision and the completion. Two agents ship, described as data in one
registry, with a pure codec each owning that CLI's argv, its event vocabulary and
its failure classification.
Nothing here has ever been run against a real vendor process. The whole layer
is verified offline against recorded transcripts in fixtures/external/, on a
machine with neither CLI installed.
Off by default, hard disabled where it matters¶
The switch that always applies is externalAgents.enabled: true in the user
config (~/.local/share/keryx/auth.json) — user-global, because a subscription
belongs to a person rather than to a checkout. Inside a .metaproject/
workspace the project must also have opted in, with keryx init
--external-agents; outside one — and keryx shell runs anywhere — the
user-global switch is the whole story, because a workspace that does not exist
cannot hold an opinion.
Above both sits a hard disable no configuration can flip: the capability refuses outright on a remote transport, or when a CI marker is set. A subscription reachable over a channel that reaches other people is the one thing the vendors' terms unambiguously forbid, so that check runs before the config is even read. Every refusal, on every layer, carries its own sentence — a silent no-op would leave you believing an agent ran.
keryx does not know whether you are logged in¶
It never opens a vendor credential store — not ~/.codex/auth.json, not
Claude's, not even to test whether a login exists. Availability comes from
--version and exit codes and nothing else, so it has three states — installed,
not installed, and not probed — and the third is a real answer rather than a
placeholder. There is no tick and no "ready": keryx agents external list reads
"installed, 0.147.0 (within the recorded range); login not verified — keryx
cannot know", and that last clause is the load-bearing half of the line.
A version outside the range the fixtures were recorded against is a recorded warning, never a refusal — neither CLI publishes a stable event schema, so hard-failing would break the feature on the vendor's next release. The count of transcript lines the codec did not recognise is the real drift signal, and it is reported per run.
No vendor sanction is claimed. keryx starts a client you installed and
already logged into, in the same relationship a terminal multiplexer has with it;
it obtains, stores, forwards and proxies no token, and consumes no subscription
tokens of its own. Whether a vendor considers headless third-party orchestration
of its own CLI acceptable is not addressed by either vendor's published
terms, and it is carried as an open risk rather than a settled question — see
the package's decisions (docs/requirements/keryx-external-agent-runtime/decisions.md
D-01) and security policy (docs/requirements/keryx-external-agent-runtime/security-policy.md
§7).
What the child gets, and how it is asked for¶
The child runs in a disposable git worktree checked out at HEAD, removed on
every terminal path including a thrown error. Your uncommitted work travels in
the prompt as a diff, since that worktree does not contain it; on overflow the
diff is what gets cut — never the directive, never the task — and the cut is
stated inside the prompt. The environment is copied from the parent and then
stripped: ANTHROPIC_*, CLAUDECODE, CLAUDE_CONFIG_DIR, CODEX_HOME, and the
whole CLAUDE_CODE_* and KERYX_* namespaces, plus a nesting-depth marker added
afterwards and honoured on entry, so a keryx started from inside an external
child refuses to start another. The tool roster is restricted: claude runs with
--tools Read Grep Glob, an allow-list over the built-in roster rather than a
permission rule, and an empty strict MCP config.
ANTHROPIC_API_KEY is stripped to make the subscription work, not for secrecy:
with a key present the CLI initialises normally, retries, and then fails in a way
that looks like a network problem rather than a configuration one.
Execution is requested through an optional runtime block on the canonical
subagent-dispatch contract, which spawn_subagent also accepts as an optional
runtime parameter. An absent block means the native runtime, so every dispatch
authored before this existed stays valid.
A fail-closed validator enforces the three constraints the JSON Schema cannot —
the agent resolves in the registry, the sandbox is one that agent's own CLI
supports, and read-only does not contradict the dispatch's allowed_actions —
with distinct refusal codes, because "this agent cannot" and "keryx does not
do this yet" are different facts about the world. The hook runs after
admission: the budget ledger and the depth and child caps have already applied,
so there is no second spawn path and no second ledger.
From the shell¶
/delegate <agent> <task>
keryx agents external list [--json] [--no-probe]
keryx agents external probe <id> [--json]
Both agents external subcommands are read-only and spend no quota — the only
process either starts is --version. Full reference:
CLI reference.
External children appear in the subagent sidebar with a ⤳ marker and open a
modal with three tabs: Work (the live structured transcript, folded from the
vendor's own event stream), Meta (agent, model, sandbox, session handle,
cost, turns, tokens, worktree path, parse skips, warnings) and Command (the
exact launch argv, a copy-pasteable shell form, and how to detach). Missing
figures render as missing and never as zero; a run that announced no session
handle says so rather than offering a command that cannot work.
Messages to a running child use the existing queue semantics — remove, edit,
force — per addressee, and every delivery reports which route it actually took.
claude accepts messages on an open stdin channel, and keryx shell launches
its runs steerable so that channel exists; codex has no mid-run input channel
at all, so its messages can only travel by resume. Steerability is a spawn-time
decision and cannot be revisited: the flag that accepts a later message also
forbids the positional prompt a one-shot run starts with, so there is no
conversion afterwards.
force is kill-plus-resume, not an abort. Writing to a live stdin would
queue the message behind the turn already in flight, which is the opposite of
what force asks for, so the run is terminated instead and the resume command is
recorded. It costs a restart, not the accumulated work — and where no resume
handle was ever announced it degrades to a plain kill and says plainly that the
message was not delivered.
keryx never spawns a resume itself. It builds the exact resume argv through the agent's own codec and shows it in the Command tab; running it in a real terminal is yours to do. The worktree is gone by then, and the tab says so.
What this deliberately does not do¶
- No mutating external agents. Read-only only.
worktree-writeis a valid contract value the runtime refuses with its own named reason — distinguishable from an agent that cannot do it — because its prerequisite is a credible audit boundary for writes, not more spawn machinery. - No supervision triggers. The specification describes a folded, trigger-driven view of a running child for the parent agent. None of it is implemented: the parent receives the child's result and nothing before it.
/delegatedoes not pass the policy engine or the subagent admission ledger. A recorded, reasoned amendment rather than an oversight; the model's ownspawn_subagentpath passes both.- No resume is ever spawned. The argv is built and displayed for detaching by hand.
- Nothing has been run against a real vendor process. Every test drives a fake process port against recorded transcripts, so "works" here means "works offline against what the CLIs actually printed", not "proven end to end".
Record and replay¶
keryx harness run --provider anthropic --model <m> --record run.json "<prompt>"
keryx harness replay --record run.json --write-fixture fixture.json
keryx harness replay --record run.json --fixture fixture.json
--record writes a run's replayable surface: five recomputable hashes — session
manifest, event log, tool registry, provider transcript, expected terminal state —
plus the run id, status and time. replay builds a fixture from a record and
validates it, or compares against a fixture you kept. A divergence prints a typed
mismatch naming the field and exits non-zero.
This is validate-log, and the distinction matters. It answers "does this
fixture still describe the run it was built from" — an integrity check. It does
not re-execute anything and cannot tell you whether the same prompt would
behave the same way today. Nothing is contacted: no provider, no tool, no network.
Re-execution against recorded results (simulate-recorded-results) is not
implemented.
Extensions and waves¶
keryx harness extension --spec <path> # one declared extension
keryx harness wave --spec <path> # a declared multi-agent wave
Extension dispatch is the one path that reaches checkApproval, so a mutating
extension needs a grant bound to its action fingerprint.
What the harness does not do yet¶
Stated here rather than left to be discovered:
- No shipped path registers a tool. Both production executors are refusals, so
keryx harness runandkeryx serveare single text turns today. The interactive shell is where tools actually run. - No remote approvals. A
keryx serveturn whose decision isaskends in a recorded denial. Run approval-requiring work locally throughkeryx shell. - No real replay. See above —
validate-logonly. - No branch merge. Reconcile by forking again from a shared ancestor.
- No mutating external children, and no supervision of a running one. The external runtime is read-only, off by default, and has never been run against a real vendor process — see what this deliberately does not do.