AgentHarness

AgentHarness is an Elixir library for running locally installed coding agents as supervised OTP sessions. It currently supports Codex CLI and Claude Code, normalizes their streaming output, and gives callers one interface for turns, questions, approvals, cancellation, completion, and event replay.

AgentHarness is deliberately bring your own CLI. It does not proxy credentials, create API keys, or log in on your behalf. The Codex and Claude processes run on your machine with the accounts already configured in their official CLIs.

AgentHarness is an early v0.1 library. Its core lifecycle is tested, but the public API and provider event vocabulary may still evolve.

What it provides

Each call to start_session/2 opens a provider runtime, each session allows one active turn, and different sessions may run at the same time. Capacity can be bounded through supervisor configuration or caller-side admission control. See Architecture and concurrency before creating a large number of resident sessions.

Prerequisites

This repository requires Elixir 1.19 and is tested with OTP 28.

Provider-side JSON helpers use Elixir 1.19's built-in JSON module; this library does not declare its own JSON codec dependency. Provider SDKs may still bring their own transitive codecs.

Install and authenticate the CLIs you intend to use:

$ codex --version
$ codex login
$ claude --version
$ claude

By default, AgentHarness requires the CLIs' saved ChatGPT or claude.ai login and rejects known API, cloud-provider, and custom-routing overrides. To intentionally use another authentication route, set provider_options: %{auth: :inherit}. See Billing and authentication for the provider-policy boundary and current official links.

Installation

Add AgentHarness to your dependencies:

def deps do
[
{:agent_harness, "~> 0.1.0"}
]
end

For local development against a checkout, use path: "../agent_harness" instead.

Then fetch and compile dependencies:

$ mix deps.get
$ mix compile

AgentHarness starts its Registry, supervisors, and default in-memory store with your application.

Quick start

alias AgentHarness.Event
{:ok, session} =
AgentHarness.start_session(:codex,
cwd: "/absolute/path/to/project",
approval_policy: :never,
sandbox: :workspace_write
)
{:ok, turn} =
AgentHarness.start_turn(
session,
"Read the project, fix the failing test, and explain the change."
)
{:ok, subscription} = AgentHarness.subscribe(turn, from: :start)
receive_events = fn receive_events ->
receive do
{:agent_harness, ref, %Event{type: :message_delta, data: %{text: text}}}
when ref == subscription.ref ->
IO.write(text)
receive_events.(receive_events)
{:agent_harness, ref, %Event{type: :turn_completed} = event}
when ref == subscription.ref ->
event
{:agent_harness, ref, %Event{type: type} = event}
when ref == subscription.ref and
type in [:turn_failed, :turn_cancelled, :turn_interrupted] ->
event
{:agent_harness, ref, %Event{}}
when ref == subscription.ref ->
receive_events.(receive_events)
end
end
terminal_event = receive_events.(receive_events)
AgentHarness.unsubscribe(subscription)
AgentHarness.stop_session(session)

For a simpler blocking workflow:

{:ok, turn} = AgentHarness.start_turn(session, "Summarize this project.")
case AgentHarness.await(turn, timeout: 120_000) do
{:ok, %{status: :completed, result: result}} ->
IO.inspect(result)
{:error, %AgentHarness.Event{} = terminal_event} ->
IO.inspect(terminal_event, label: "agent did not complete")
{:error, :timeout} ->
AgentHarness.cancel(turn)
end

Responding to questions and approvals

Questions and permission checks arrive as :request_created events:

alias AgentHarness.{Event, Request, Response}
receive do
{:agent_harness, ref,
%Event{
type: :request_created,
data: %Request{kind: :question} = request
}}
when ref == subscription.ref ->
# A scalar works for one question. For multiple questions, pass a map
# keyed by each entry's `id` from request.questions.
:ok = AgentHarness.respond(request, Response.answer("Postgres"))
{:agent_harness, ref,
%Event{
type: :request_created,
data: %Request{kind: kind} = request
}}
when ref == subscription.ref and
kind in [:command_approval, :file_change_approval, :permission] ->
:ok = AgentHarness.respond(request, Response.approve(scope: :once))
end

A request can be answered only once. Pending requests become expired when their turn ends.

Per-session MCP and skills

Both providers accept session-scoped MCP configuration:

mcp_servers = %{
"project-tools" => %{
command: "npx",
args: ["-y", "@example/project-mcp", "/absolute/path/to/project"]
}
}
skills = [
%{name: "release", path: "/absolute/path/to/release/SKILL.md"}
]
{:ok, session} =
AgentHarness.start_session(:claude,
cwd: "/absolute/path/to/project",
mcp_servers: mcp_servers,
skills: skills,
provider_options: %{
auth: :subscription,
allowed_tools: ["Read", "Grep", "Glob", "Skill"]
}
)

The providers materialize these settings differently. Claude loads standalone skills through a temporary session plugin; Codex sends explicit skill items with each turn. Details and provider-specific options are in Provider configuration.

Documentation

Development

Normal tests use Mox-backed provider clients and do not consume Codex or Claude quota:

$ mix precommit

Live CLI tests are tagged and excluded by default:

$ mix test --include live

Live tests invoke your authenticated CLI and may consume subscription or API usage. Read Testing before enabling them.

License

AgentHarness is available under the MIT License.