agent-abstraction
Drive Claude Code, Codex and GitHub Copilot headlessly from Rust: one request type, one event vocabulary, one session model across three CLIs that agree on none of those things.
[]
= "0.2"
Every flag mapping and output shape here was verified against the installed CLIs rather than taken from documentation, which is the part that keeps being wrong.
It is a library, not a CLI. Your program links it and spawns the agent directly, so nothing marshals a request through a command line and back out of stdout twice.
use ;
let outcome = run
.await?;
println!; // "pong"
println!;
What each agent can actually do
Verified live, against claude 2.1.205, codex-cli 0.145.0 and GitHub Copilot CLI 1.0.75
and not inferred from documentation.
| session id | fork | events | system prompt | resume flag | |
|---|---|---|---|---|---|
| Claude Code | caller-minted (--session-id) |
yes (--fork-session) |
stream-json |
native (--append-system-prompt) |
--resume |
| Codex | agent-printed (thread_id) |
no | --json |
prepended to prompt | exec resume <id> |
| Copilot | caller-minted (--session-id) |
no | --output-format json |
prepended to prompt | --session-id |
Can I choose the session id, or do I have to read it back?
Both, depending on the agent. Verified by round-trip, not from --help:
| assign it up front | read it back | |
|---|---|---|
| Claude Code | yes, .session_id(uuid) |
also reported |
| Copilot | yes, .session_id(uuid) |
also reported |
| Codex | no | thread_id, before it answers |
// Claude and Copilot: the id is yours to pick, so it can match a thread id
// your app already has, with no mapping table in between.
let mine = new_v4.to_string;
let outcome = run.await?;
assert_eq!;
Both CLIs require a valid UUID. Asking Codex for an assigned id is an
Error::Unsupported raised before spawning, never a silently unrelated conversation.
Assigning beats reading back, where you get the choice: the binding exists before the
process starts, so a run that dies mid-turn still leaves a resumable session. Codex's
thread_id can only be recorded once it has been printed.
That said, Codex prints it early. It arrives in thread.started, the first record of the
stream, before any answer text, so a host can persist the binding the moment the stream
opens rather than waiting for the turn to finish. Event::Started is the normalized form of
that moment for all three agents.
Asking an agent for something it cannot do is always an Error::Unsupported, never a quiet
downgrade. A caller that asked to fork and silently got a linear resume would corrupt the
conversation it meant to branch.
Streaming
let mut running = stream?;
while let Some = running.recv.await
let outcome = running.finish.await?;
Streaming is the default. Format::Stream is what you get without asking, because the
alternative is silence: under Format::Json a run that takes twenty minutes reports nothing
for twenty minutes, which is indistinguishable from a hang. Stream carries everything
Json does, session id and schema-conforming value included, so the default costs only
parsing.
Text arrives token by token on Claude (via --include-partial-messages) and Copilot, and
message by message on Codex, which has no finer granularity. Claude sends deltas and the
completed message they build up to; only the deltas are emitted, or a transcript would show
every answer twice.
Event::Text is the incremental display stream. Outcome::text is the agent's own
authoritative answer. They are deliberately separate: concatenating the deltas is not
guaranteed to equal the final text (Copilot emits both; Claude emits only the latter), so
read Outcome::text for the answer and never sum the events.
Sessions
Thread one stable name across turns; the store maps it to whatever handle the agent understands.
let store = open;
let turn = new
.session?;
assert_eq!;
let outcome = run.await?;
Records live at <dir>/<project-slug>/<name>.json, partitioned by project so the same name
in two checkouts never collides, and written through a temp file and a rename so a
concurrent reader never sees a half-written record. A corrupt record reads as absent: the
next turn opens a fresh conversation rather than failing over a cache nobody asked about.
Names are percent-encoded into a single path segment, which is injective: two different
names can never land on the same file. That matters more than it sounds, because the failure
mode of a lossy scheme is silent, not loud. Folding unsafe characters to - would map
café and cafe- together, and the second session to use that name would quietly resume
the first one's conversation. Uppercase is encoded too, since macOS and Windows are
case-insensitive and would otherwise collide Chat with chat. The record keeps your
original name, so list() hands back what you passed, not a mangled segment.
Choosing a model
Agent::models() returns what each agent offers, so a host can render a picker without
hard-coding three vendors' worth of ids:
for model in Claude.models
The list is advisory and never enforced. Request::model takes any string and nothing
here checks it, so a model released this morning is not blocked by a list compiled last
month. A model the account cannot reach comes back as Error::AgentError with the
provider's own status and wording.
That distinction matters more than it sounds, because a catalogue is not an entitlement. Copilot's picker lists twenty-three models and a Free plan permits exactly one:
Your Copilot Free plan currently includes only Auto, which automatically selects the
best available model for each task.
Every other id there is refused before a request is made, including gpt-5.4, the example
in Copilot's own --help. Treat the list as choices to try, and let the run report what
the account actually allows. Model::is_default marks the safe pre-selection.
Aliases and pinned ids are both carried, and Model::kind tells them apart, because they
do not always agree. On claude 2.1.212, --model opus reported claude-opus-4-8 while
--model claude-opus-5 reported claude-opus-5, even though that release's own notes call
Opus 5 the default Opus model. An alias is whatever the account resolves it to.
Reasoning effort
Model::efforts lists the levels each model takes, and Request::effort sends one:
let request = new.model.effort;
Passed through verbatim, like the model, because the sets are not interchangeable: Claude
documents five levels, Copilot seven, and Codex varies them per model, offering ultra on
its two frontier models and not on the rest. Delivered as --effort on Claude and Copilot,
and as -c model_reasoning_effort=<level> on Codex, which has no flag for it.
Support is not uniform even within one agent. Copilot's auto exits 1 rather than
ignoring the flag:
Error: Model "auto" does not support reasoning effort configuration (requested: "low")
so its catalogue entry carries no levels. An empty efforts means a picker has nothing to
offer for that model, whether because it accepts none or because the levels are not
established here; the entry says which.
Where a CLI can be asked directly, prefer that:
let models = Codex.discover_models.await?; // reflects the installed binary
discover_models returns Error::Unsupported on Claude and Copilot rather than silently
returning the compiled list: both enumerate models only in an interactive picker, and a
caller asking for discovery is asking for freshness. Agent::models_verified() records how
each compiled list was established and against which release.
Structured answers
When the answer is data rather than prose, constrain it with a JSON Schema and read it back parsed instead of guessing at formatting the model never promised:
let outcome = run
.await?;
assert_eq!;
The delivery differs and is hidden: Claude takes the schema inline and reports the value in
its own field, Codex reads it from a file this crate writes and removes, and returns the
value as its answer text. Copilot 1.0.75 has no schema support at all, so asking is an
Error::Unsupported rather than prose dressed up as data.
Write schemas strictly. Codex sends yours to OpenAI's structured-output API, which
requires "additionalProperties": false on every object and every property in required.
Without it the request fails with a 400 before the model runs. Claude is more forgiving, so
a schema that works there can still fail on Codex; writing to the stricter rule keeps one
schema usable for both.
Cancellation
Dropping a Run kills the agent, and everything it spawned. Closing a window or
cancelling a request should stop the work, not leave an agent running invisibly, spending
quota and writing files with nobody watching.
let running = stream?;
drop; // agent and its children are killed
running.cancel.await?; // cooperative: returns only once the tree has exited
running.detach; // opt out: keep running unsupervised
cancel is cooperative rather than an abort: the driver signals the process group, reaps
the child and joins its readers before returning Error::Cancelled. So when it returns the
tree really is gone, which matters if the next thing you do touches the files it was working
on.
drop synchronously signals the process group on Unix, then aborts the driver. What it
cannot do is wait: Drop cannot await, so it does not block until the child has exited and
its readers are joined. Both kill the tree; only cancel tells you when it is gone. On
Windows only the direct child is signalled.
Each run gets its own process group on Unix, and cancellation, drop and timeout all tear down the whole group. Killing only the CLI would orphan the commands it started, which keep holding files and credentials afterwards. Windows has no equivalent here yet: only the direct child is killed, since containing a tree there needs a Job Object.
Permissions
Permission maps one posture onto each agent's own vocabulary:
| Claude | Codex | Copilot | |
|---|---|---|---|
ReadOnly |
dontAsk + --disallowedTools |
--sandbox read-only |
--deny-tool=shell,write |
Plan |
--permission-mode plan |
--sandbox read-only |
--mode plan |
Edit |
acceptEdits |
--sandbox workspace-write |
--deny-tool=shell |
Auto |
--permission-mode auto |
--sandbox workspace-write |
--allow-all-paths |
Bypass |
bypassPermissions |
--dangerously-bypass-approvals-and-sandbox |
--allow-all-paths |
The default is ReadOnly. Widen it explicitly.
What this does not cover. These postures constrain each CLI's built-in tools: its
shell, its file writes, its sandbox. They do not constrain MCP servers, plugins or
custom tools, which are a separate tool category in all three CLIs. An MCP tool that files
an issue, writes to a database or calls a deploy API can still act during a nominally
read-only run. Claude's mapping denies mcp__* as well, but the other two have no
equivalent switch, so if a run must not cause remote side effects the containment has to be
which MCP servers are enabled at all.
Two more honest limits: Codex has no true plan mode, so Plan maps to its read-only
sandbox (writes blocked, execution still permitted), and unchecked_args can contradict any
of this by design.
Is each agent logged in?
Without spending a request:
for agent in ALL
claude-code: logged in as you@example.com (max)
codex: logged in as ChatGPT
copilot: unknown: copilot exposes no status command, so this cannot be
confirmed without spending a request
Claude answers JSON (claude auth status), Codex answers prose
(codex login status), and Copilot offers neither. That third case reports Unknown
rather than "logged out", because telling someone to re-authenticate a working setup is
worse than admitting the question cannot be answered. needs_login() is true only for a
confirmed logout, so gating on it never nags about an agent that simply cannot be asked.
Environment isolation
EnvPolicy::Minimal is the default. Inheriting the whole environment is what a CLI gets
from a shell, but this crate runs inside processes that hold unrelated secrets, and full
inheritance hands every one of them to the agent and to every command the agent runs. That
is worth deciding deliberately, so it is the opt-in:
new // Minimal, nothing to configure
new.env_policy // opt in
Minimal passes through only what the selected agent needs. The crate owns that list per
agent rather than the caller, because an incomplete hand-written one fails as an
authentication error rather than as an obvious config mistake.
The list was derived by experiment, not assumption: PATH + HOME alone is not enough,
because Claude's keychain lookup is keyed on USER and returns "Not logged in" without it.
PATH + HOME + USER is the verified floor for all three on macOS. Windows names are
included on the same reasoning but are unverified.
Proxy and custom-CA variables are deliberately excluded. They are situational rather
than required, and HTTPS_PROXY routinely embeds credentials (http://user:pass@proxy),
so forwarding them automatically would leak one through the policy meant to withhold
secrets. A host that needs them should surface them as a setting; NETWORK_ENV names them
so a settings screen does not have to hardcode the list:
for name in NETWORK_ENV
Two tests keep it honest: a live one asserting every agent still authenticates under
Minimal, so an incomplete list fails loudly, and a deterministic one asserting the host's
own variables do not reach the child.
Gotchas worth knowing
codex execrefuses to run outside a git repository. This crate always passes--skip-git-repo-check, so it runs anywhere. That check exists to stop an agent editing files with no way to undo them; the sandbox is the real containment here, and it defaults toread-only.codex exec resumedoes not accept--sandbox. It is a different option set fromcodex execand rejects the flag outright, so the permission posture is applied as-c sandbox_mode=...on the resume path. Only a multi-turn run reveals this: every single-turn test passes either way.- Copilot's tool filters need
=. They are declared--deny-tool[=tools...], an optional value, which binds only as--deny-tool=shell. Across a space the value is read as a positional and the deny is silently lost. This crate always emits the combined form. - Copilot needs
--allow-all-toolsto run headlessly at all, or it stalls at the first tool confirmation. It is always emitted;Permissionthen narrows via denies. - A failed turn still exits 0. Ask Claude for a model that does not exist and it exits
cleanly, reports
subtype: "success", and puts "There's an issue with the selected model" where the answer belongs. Codex does the same and wraps the upstream body in a JSON string. Both come back asError::AgentError; see below. - Claude's
stream-jsonrequires--verbose, or it refuses to start. Handled. - Large prompts move to stdin automatically above 128 KiB, so a long prompt never fails
with
E2BIG.
When a vendor changes its output
The CLIs move. A format change shows up here as a run that exits 0 and returns nothing,
which is a miserable thing to debug from the outside, so Outcome carries the evidence:
if outcome.looks_like_a_format_change
Unparseable lines are counted rather than discarded. A non-zero count on its own is normal (agents interleave banners with their JSON); a non-zero count with an empty answer is the signature worth alerting on.
Captured buffers (text, raw stdout, stderr) are bounded at MAX_CAPTURE, 1 MiB, keeping
the earliest output. An agent can stream for hours, and an unbounded capture turns a long
run into an OOM instead of an answer. Individual lines are bounded separately at MAX_LINE,
because a reader that accumulates until a newline can exhaust memory on one line that never
ends, long before any total cap applies.
Individual event payloads are bounded at MAX_EVENT_BYTES (64 KiB) and marked with
TRUNCATION_MARK when shortened. The channel bounds how many events queue, not how large
they are, so without this a stalled consumer could hold roughly 130 MiB; with it, about
16 MiB. Identifiers are exempt: a shortened session id cannot resume anything and a
shortened tool id cannot be matched to its call.
Under a structured format there is no silent fallback to raw stdout: a run that produced no
recognizable records, or never reached its terminal record, returns Error::Parse rather
than a plausible-looking answer assembled from whatever was printed.
A clean exit is not a successful turn
All three agents report some failures with exit code 0, with the explanation where the answer
should be. An unknown model, a rejected schema and an upstream outage all arrive this way, so
a caller checking only Result::is_ok renders the error as the model's reply.
These are Error::AgentError, carrying the agent's own wording and the provider's status
where one was reported:
match run.await
NotAuthenticated and RateLimited are the two members of this family that predate it and
keep their own variants, because the remedy differs. Everything else in it lands here.
Codex needs one extra step: it forwards the provider's response body as a string, so its
turn.failed message is JSON containing the sentence and the status. This crate unwraps it,
so message is the sentence and status is the code. A message that is not that shape is
passed through untouched.
No shell, ever
Arguments are built as a Vec<String> and passed straight to exec. There is no shell in
the path, so a prompt containing ;, backticks or $(...) is data, not syntax, with no quoting
or escaping to get wrong.
Operating within the agents' terms
This crate drives each vendor's own supported headless interface using the credentials that
CLI already holds. It does not reimplement a provider API, multiplex accounts, or retry
around a quota. A refusal surfaces as Error::RateLimited carrying the provider's own
wording, and backing off is the caller's decision. See
docs/operating-limits.md.
Testing
The live suite drives the installed agents end to end (answer, usage, streaming, multi-turn memory, forking) and skips any agent whose binary is absent rather than failing on it. It spawns real agents and consumes real quota, which is why it is ignored by default.
Relationship to oneharness
A Rust port of nickderobertis/oneharness (MIT), reduced to three agents and rebuilt as an embeddable library. What changed:
- The Python and TypeScript SDKs are gone, along with the JSON-Schema codegen that fed
them. They existed only so non-Rust callers could shell out to the
oneharnessbinary and re-validate its JSON. In a Rust consumer that entire layer collapses into the public API: the type system is the contract. - The CLI is gone. A GUI embedding this crate should not pay for a process boundary and two JSON round-trips to ask a question.
- The shell scripts are gone, 39 of them, mostly CI gates and per-harness e2e drivers.
- Five harnesses are gone (OpenCode, Goose, Qwen, Crush, Cursor).
- Async throughout. oneharness runs blocking; this streams over tokio, which is what a Tauri front end needs to render a run as it happens.
Some findings did not survive re-verification against the current CLIs. oneharness models
Copilot as having no headless session id and no event stream (session_formats: &[],
events_format: None); Copilot 1.0.75 has both. Where this crate and oneharness disagree,
this crate matches what the CLI does today.
License
MIT. See LICENSE; the original oneharness copyright is retained alongside ours.