Skip to content

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:

{"decision":"block","reason":"…"}

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:

  1. 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.
  2. 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:

leopold workflow --run --provider hybrid \
  --executor-provider codex \
  --review-provider claude

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 use codex 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.