callva-harness-runner
One run of an agent harness, as a Python library. A harness is the vendor's agent runtime: Claude Code, driven through Anthropic's claude-agent-sdk, or the Codex CLI, driven through OpenAI's openai-codex, always the CLI installed on the machine. A profile is one harness configuration, a flat set of explicit knobs. A run is one turn: a prompt goes in, the harness works to its end or its deadline, and one Result comes out, with the failure named. The deadline kills everything the harness started. What a run may do is whatever its profile says, and nothing else; the library never decides it. It ships three profiles, read, act and read-sandboxed, that a caller loads by name, and a caller's own profile files of the same name come first.
DESIGN.md is the contract. The source is at https://github.com/callva-io/harness-runner.
Formerly callva-agentworker, whose last release is 0.2.0. The 0.3.0 renames, with no aliases (each old name is refused with a message naming the new one):
| 0.2.0 | 0.3.0 |
|---|---|
distribution callva-agentworker |
callva-harness-runner |
import callva.agentworker |
callva.harness_runner |
profile knob engine |
harness |
Result.engine |
Result.harness |
Result.engine_version |
Result.harness_version |
failure kind incompatible_engine (FailureKind.INCOMPATIBLE_ENGINE) |
incompatible_harness (FailureKind.INCOMPATIBLE_HARNESS) |
ENGINES |
HARNESSES |
Probe.engine |
Probe.harness |
Install
pip install callva-harness-runner
It depends on claude-agent-sdk==0.2.161 and openai-codex==0.159.0, pinned exactly; together they add about 0.5 GB, most of it the CLIs both SDKs bundle. The bundled CLIs are never run: the library always runs the CLI installed on the machine.
In a PEP 723 script, pin the exact version so nothing outside the script's own release changes what it runs:
# /// script
# dependencies = ["callva-harness-runner==0.4.0"]
# ///
Use
from callva.harness_runner import Profile, Session, run
profile = Profile.from_dict(config["profile"]) # your configuration, your knobs
result = run("Summarise README.md in one line.", profile, cwd="/path/to/project")
if result.ok:
print(result.answer, result.cost_usd, result.session_id)
else:
print(result.failure.kind, result.failure.message)
run() never raises for anything the harness did. Every ending is a Result; result.failure.kind says what kind, and result.failure.message carries the harness's own sentence. It raises ValueError only for a request it cannot express, such as a pinned session on codex.
run(prompt, profile, cwd, *, session=None, environ=None, extra_env=None, on_event=None, cancel=None, stderr_tail=40):
session:Session.fresh()(the default),Session.pinned(id)orSession.resume(id), below.environ: the environment the harness starts from; this process's by default. A variable absent from it is absent in the harness, even though both SDKs inherit this process's environment.extra_env: variables set on top ofenvironand the profile'senv_set.on_event: called with one dict per harness event while the turn runs. On claude it is the SDK's message: a system message is the harness's own event (subtypeinitcarriesapiKeySource,modelandclaude_code_version), any other message is its fields plustype, the SDK class name. On codex it is{"method", "params"}, one app-server notification.cancel: athreading.Event; setting it ends the turn and kills its processes.
Profile
A profile is a flat set of explicit knobs. There is no inheritance and no fence. A knob left at its default leaves the harness's own default in place. A knob one harness cannot honour is refused for that harness with a ProfileError when the profile is built, never ignored while it runs. Each knob's docstring on Profile says what it does on each harness; PROFILE_SCHEMA (shipped as profile.schema.json) validates a profile kept in configuration, and Profile.from_dict refuses unknown keys.
| Knob | claude | codex |
|---|---|---|
harness |
claude |
codex |
cli_path |
the CLI the SDK launches; None finds the installed claude |
the binary whose app-server the SDK talks to; None finds the installed codex |
model |
--model |
the thread's model |
effort |
--effort |
the turn's reasoning effort |
timeout_seconds |
the deadline; default 600 | the same |
budget_usd |
--max-budget-usd |
refused: codex reports no cost |
tools |
--tools, the built-in tools that exist; [] removes them all |
refused |
allowed_tools |
--allowedTools, patterns that run without a prompt |
refused |
disallowed_tools |
--disallowedTools |
refused |
permission_mode |
--permission-mode |
refused: use approval_policy and codex_config |
setting_sources |
--setting-sources; ["project", "local"] loads the target's CLAUDE.md, rules and hooks without the user's |
refused |
settings |
--settings, an object or path layered above the settings files: sandbox, permissions |
refused: use codex_config |
mcp_config |
--mcp-config, {"mcpServers": {...}} or a path |
refused: mcp_servers in codex_config |
strict_mcp |
--strict-mcp-config |
refused |
add_dirs |
--add-dir per entry |
refused: give the directory access in codex_config permissions |
append_system_prompt |
appended to the Claude Code system prompt | the thread's developer instructions |
output_schema |
--json-schema |
the turn's output schema |
name |
--name |
the thread's name |
env_set |
set in the harness's environment | the same |
env_remove |
names or globs removed from what the harness inherits | the same |
strip_session_markers |
also remove SESSION_MARKERS; default true |
the same |
codex_config |
refused: use settings |
the thread's config, keyed as in config.toml: permission profiles, sandbox_mode, network, mcp_servers |
approval_policy |
refused: use permission_mode |
never (escalations refused) or auto_review (codex's reviewer decides); None is the SDK default, auto_review |
service_tier |
refused | the thread's service tier |
bypass_hook_trust |
refused: claude has no hook trust | refused when true: the app-server has no hook-trust bypass, so hooks run only when the machine trusts them |
claude_extra_args |
extra CLI flags, {"flag": "value"} or {"flag": None} |
refused |
codex_extra_args |
refused | arguments before app-server, such as ["-c", "key=value"] |
Claude always runs on the claude_code system-prompt preset, so a turn has the same prompt claude -p has; the library never replaces either harness's system prompt, and append_system_prompt only adds to it.
Profiles by name
A profile file is TOML with one table per harness, [claude] and [codex], each holding that harness's knobs. The table name is the harness: a harness key inside it may be left out, and when present it must match, or the file is refused. Nothing else may sit at the top of the file.
from callva.harness_runner import find_profile, run
import dataclasses
profile = find_profile("read-sandboxed", "codex", ["capabilities/myapp/profiles"])
profile = dataclasses.replace(profile, model="gpt-5.6-luna") # change a knob for one run
result = run("What does this project do?", profile, cwd="/path/to/project")
A name resolves to the first NAME.toml found in, in order:
- the folders the caller passes, in the order given;
- the machine folder,
$XDG_CONFIG_HOME/callva-harness-runner/profiles, or~/.config/callva-harness-runner/profileswhenXDG_CONFIG_HOMEis unset; - the profiles shipped inside the package.
The file found is used whole: no merging and no inheritance. A found file without a table for the requested harness is an error naming the file and the harness, and the search does not go on to a later source. A name is one file-name segment of letters, digits, ., - and _, not starting with .; anything else is refused.
find_profile(name, harness, folders=(), *, environ=None) -> Profilefind_profile_file(name, folders=(), *, environ=None) -> ProfileFile: the file itself (name,source,path,text,shadows), with.harnesses()and.profile(harness).list_profiles(folders=(), *, environ=None) -> list[ProfileListing]: every visible profile with itsname,source(folder,machineorshipped),path,harnesses, the same-named files itshadows, and anerrorwhen the file cannot be read as a profile file.machine_folder(environ=None) -> Path
ProfileNotFound, a ProfileError, means no source has the name.
From a shell, python -m callva.harness_runner profiles prints the listing as JSON, and python -m callva.harness_runner profiles show NAME prints the file a lookup resolves to, after a comment line naming its source and path. Both take --folder DIR, once per folder, in search order.
Shipped profiles
All three run claude-opus-5-5 and gpt-6-sol at effort medium with a one-hour deadline. Print one with profiles show NAME.
read: a research question held to reading by instruction alone. Every tool and a full shell (claudebypassPermissions, codexdanger-full-access), the target's project and local settings on claude, a read-only instruction appended to the system prompt, andCAPABILITIES_READ_ONLY=1.act: a task with full access (claudebypassPermissions, codexdanger-full-access, approvals never).read-sandboxed: the same read, enforced by the operating system. The run's working directory cannot be written; the network,~/.cacheand the temporary directory stay writable. On claude, Claude Code's seatbelt sandbox withask: ["Bash"], the write tools disallowed, no MCP servers,disableAllHooks, and the permission ruleEdit(./**), which Claude Code resolves against the working directory and merges into the sandbox's write denials; adenyWritepath would resolve against the settings root instead, not the run's directory. On codex, a named permissions profile extending:read-onlywith~/.cacheand:tmpdirwritable and the network on. The same read-only instruction andCAPABILITIES_READ_ONLY=1. On claude, read-sandboxed does not run the target's hooks, because a hook runs outside the sandbox and could write the target: context a hook would build, such as a ContextKit SessionStart build, is read as it already is on disk. CLAUDE.md and the project's rules still load. The plainreadprofile does run the target's hooks.
Environment
Both SDKs only add to the environment they inherit. To remove a variable for real, the library puts a small launcher of its own in front of the CLI: the SDK starts the launcher, the launcher removes what the profile removes and becomes the CLI. The launcher learns names, never values. SESSION_MARKERS are the variables a running Claude Code session leaves in its children (CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_EFFORT, AI_AGENT and the rest); a child harness that inherits them believes it runs inside that session, so they are removed unless strip_session_markers is false. On claude the values the SDK itself sets for the CLI, such as its entrypoint, stay.
Deadline and cancel
The launcher makes the harness a session leader. When the deadline passes, or cancel is set, no further harness start is allowed, and the harness, every process it started, and everything those left behind in their process group or session are sent SIGTERM and then SIGKILL, grandchildren included. A caller that is itself killed cannot do this, and the harness's tree then outlives it.
Harness guard
TESTED_VERSIONS holds, per harness, the range of CLI versions this release was tested with: claude 2.1.280 to 2.1.284, codex 0.159.0. Before a turn the library reads the installed CLI's --version:
- older than the range: the turn is refused with failure kind
incompatible_harness, and the message names the installed version and the tested one; - newer than the range: the turn runs, and
result.warningsnames both versions; - no CLI:
binary_missing, as before.
Session
Session.fresh() starts a conversation; on claude the id is chosen before launch so a killed turn is still addressable, on codex it is the thread id the app-server gives. Session.resume(id) continues one on either harness. Session.pinned(id) chooses the id and is claude-only.
Result
| Field | Meaning |
|---|---|
ok |
the harness confirmed a completed turn with an answer |
harness |
claude or codex |
answer |
the final text |
structured |
the parsed answer when output_schema was given |
session_id |
claude session id or codex thread id, set whenever known, including after a kill |
model |
the model that ran when claude reports it, else the requested one |
cost_usd |
claude's own estimate; None on codex |
duration_ms, num_turns |
as reported; num_turns is None on codex |
tokens |
input, output, cache_read, cache_creation for this turn |
permission_denials |
claude's list; empty on codex |
failure |
None when ok; else kind and the harness's own message |
exit_code |
the exit code the claude SDK reported for a failed process, else None |
stderr_tail |
the harness's last stderr lines, for a person |
command |
the CLI command line the launcher started |
raw |
claude's result message, or codex's {"turn", "items", "errors"} |
harness_version |
the installed CLI version the guard read |
warnings |
the guard's warning when the CLI is newer than tested |
Failure kinds
quota, model_refused, not_authenticated, timeout, cancelled, binary_missing, max_turns, budget, invalid_output, error, crash, incompatible_harness. The harness's own verdict is read first (claude's result subtype, codex's codexErrorInfo), then an HTTP status it reports, then its sentence: a spent quota before a refused model, because a subscription spent on one model says both and only one of them is a pause. A claude turn that times out while the harness is retrying a 401 or a 429 is not_authenticated or quota. failure.retryable is true for the kinds where running the same turn again later can succeed.
Changelog
0.4.0
Profiles by name: profile files (one table per harness), find_profile, find_profile_file, list_profiles, machine_folder, the profiles command, and three shipped profiles, read, act and read-sandboxed. Additive: everything in 0.3.0 works unchanged.
0.3.0
The library is renamed and speaks of harnesses, profiles and runs; its behaviour is exactly 0.2.0's. What a 0.2.0 consumer must change: depend on callva-harness-runner and import callva.harness_runner; write harness where a profile said engine; read Result.harness and Result.harness_version; branch on incompatible_harness; import HARNESSES. The table at the top maps every old name.
0.2.0
The engines now run through the vendors' SDKs, and the profile is explicit knobs. What a 0.1.x consumer must change:
fenceis gone, and so is its default. A 0.1.x profile that named no fence ran fenced toread; the same profile now runs with the engine's own defaults. State what the turn may do withtools,allowed_tools,disallowed_tools,permission_mode,setting_sources,settings,mcp_configandstrict_mcpon claude, andcodex_configandapproval_policyon codex.Profile.from_dictrefuses afencekey with a message naming these knobs, andProfile.fenceno longer exists.allow_toolsbecomestoolsplusallowed_tools.env(allow,deny,set) becomesenv_set,env_removeandstrip_session_markers; there is no allow-list.extra_argsbecomesclaude_extra_args(a mapping) orcodex_extra_args.EnvPolicy,DEFAULT_DENYandFENCESare no longer exported.run()losesschema(now the profile'soutput_schema),name(now the profile'sname) andprompt_via(the prompt always travels over the SDK's own channel).- A knob the engine cannot honour is refused instead of ignored:
budget_usdon codex,service_tieron claude. on_eventreceives SDK messages (claude) and app-server notifications (codex) instead of CLI JSON lines.rawchanges shape the same way,commandis the command line the launcher started, andexit_codeisNoneunless the claude SDK reported one.- A CLI older than the tested range is refused with the new kind
incompatible_engine;Resultgainsengine_versionandwarnings. - Two dependencies arrive, about 0.5 GB.
Unchanged: Profile.from_dict, Session.fresh, Session.pinned, Session.resume, run(prompt, profile, cwd, session=, environ=, extra_env=, on_event=, cancel=), FailureKind, and every 0.1.x Result field.
Versioning
The major stays 0. A breaking change or a new feature bumps the minor; a fix bumps the patch. A changed shipped profile is a change to what a run does, so it is at least a minor.
License
Apache-2.0.
Metadata
Release files for callva-harness-runner 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| callva_harness_runner-0.4.0.tar.gz | 41.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| callva_harness_runner-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 88.1 kB
Release files / callva_harness_runner-0.4.0.tar.gz
| Download URL | callva_harness_runner-0.4.0.tar.gz |
|---|---|
| Size | 41.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b340b45a85f6b7ab96309ae233f40dadac901fd9e6cee71b9b433754fde2883f
|
|
BLAKE2b-256 checksum How to use checksums |
e0276c9799c639c9e76abff3c1bcdc65083ebb32ab44a958273ea12333a717ac
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency logRelease files / callva_harness_runner-0.4.0-py3-none-any.whl
| Download URL | callva_harness_runner-0.4.0-py3-none-any.whl |
|---|---|
| Size | 46.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d252ea71d674d7bf09121509b01c5de78668cde3cad65ae16efc52fb104dd934
|
|
BLAKE2b-256 checksum How to use checksums |
9948fc84f24f36a3f657beafa38896b883c7b988c8cbcad9384b8ae4bea24cd2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency log