agent-abstraction 0.2.2

Drive the Claude Code, Codex and GitHub Copilot CLIs headlessly from Rust. One request type, one event stream and one session model across all three, with resume and fork.
Documentation

agent-abstraction

crates.io docs.rs license

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.

[dependencies]
agent-abstraction = "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 agent_abstraction::{Agent, Permission, Request, run};

let outcome = run(
    &Request::new(Agent::Claude, "Reply with the single word: pong")
        .model("haiku")
        .permission(Permission::ReadOnly),
)
.await?;

println!("{}", outcome.text);          // "pong"
println!("{:?}", outcome.usage.cost_usd);

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 = uuid::Uuid::new_v4().to_string();
let outcome = run(&Request::new(Agent::Claude, "hi").session_id(&mine)).await?;
assert_eq!(outcome.session.as_deref(), Some(mine.as_str()));

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(&Request::new(Agent::Claude, "audit this repo"))?;
while let Some(event) = running.recv().await {
    match event {
        Event::Text(text) => print!("{text}"),
        Event::ToolCall { name, .. } => println!("[{name}]"),
        Event::RateLimit(limit) => eprintln!("quota: {}", limit.status),
        _ => {}
    }
}
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 = SessionStore::open("/var/lib/myapp/sessions");

let turn = Request::new(Agent::Claude, "what did I ask you to remember?")
    .session(&store, ".", "thread-42", /* fork */ false)?;

assert_eq!(turn.session_phase(), Some(Phase::Continue));
let outcome = run(&turn).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 Agent::Claude.models() {
    println!("{:24} {}", model.id, model.name);   // "opus", "Opus"
}

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.

Where a CLI can be asked directly, prefer that:

let models = Agent::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(&Request::new(Agent::Codex, "Alice is 30 years old.")
    .schema(r#"{"type":"object",
                "properties":{"name":{"type":"string"},"age":{"type":"integer"}},
                "required":["name","age"],"additionalProperties":false}"#))
    .await?;

assert_eq!(outcome.structured.unwrap()["name"], "Alice");

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(&request)?;
drop(running);                  // 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 Agent::ALL {
    let status = AuthStatus::check(agent).await?;
    println!("{agent}: {}", status.summary());
}
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:

Request::new(Agent::Claude, "review this")     // Minimal, nothing to configure
Request::new(Agent::Claude, "review this").env_policy(EnvPolicy::Inherit)   // 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 {
    if let Ok(value) = std::env::var(name) {
        request = request.env(*name, value);
    }
}

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 exec refuses 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 to read-only.
  • codex exec resume does not accept --sandbox. It is a different option set from codex exec and 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-tools to run headlessly at all, or it stalls at the first tool confirmation. It is always emitted; Permission then 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 as Error::AgentError; see below.
  • Claude's stream-json requires --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() {
    tracing::error!(
        unparsed = outcome.unparsed,
        sample = ?outcome.first_unparsed,
        "the CLI is healthy; this crate's parser is not",
    );
}

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(&request).await {
    Err(Error::AgentError { status: Some(404), message, .. }) => {
        // Typically a model the account cannot reach. `message` is the agent's wording.
        eprintln!("{message}");
    }
    Err(e) if e.is_auth_failure() => { /* prompt for login */ }
    Err(e) if e.is_transient() => { /* back off */ }
    other => { /* ... */ }
}

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

cargo test
cargo test --test live -- --ignored --test-threads 1

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 oneharness binary 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.