Harnesses: Claude Code and Codex¶
Leopold is not a Claude Code plugin that happens to run elsewhere. It is a harness layer that sits on top of whichever coding agent you use, and today that means Claude Code and OpenAI Codex CLI.
The reason this works is not clever abstraction on Leopold's side. It is that Codex reimplemented Claude Code's hook contract almost verbatim — same event names, same payload keys, same reply shapes. Leopold's hooks run on both harnesses as the exact same shell scripts, unmodified.
What is already portable¶
The brief is the whole point of Leopold, and it was never harness-specific:
.leopold/
MISSION.md what we are doing and why
CHARTER.md how to decide when nobody is watching
GUARDRAILS.md the budgets and stop conditions
PLAN.md the checklist the run burns down
DECISIONS.md what got decided, and on what basis
state.json run state (active, iteration, budgets)
events.jsonl the append-only event log
Plain markdown and one JSON file. Nothing in there knows or cares which agent is reading it. A brief written in Claude Code runs on Codex and vice versa.
What actually differs¶
| Claude Code | Codex CLI | |
|---|---|---|
| Home | ~/.claude |
~/.codex |
| Skills | ~/.claude/skills/ |
~/.codex/skills/ (same SKILL.md format) |
| Settings | settings.json (JSON) |
config.toml (TOML) |
| Project memory | CLAUDE.md |
AGENTS.md |
| Plugin manifest | .claude-plugin/ |
.codex-plugin/ |
| Headless seam | @anthropic-ai/claude-agent-sdk |
codex exec --json |
| Hooks need trust | no | yes, once |
That last row is the only real behavioral difference, and the rest of this page is mostly about it.
Neither harness fires "fewer" events — they fire different ones¶
It is tempting to read the table above as "Codex is the smaller harness, so it must fire
a subset of the lifecycle events". It does not, and the probe settled it: every one of
the 12 events Codex CLI 0.152.1 documents fired in a hermetic headless run —
SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse,
PermissionRequest, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop
and Interrupt. Compaction, subagents and permission prompts all reach a hook on Codex.
Claude Code 2.1.259 documents 33, of which 26 fired headless; the seven that did not
have no headless trigger on that version (PermissionDenied, Notification,
TeammateIdle, DirectoryAdded, WorktreeRemove, Elicitation, ElicitationResult).
So the real difference is not size but which events exist: Claude Code has task,
file, config, worktree and failure events that Codex has no equivalent for, and Codex has
Interrupt, which Claude Code does not fire.
Every claim on this page comes from that capture, not from a docs page:
Hook Events is regenerated by make probe-hook-events
and holds a verbatim payload — or the trigger tried, for an event that did not fire —
for all 45 rows. hooks/hook-matrix.tsv is derived from it, and it, not this prose,
decides each capability's status per harness.
The hooks are the same scripts¶
The run engine rides on always-on hooks that are inert unless a run is active; the three
whose harness behavior differs most are below, and the full set is in
Hooks (persona-guard.sh
is the one that is armed only while a persona run is active — same script on both
harnesses too).
guard-irreversible.sh (PreToolUse) — the git lock. It denies git commit and
git push while a run is active, so the run stages work and you ship it. Codex
delivers PreToolUse with the same keys Claude Code does — tool_name (its shell tool
is reported as Bash), tool_input.command, cwd, transcript_path — and honors the
same reply:
{"hookSpecificOutput":{"hookEventName":"PreToolUse",
"permissionDecision":"deny","permissionDecisionReason":"…"}}
stop-continuity.sh (Stop) — the autonomous engine. When the agent finishes a
turn it reads state.json and PLAN.md, and if work remains with no stop condition
met it blocks the halt and re-injects the next instruction. Codex delivers Stop with
session_id, turn_id, cwd, transcript_path and stop_hook_active (verified on
codex-cli 0.150.1; Claude Code 2.1.258 sends session_id, prompt_id, cwd,
transcript_path, stop_hook_active and last_assistant_message), and honors the same
reply:
The session_id is what binds a run to the one session conducting it: it equals
CODEX_THREAD_ID in the shell tool on Codex and CLAUDE_CODE_SESSION_ID on Claude
Code, and the hook continues only the session that matches the run's owner
(Hooks). One difference matters to hook authors: a Codex hook
process inherits no CODEX_* environment (Claude Code's inherits
CLAUDE_CODE_SESSION_ID, CLAUDE_PID and CLAUDE_PROJECT_DIR), so on Codex the
payload is the only identity a hook has — which is why the owner check reads the
payload, never the environment.
permission-policy.sh (PermissionRequest) — the prompt the run answers itself.
While the session conducting a run hits a permission prompt, the hook answers it
instead of waiting: allow, except that a git commit / git push is handed to the git
lock above and its deny repeated verbatim. Both harnesses send the same payload keys
(tool_name, tool_input, cwd, session_id) and read the same reply:
{"hookSpecificOutput":{"hookEventName":"PermissionRequest",
"decision":{"behavior":"deny","message":"…"}}}
This is the one place the two harnesses are not equal, and Leopold says so rather than
pretending otherwise. Claude Code (2.1.259) fires the event under
--permission-prompts none and honors allow and deny. Codex (0.152.1) fires it only
under --approve-for-me and honors deny only — an allow reply is ignored — so on
Codex the hook is the git lock's voice at the prompt and autonomy stays with the driver's
sandbox flags. hooks/hook-matrix.tsv records that as substitute, and leopold doctor
prints the cost on the row instead of claiming parity.
subagent-account.sh (SubagentStart / SubagentStop) and subagent-cap.sh
(PreToolUse) — what the children cost, and the ceiling. These two do not differ. Both
harnesses fire both subagent events with the same accounting keys (agent_id,
agent_type, and agent_transcript_path on stop), so the ledger keys identically on
each; and the cap's deny at PreToolUse is honored on both, matching Agent|Task on
Claude Code and collaborationspawn_agent on Codex through one matcher string. All four
matrix rows are available.
verify-receipt.sh (PostToolUse / PostToolUseFailure) — the evidence that an item is
done. This one does differ, and the matrix says so. On Claude Code most failing Bash
commands do not reach PostToolUse at all — they fire PostToolUseFailure with
error: "Exit code 1" — so the two events together give a receipt with a real exit code.
The firing of PostToolUse is still not by itself a pass: three fields of its response
object (returnCodeInterpretation, interrupted, backgroundTaskId) each deny one, and
the receipt records outcome: nonzero or incomplete instead of a fabricated 0.
Codex has no tool-failure event, and its PostToolUse fires for pass and fail alike with
tool_response as stdout only: a Codex receipt records exit_code: null, outcome: ran,
and proves the verification ran after the last edit, never that it passed. The row is substitute
for that reason, leopold doctor quotes the cost on the Codex line, and the bound still
ships on both harnesses rather than being held back to the intersection.
done-gate.sh (PreToolUse / TaskCompleted) — done means verified. This one differs by
half. The PreToolUse gate that denies an edit of .leopold/PLAN.md turning a - [ ]
into a - [x] is available on both — Claude Code's Edit / Write / MultiEdit,
Codex's apply_patch, whose tool_input.command holds the patch. TaskCompleted, where
exit 2 leaves the task pending, exists only on Claude Code: Codex has no task events at
all, so the matrix marks that row substitute, the installer refuses the spec there by
name, and the PLAN.md half carries the whole bound. leopold doctor names the event Codex
lacks on the same line rather than letting one wired event read as parity.
compact-checkpoint.sh (PreCompact / PostCompact) — the window that survives a
compaction. available on both, and this one surprised the probe: Codex fires both
compaction events, with trigger plus turn_id and model. What it does not carry is
Claude Code's custom_instructions before and compact_summary after — which costs
nothing here, because the checkpoint is composed from durable state
(.leopold/state.json, PLAN.md, the journal) and never from the payload's summary. See
Continuity.
stop-failure.sh (StopFailure) — an API error is a wait, not a verdict. Claude Code
only. On a failed turn Stop does not fire at all, so StopFailure is the single witness,
and its error names the class (rate_limit, server_error, authentication_failed).
Codex has no failure hook: an API error ends the turn as turn.failed in the --json
stream, which the driver reads instead — the matrix marks the row unavailable and
leopold doctor says so in words rather than letting the silence read as coverage.
file-watch.sh (FileChanged) and config-guard.sh (ConfigChange) — the second-writer
and tamper detectors. Claude Code only; both rows are unavailable on Codex, the
installer refuses the specs there by name, and doctor prints the consequence: a second
writer on the plan's files, and a mid-run edit of the hook wiring, are not detected on
Codex.
review-lens-roles — the one capability whose Codex side is the richer one. The
driver's review lenses are native Codex agent roles ($CODEX_HOME/agents/<lens>.toml,
honored by spawn_agent(agent_type=<lens>) and echoed back in SubagentStart); Claude
Code has no role files, so its lenses are the driver's own SDK sessions and the matrix
marks Claude Code the substitute. Parity is not asserted twice — it is derived by test
from the driver's own lens list.
So autonomy is not a Claude-only feature. /leopold-run keeps a Codex session going
the same way it keeps a Claude Code session going, with the same budgets, the same
no-progress detection, and the same context ceiling.
The one manual step on Codex¶
Codex will not execute a hook declared in config.toml until you have trusted it
once. Until then it is silently inert — no error, it just does not run.
Leopold does not try to forge that approval. Two ways through it:
- Open Codex once and approve the Leopold hooks. After that the git lock, the continuity engine and the permission policy are live in every interactive session.
- Install Leopold as a Codex plugin. Plugin-provided hooks are trusted through the plugin install, so there is no separate step.
Headless workers started by leopold run --provider codex arm themselves, so a
driver-conducted run is locked from its first turn whether or not you have approved
anything.
leopold doctor tells you which state you are in.
Installing¶
./install.sh # every harness found on this machine
./install.sh --harness claude # Claude Code only
./install.sh --harness codex # Codex only
./install.sh --harness all # both, installed or not
The skills go into each harness's skills directory. The hooks, templates, docs and
extensions go into one shared asset home — ~/.claude/leopold when Claude Code is in
play (so existing installs keep working), otherwise ~/.codex/leopold. Override it
with LEOPOLD_HOME.
The Codex wiring is written into config.toml as a marker-delimited block:
# >>> leopold (managed) >>>
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/stop-continuity.sh"
timeout = 15
[[hooks.PreToolUse]]
matcher = "Bash|Edit|Write|MultiEdit|NotebookEdit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/guard-irreversible.sh"
timeout = 5
[[hooks.PermissionRequest]]
[[hooks.PermissionRequest.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/permission-policy.sh"
timeout = 5
[[hooks.PreCompact]]
[[hooks.PreCompact.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/compact-checkpoint.sh"
timeout = 10
[[hooks.PostCompact]]
[[hooks.PostCompact.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/compact-checkpoint.sh"
timeout = 10
[[hooks.SubagentStart]]
[[hooks.SubagentStart.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/subagent-account.sh"
timeout = 10
[[hooks.SubagentStop]]
[[hooks.SubagentStop.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/subagent-account.sh"
timeout = 10
[[hooks.PreToolUse]]
matcher = "Agent|Task|collaborationspawn_agent"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/subagent-cap.sh"
timeout = 5
[[hooks.PostToolUse]]
matcher = "Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/verify-receipt.sh"
timeout = 10
[[hooks.PreToolUse]]
matcher = "Edit|Write|MultiEdit|apply_patch"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/done-gate.sh"
timeout = 5
# <<< leopold (managed) <<<
Re-installing replaces the block and nothing else. Your config is backed up first, and the merged file is validated — if it would not parse, the install rolls back and prints the block for you to paste yourself.
Extensions install on every harness you have¶
The four bundled extensions — serena, enhance, ovmem, gstack — are not
Claude-only either. Each one installs, reports and cleans up per harness:
| Extension | Claude Code | Codex CLI |
|---|---|---|
| serena | claude mcp add --scope user + 4 hooks in settings.json |
codex mcp add + the same 4 hooks in config.toml, --context=codex, serena-hooks --client=codex |
| enhance | UserPromptSubmit in settings.json, plain-text injection |
UserPromptSubmit in config.toml, JSON hookSpecificOutput.additionalContext |
| ovmem | 4 hooks in settings.json, in-process 25s flush |
the same 4 in config.toml; SessionEnd declared at 3s and the flush detached, because Codex hard-caps that hook at 3 seconds |
| gstack | ./setup --host claude → ~/.claude/skills |
./setup --host codex → ~/.codex/skills (checkout in ~/.gstack/repos/gstack) |
Two rules hold across all four. One writer per format: the JSON and TOML
wiring lives in a single shared helper, extensions/lib/harness.sh, so the two
harnesses cannot silently drift apart. And nothing reports per machine:
status, remove and doctor answer one line per harness, so a two-harness box
never sees one harness's state passed off as both, and Codex's hook-trust gate is
named instead of being shown as a green that is not live yet.
bash extensions/serena/manage.sh doctor # or ovmem / gstack / enhance
leopold doctor # every harness present, in one pass
The engine data is shared even though the wiring is not: ovmem keeps one memory directory for the machine, so a decision recorded in a Codex session comes back in a Claude Code one, and the enhancer's ledger and prompt profile are the same files from either seat.
Each suite is hermetic — temp HOME/CLAUDE_HOME/CODEX_HOME, stubbed CLIs, no
network: make serena-test, make ovmem-test, make gstack-test,
make enhance-test, plus make codex-install-test for the whole Codex install
end to end.
The dashboard reads Codex runs¶
leopold watch shows real tokens, cost and context on a Codex run — the panel is
not blank and, more importantly, never a zero.
It finds the transcript the same way on both harnesses: the path the hook reported
in state.json, or else the newest session for this project — a Claude Code
transcript under ~/.claude/projects/, or a Codex rollout under
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl whose session_meta.cwd is this
project. It then sniffs which one it is and parses accordingly: Claude Code's
per-message usage + model, or Codex's event_msg lines with
payload.type == "token_count" (info.total_token_usage, including
cached_input_tokens and reasoning_output_tokens).
Codex reports tokens and never dollars, so cost is priced from a built-in
per-model table, cached input billed at 0.1x. An unrecognized model falls back to
a non-zero default rate: a run priced at zero would silently disable
--budget-usd, the one failure a budget cannot have. And a live rollout that has
not reported usage yet says "waiting for session data…" rather than quoting
$0.00.
Extension dashboard tabs follow the same rule. An extension.json
dashboard.module may be a path relative to the asset home (ovmem/dashboard.py),
resolved Claude-first exactly like the installers do — so the Memory tab shows up
on a machine with no ~/.claude instead of silently vanishing.
Covered by make watch-test (stdlib only, no network), which asserts Codex
detection, pricing, discovery by cwd, the unknown-model fallback and the
no-usage-yet case.
Choosing a harness for a run¶
leopold harness # what is here, and what each one can do
leopold run # conduct on the default harness
leopold run --provider codex # conduct on Codex
LEOPOLD_PROVIDER=codex leopold run # same, from the environment
Precedence is --provider → LEOPOLD_PROVIDER → the only harness installed → the
harness whose session Leopold was launched from → Claude Code as the last resort. A
name Leopold does not recognize is an error, not a silent fallback — conducting a run
on the wrong harness because of a typo is not a failure mode worth having.
That fourth step exists because the tie-break used to be the third: on a machine with
both, workflow --run launched from a Codex session started the Claude Agent SDK. Each
CLI marks its child environment — Codex exports CODEX_THREAD_ID (and CODEX_CI),
Claude Code exports CLAUDECODE / CLAUDE_CODE_SESSION_ID — so the run now belongs to
the seat you launched it from. With no marker at all, the Claude fallback is unchanged.
One run, two harnesses¶
A run does not have to pick one. --provider hybrid assigns a harness per role:
executor is the workers, review is the review lenses, hypothesis panels and
tournament judges, conductor is the turn decisions and routing. A role left unset
inherits the resolved default. Every agent's provider lands in .leopold/events.jsonl
(run_start, wf_phase, wf_agent_start), because a run split across two harnesses
that does not record which one answered is a run you cannot debug.
A run with no hybrid flags gets no role assignment at all — which is exactly why a single-provider run is byte-for-byte what it always was.
How the driver reaches each harness¶
Everything in the driver — worker turns, conductor decisions, review lenses,
hypothesis panels, routing, tournament judges — goes through one seam,
packages/driver/src/sdk.ts, and consumes one small message shape. The provider
behind that seam is swappable:
- claude — the Agent SDK's
query, on your own Claude Code auth. - codex —
codex exec --json, on your own Codex login. Multi-turn items usecodex exec resume <thread_id>, which gives the worker the same property that matters: fresh context per plan item, continuous context within one.
Because the shape is identical, no call site knows which harness answered. The mapping the Codex side has to do is small and lives in one file:
| Driver concept | Codex |
|---|---|
cwd |
-C <dir> |
model |
-m <model> |
effort |
-c model_reasoning_effort=… (max → xhigh) |
read-only session (disallowedTools) |
--sandbox read-only |
canUseTool guard |
the PreToolUse hook + --dangerously-bypass-hook-trust |
total_cost_usd |
token usage priced by model |
That last one is worth knowing about: Codex reports token counts, never a dollar
figure, so --budget-usd prices the run from a built-in table. An unrecognized model
falls back to a default rate rather than zero — pricing a run at zero would silently
disable the budget, which is the one failure mode a budget cannot have.