AgentHarness.start_session/2 accepts a common set of session options and a
provider_options escape hatch. Common options express intent; adapters map
them to the provider's native protocol.
AgentHarness.start_session(provider,
cwd: "/absolute/workspace",
model: "provider-model-name",
system_prompt: "Project-specific instructions",
approval_policy: :on_request,
sandbox: :workspace_write,
mcp_servers: %{},
skills: [],
env: %{},
provider_options: %{},
event_buffer_size: 1_000,
completed_turn_cache_size: 1_000,
startup_timeout: 30_000,
startup_finalization_timeout: 5_000,
turn_start_timeout: 30_000,
provider_command_timeout: 30_000,
store_failure: :degrade
)Provider values are not artificially reduced to one cross-provider enum. Both
adapters map the common approval_policy and sandbox fields, but each value
must use that provider's native shape. For Claude, approval_policy becomes
permission_mode, while sandbox uses the claude_code sandbox schema.
startup_timeout bounds Store reconciliation and provider opening for one
session. After the provider opens, startup_finalization_timeout separately
bounds the initial Store commit and each cleanup phase; it defaults to 5_000
milliseconds. The public caller follows the phase transition, so one budget is
not silently consumed by another. Readiness ends with a bounded two-way caller
acknowledgement. Until the SessionServer processes that acknowledgement, Store
snapshots carry a pending startup-attempt marker. A partial or hard-killed
unacknowledged aggregate is therefore reclaimable on the next start even if its
last write had already reached status: :idle. A handshake that does not finish
within that phase budget returns {:error, :session_start_ack_timeout} and the
pending attempt remains safely reclaimable.
turn_start_timeout bounds the provider's asynchronous admission of a locally
accepted turn. These are per-session positive millisecond values. If turn
admission crosses its deadline, AgentHarness cannot know whether upstream work
began, so it fails the turn and retires that SessionServer instead of reusing
the conversation as idle.
provider_command_timeout is the SessionServer watchdog for respond/2 and
the provider-facing part of cancel/1. It defaults to 30_000 milliseconds.
An uncertain command timeout retires the provider session so an answer or
cancellation that may already have crossed the transport boundary is never
treated as safely retryable.
store_failure: :degrade keeps a session alive after a write failure, switches
its status snapshot to durability: {:degraded, failure}, and publishes a
non-durable :store_failed event. Use store_failure: :stop when fail-stop
durability is required.
event_buffer_size bounds recent replay events. completed_turn_cache_size
bounds completed Turn values, terminal events, and request records held by the
live SessionServer; it defaults to the event-buffer size and may be zero or
:infinity. A configured Store remains the source for older cold lookups. The
built-in Memory Store retains its full journal until guarded purge, so use a
durable Store with an explicit retention policy for high-volume fleets.
Process-level limits and internal command deadlines are application settings:
config :agent_harness,
max_sessions: 100,
max_provider_processes: 200,
max_runner_tasks: 500,
session_call_timeout: 30_000,
provider_command_call_timeout: 60_000,
codex_call_timeout: 25_000,
codex_startup_call_timeout: 25_000,
claude_call_timeout: 5_000,
claude_interrupt_timeout: 3_000,
claude_stop_timeout: 1_000,
store_call_timeout: 5_000,
provider_close_timeout: 5_000,
provider_open_shutdown_grace: 250,
session_shutdown_timeout: 60_000The three max_* settings default to :infinity. Configure them before the
application starts. The default provider-command deadline order is outward:
the built-in transport call is shorter than provider_command_timeout, which
must be shorter than provider_command_call_timeout. This lets the adapter
report first, then the SessionServer retire uncertainty, before the public
caller can give up. The default turn-start watchdog is likewise longer than
either built-in provider call, and session shutdown has more time than provider
cleanup. A built-in provider call timeout is classified as uncertain and
retires the affected session.
provider_open_shutdown_grace is the non-negative number of milliseconds the
built-in opening-runtime guardian allows supervised cleanup before forcing that
runtime down when its SessionServer disappears during startup. It defaults to
250. session_shutdown_timeout defaults to 60_000 milliseconds so normal
session shutdown has room for bounded provider cleanup and final persistence.
store_call_timeout bounds calls to the built-in Memory Store; a custom Store
must enforce its own I/O deadline because its callback runs in the calling
process.
Bring your own CLI and authentication
AgentHarness launches a local CLI and relies on credentials that CLI already owns. It does not open login flows, accept passwords, store provider credentials, or choose a paid plan for you. The selected executable and any custom client module are trusted code boundaries.
Authenticate each CLI outside your Elixir application:
$ codex login
$ claude
Then verify a small request directly in the CLI before debugging AgentHarness.
AgentHarness defaults to guarded :subscription mode. API keys, cloud
providers, custom endpoints, and other intentional routes require
auth: :inherit. Plan eligibility and billing are provider policy, not a
library capability; read Billing and authentication
before unattended use.
Codex authentication and environment
Codex defaults to the fail-closed subscription mode:
provider_options = %{auth: :subscription}This mode uses the ChatGPT login in the selected Codex home. Before launching the app-server, AgentHarness:
- clears API keys, endpoint variables, and OSS/custom-provider selectors;
- forces the SDK to no API key, the official base URL, and the OpenAI backend;
- validates the SDK's resolved model payload contains no environment or config overrides;
- pins every app-server thread to the built-in
openaimodel provider; - rejects model payloads, governed/remote execution surfaces, raw config overrides, custom provider backends, OSS providers, and thread profiles;
- inspects effective user, project, and system Codex configuration and rejects custom model providers, custom OpenAI base URLs, provider/profile tables, or an API-forced login method.
These checks prevent ambient or per-session provider routing from silently
switching the child to API billing. auth: :inherit is the explicit escape
hatch for those advanced configurations.
AgentHarness also inspects the selected file-backed Codex auth record before
launch. Stored api_key and bedrock_api_key modes are rejected. A keyring
store cannot currently be inspected by the Elixir SDK, so subscription mode
fails closed for that store as well. The validated CODEX_HOME is then set
explicitly in the child environment so an unvalidated inherited home cannot
take precedence.
To isolate a Codex home or account:
{:ok, session} =
AgentHarness.start_session(:codex,
cwd: "/work/project",
env: %{"CODEX_HOME" => "/work/codex-profile"}
)Environment entries are merged into the app-server process environment. Subscription mode overwrites auth- and provider-routing entries after that merge. Avoid putting credentials directly into logs, metadata, or exception messages.
To intentionally preserve API credentials or another custom environment, select:
provider_options = %{auth: :inherit}The only accepted Codex auth modes are :subscription and :inherit.
AgentHarness does not refresh ChatGPT tokens or generate upstream attestation
tokens itself. If an app-server asks the host for either operation, the active
turn fails and that provider connection is torn down rather than leaving an
unresolvable request pending.
Claude authentication modes
Claude defaults to:
provider_options = %{auth: :subscription}This uses the globally installed claude executable and refuses to start
unless claude auth status reports a logged-in, first-party claude.ai
account. The check uses the selected executable, working directory, and
scrubbed child environment.
Subscription mode forcibly unsets known higher-precedence or alternate routing configuration, including:
ANTHROPIC_API_KEY,ANTHROPIC_AUTH_TOKEN, andANTHROPIC_BASE_URL;- custom auth headers and provider-specific base URLs;
- Bedrock, Mantle, Vertex, Foundry, and Claude Platform on AWS selectors;
- ambient
CLAUDE_CODE_OAUTH_TOKENand refresh-token overrides; - host-managed credential-file, credential-variable, and provider-routing selectors.
It also fails session startup if either of these API-key sources is configured:
provider_options: %{api_key: "..."};config :claude_code, api_key: "...".
Those checks matter because Claude Code's documented authentication precedence
places cloud providers, bearer tokens, API keys, and configured key helpers
ahead of the saved /login subscription. Remove the conflicting configuration
to use subscription mode, or select auth: :inherit when another credential is
intentional. See
Claude Code authentication and its
environment-variable reference.
Because CLI settings, setting_sources, arbitrary extra_args, or a custom
SDK adapter can install an apiKeyHelper or re-enable a credential source,
subscription mode rejects nonempty values for those provider options. It pins
the local Port adapter and supplies empty settings, source, and argument
values, overriding auth-sensitive config :claude_code defaults. Use the
common session options for sandbox, MCP, skills, model, and tools; select
auth: :inherit if custom Claude settings or adapters are intentional.
There is one boundary the library cannot override: Claude Code always applies
organization-managed policy, and managed settings have higher precedence than
CLI arguments. Server-managed settings, MDM policy, registry/plist policy, or
system managed-settings.json files can inject env or an apiKeyHelper
after AgentHarness's scrub. claude auth status does not reveal every such
route. Treat managed policy as trusted external configuration and verify it
with your administrator before relying on subscription-only billing. See
Claude Code settings and precedence
and SDK settings behavior.
To let the child process inherit your environment unchanged:
provider_options = %{auth: :inherit}Use :inherit only when intentional—for example, when an OAuth token or API
key is managed by your local environment. The only accepted auth modes are
:subscription and :inherit.
MCP servers
MCP configuration belongs to the logical session:
mcp_servers = %{
"project-files" => %{
command: "project-mcp",
args: ["--root", "/work/project"],
env: %{"LOG_LEVEL" => "warning"}
},
"internal-docs" => %{
url: "https://docs.example.test/mcp"
}
}
{:ok, session} =
AgentHarness.start_session(:codex,
cwd: "/work/project",
mcp_servers: mcp_servers
)Use strings for stable MCP server names. Configuration values are provider configuration, so the exact accepted fields depend on the installed CLI.
Codex inserts the map into the app-server thread's config.mcp_servers.
Nested atom keys are converted to strings. In auth: :inherit mode, existing
MCP entries supplied in Codex thread_options.config are merged, with common
mcp_servers entries taking precedence by server name. Subscription mode
rejects caller-supplied raw thread config and builds the MCP map itself.
Claude always passes an explicit inline MCP map and enables strict MCP mode,
including when the map is empty. This prevents SDK application defaults and
global CLI MCP servers from leaking into a session. To opt into global Claude
MCP configuration, set provider_options: %{strict_mcp_config: false}; doing so
weakens per-session isolation.
The underlying Claude SDK also accepts BEAM-native MCP server modules. That is a Claude-specific extension; do not rely on it in provider-neutral code.
Skills
The common skills option accepts paths or descriptors:
skills = [
"/work/skills/testing/SKILL.md",
%{name: "release", path: "/work/skills/release/SKILL.md"}
]Codex skills
Codex normalizes each entry into an explicit skill input:
%{type: :skill, name: "release", path: "/work/skills/release/SKILL.md"}Skill inputs are prepended to every turn, and skills_enabled is enabled on
the thread. A Codex skill descriptor may set enabled: false to omit it.
Paths are sent to Codex as configured. Validate that they exist and are readable before starting orchestration.
Claude skills and plugins
Claude accepts three path forms:
- a plugin root containing
.claude-plugin/plugin.json; - a directory containing
SKILL.md; - a direct path to
SKILL.md.
Existing plugin roots are loaded directly. Standalone skill directories are copied, including supporting files, into one generated session plugin under the system temporary directory. The generated plugin is removed when the provider session closes.
The adapter adds the Skill tool to allowed_tools when it creates a plugin.
If you provide your own restrictive tool configuration, keep Skill allowed.
This translation is why Claude reports skill support as :emulated while
Codex reports it as :native.
Codex options
Codex uses the codex_sdk app-server transport. A complete configuration may
look like:
{:ok, session} =
AgentHarness.start_session(:codex,
cwd: "/work/project",
model: "your-codex-model",
system_prompt: "Use the project's conventions.",
approval_policy: :on_request,
sandbox: :workspace_write,
provider_options: %{
auth: :subscription,
codex_path: "/opt/bin/codex",
codex_options: %{},
connect_options: %{
init_timeout_ms: 10_000
},
thread_options: %{
web_search_enabled: true,
model_reasoning_summary: :concise
},
turn_options: %{
effort: :medium
}
}
)Recognized layers:
| Key | Applied to |
|---|---|
:auth | :subscription (default) or intentional :inherit |
:client | Trusted test/custom Codex client module |
:codex_path | Executable override |
:codex_options | Codex.Options construction |
:connect_options | App-server connection/initialization |
:thread_options | New or resumed Codex thread |
:turn_options | Every turn in this AgentHarness session |
:provider_session_id | Resume a specific Codex thread |
:thread_id | Alias for :provider_session_id |
:resume | Alias for an exact thread ID |
String forms of documented option keys are canonicalized without creating arbitrary atoms.
Common session values are used as defaults:
cwd → thread working_directory
model → thread model
system_prompt → thread developer_instructions
approval_policy → thread ask_for_approval
sandbox → thread sandboxExplicit safe thread_options override those defaults. Subscription mode
reserves provider-, profile-, and raw-config routing keys; use auth: :inherit
when those settings are intentional. Each call to start_turn/3 may also
supply Codex turn options:
AgentHarness.start_turn(session, "Analyze this failure",
effort: :high,
additional_context: "CI failed only on Linux",
output_schema: schema
)id and metadata remain AgentHarness options and are not sent to Codex.
Resume a Codex conversation
Provider session IDs appear in AgentHarness.status/1, :session_updated
events, terminal result data, and the Store snapshot.
To create a new AgentHarness process around an existing Codex thread:
{:ok, resumed} =
AgentHarness.start_session(:codex,
cwd: "/work/project",
provider_options: %{provider_session_id: saved_thread_id}
):last is deliberately rejected. In the SDK's app-server transport it does
not identify an exact thread and can silently start a new one. Resolve and
persist the concrete Codex thread ID instead. AgentHarness does not
automatically reconstruct stopped sessions from Store in v0.x.
Codex structured input
String prompts are converted to text input. Codex also accepts a list of structured input maps:
AgentHarness.start_turn(session, [
%{type: :text, text: "Review this image"},
%{type: :local_image, path: "/work/screenshot.png"}
])Configured skill items are prepended automatically.
Claude options
Claude uses the claude_code Elixir SDK to drive the globally installed CLI
with a bidirectional stream:
{:ok, session} =
AgentHarness.start_session(:claude,
cwd: "/work/project",
model: "sonnet",
system_prompt: "Use the project's conventions.",
approval_policy: :default,
sandbox: %{enabled: true},
provider_options: %{
auth: :subscription,
allowed_tools: ["Read", "Grep", "Glob", "Bash(mix test:*)"],
disallowed_tools: ["Bash(rm:*)"],
max_turns: 20,
readiness_timeout: 15_000,
question_timeout: 300_000
}
)AgentHarness consumes these internal keys:
| Key | Meaning |
|---|---|
:auth | :subscription (default) or :inherit |
:auth_check_timeout | Bound for claude auth status; default 5_000 ms |
:readiness_timeout | Time to wait for the CLI initialization handshake; default 10_000 ms |
:question_timeout | Time to wait for your response; default :infinity |
:client | Trusted test/custom client module |
Other provider options are passed to ClaudeCode.start_link/1 after common
fields are mapped, except that subscription mode rejects or replaces
auth-sensitive api_key, settings, setting_sources, extra_args, and
adapter values. Useful options include allowed_tools, disallowed_tools,
permission_mode, max_turns, resume, fork_session, agents,
output_format, and plugins. Refer to the installed claude_code dependency
and Claude Code version for the complete accepted set.
Unknown binary option names are rejected rather than converted to atoms. Prefer atom-keyed options in application code.
Partial messages are enabled by default, producing character-level
:message_delta, :thinking_delta, and :tool_input_delta events. Set
include_partial_messages: false to reduce event volume; complete
assistant/tool messages and the terminal result remain available.
Claude maps common fields directly to Claude-native SDK options:
approval_policy: :default, # Claude `permission_mode`
sandbox: %{enabled: true} # Claude SDK sandbox configurationConsult the installed claude_code version for the sandbox schema. Do not use
dangerous permission-bypass modes unless the CLI is already contained by a
separate, trusted sandbox. Explicit provider_options.permission_mode or
provider_options.sandbox values override the corresponding common field.
Session startup waits for Claude's CLI initialization handshake before emitting
:session_ready. readiness_timeout bounds that wait. The handshake verifies
transport initialization, not whether a later model request will pass account,
quota, or model checks; those errors can still arrive during a turn.
The subscription auth check is a separate, no-model CLI command and does not
consume inference usage. Its timeout is controlled by auth_check_timeout.
Claude turn filters are applied by AgentHarness after reading the SDK stream.
They can suppress nonterminal provider messages, but the terminal
ResultMessage is always retained so a successful turn cannot become a
:missing_result failure.
Claude currently supports one-shot approvals. A
Response.approve(scope: :session) returns
{:error, {:unsupported_approval_scope, :session}} rather than silently
downgrading the requested scope.
Question timeout
Claude's can_use_tool callback waits while your Elixir application resolves a
question or permission request. With the default :infinity, the Claude turn
can remain blocked indefinitely.
Set a positive millisecond timeout to fail closed:
provider_options = %{question_timeout: 60_000}When it expires, AgentHarness denies the tool and interrupts that request. This
is independent of await/2 and stream timeouts.
Resume or fork Claude
{:ok, resumed} =
AgentHarness.start_session(:claude,
cwd: "/work/project",
provider_options: %{
auth: :subscription,
resume: saved_claude_session_id
}
)
{:ok, forked} =
AgentHarness.start_session(:claude,
cwd: "/work/project",
provider_options: %{
auth: :subscription,
resume: saved_claude_session_id,
fork_session: true
}
)AgentHarness records updated Claude session IDs, but v0.x does not automatically recreate a stopped session from Store.
Pi options
Pi is driven through pi --mode rpc. It is the smallest of the supported
harnesses: four core tools, no permission system, and no MCP. A session that
sets mcp_servers, approval_policy, or sandbox is rejected at
start_session/2 rather than opened with those settings quietly dropped.
{:ok, session} =
AgentHarness.start_session(:pi,
cwd: "/absolute/path/to/project",
model: "anthropic/claude-sonnet-5",
provider_options: %{
tools: ["read", "grep", "find", "ls"],
thinking: "medium",
agent_dir: "/absolute/path/to/isolated/pi-home"
}
)| Option | Meaning |
|---|---|
auth | :subscription (default) or :inherit |
api_key | Explicit key; only valid with auth: :inherit |
provider | Pi provider name when the model pattern does not carry one |
executable | Path or name of the pi binary |
tools | Allowlist of tool names |
exclude_tools | Denylist of tool names |
no_tools | Disable all tools |
thinking | off, minimal, low, medium, high, xhigh, or max |
extensions | Extension file paths to load |
resume | Session file path or partial session ID to continue |
fork | Session file path or partial session ID to branch from |
session | false for an ephemeral run that is never written to disk |
session_dir | Directory for session storage and lookup |
agent_dir | Sets PI_CODING_AGENT_DIR, isolating config and sessions |
name | Session display name |
offline | Skip pi's startup network calls |
By default the harness assigns its own session id with --session-id, so
provider_session_id matches the AgentHarness session id. Setting resume,
fork, or session: false hands that choice back to pi, and the id is read
from pi instead.
Questions from Pi
Pi has no built-in question tool. Dialogs reach the harness through pi's
extension UI sub-protocol, so they appear only when a loaded extension calls
ctx.ui.confirm, ctx.ui.select, ctx.ui.input, or ctx.ui.editor. Each one
arrives as a %Request{kind: :question}:
confirmcarriestrue/falsechoices. Answer withResponse.approve/0,Response.deny/0, orResponse.answer(boolean).selectcarries the extension's options as choices.inputandeditorare free-form; answer withResponse.answer(text).
Response.cancel/0 dismisses any dialog. A dialog raised while no turn is
active is dismissed automatically, because an unanswered dialog blocks pi
indefinitely.
Steering
Pi can accept messages while a turn is running, through its steer and
follow_up commands. AgentHarness allows one active turn per session and has
no steering entry point, so the adapter reports steer: :unsupported and
start_turn/3 during a running turn returns {:error, {:turn_in_progress, turn}}.
The protocol support is in place for when a public API lands.
Provider differences
| Concern | Codex | Claude | Pi |
|---|---|---|---|
| Transport | Codex app-server via codex_sdk | Bidirectional Claude CLI via claude_code | pi --mode rpc JSONL over a port |
| Turn input | String or structured input list | String | String, or a list of strings joined by newline |
| Token deltas | Native | Native | Native |
| Questions | Native app-server request | Native can_use_tool callback | Extension UI dialogs, only when an extension asks |
| Approvals | Command, file, permissions, MCP elicitation | Tool permission callback | Unsupported; pi has no permission system |
| Session-scoped approval | Advertised for file/permission protocols; command requests follow availableDecisions | Explicitly unsupported; returns an error | Not applicable |
| Per-session MCP | Native | Native | Unsupported; a configured server is rejected |
| Skills | Explicit native skill input | Session plugin generated when needed | Native --skill per skill path |
| Cancellation | Turn interrupt, then drain the authoritative terminal event | CLI interrupt | abort, then the aborted stop reason |
| Resume | Codex thread ID | Claude session ID | Session file path or partial session ID |
| Fork | Unsupported in the current adapter | Native via resume plus fork_session | Native via fork |
| Approval/sandbox config | Common session fields | Common fields with Claude-native values | Rejected; pi does not sandbox |
| Steering | Capability currently unsupported | Capability currently unsupported | Supported by pi, not yet exposed by the harness |
| Terminal signal | Codex terminal turn event | Claude ResultMessage | agent_settled, not agent_end |
Provider-specific event data remains available in Event.raw. Write your
orchestrator against normalized lifecycle and request events, then inspect
raw only where a provider-specific feature is intentional.