Provider configuration

Copy Markdown View Source

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_000

The 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 openai model 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, and ANTHROPIC_BASE_URL;
  • custom auth headers and provider-specific base URLs;
  • Bedrock, Mantle, Vertex, Foundry, and Claude Platform on AWS selectors;
  • ambient CLAUDE_CODE_OAUTH_TOKEN and 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:

KeyApplied to
:auth:subscription (default) or intentional :inherit
:clientTrusted test/custom Codex client module
:codex_pathExecutable override
:codex_optionsCodex.Options construction
:connect_optionsApp-server connection/initialization
:thread_optionsNew or resumed Codex thread
:turn_optionsEvery turn in this AgentHarness session
:provider_session_idResume a specific Codex thread
:thread_idAlias for :provider_session_id
:resumeAlias 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 sandbox

Explicit 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:

KeyMeaning
:auth:subscription (default) or :inherit
:auth_check_timeoutBound for claude auth status; default 5_000 ms
:readiness_timeoutTime to wait for the CLI initialization handshake; default 10_000 ms
:question_timeoutTime to wait for your response; default :infinity
:clientTrusted 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 configuration

Consult 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"
    }
  )
OptionMeaning
auth:subscription (default) or :inherit
api_keyExplicit key; only valid with auth: :inherit
providerPi provider name when the model pattern does not carry one
executablePath or name of the pi binary
toolsAllowlist of tool names
exclude_toolsDenylist of tool names
no_toolsDisable all tools
thinkingoff, minimal, low, medium, high, xhigh, or max
extensionsExtension file paths to load
resumeSession file path or partial session ID to continue
forkSession file path or partial session ID to branch from
sessionfalse for an ephemeral run that is never written to disk
session_dirDirectory for session storage and lookup
agent_dirSets PI_CODING_AGENT_DIR, isolating config and sessions
nameSession display name
offlineSkip 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}:

  • confirm carries true/false choices. Answer with Response.approve/0, Response.deny/0, or Response.answer(boolean).
  • select carries the extension's options as choices.
  • input and editor are free-form; answer with Response.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

ConcernCodexClaudePi
TransportCodex app-server via codex_sdkBidirectional Claude CLI via claude_codepi --mode rpc JSONL over a port
Turn inputString or structured input listStringString, or a list of strings joined by newline
Token deltasNativeNativeNative
QuestionsNative app-server requestNative can_use_tool callbackExtension UI dialogs, only when an extension asks
ApprovalsCommand, file, permissions, MCP elicitationTool permission callbackUnsupported; pi has no permission system
Session-scoped approvalAdvertised for file/permission protocols; command requests follow availableDecisionsExplicitly unsupported; returns an errorNot applicable
Per-session MCPNativeNativeUnsupported; a configured server is rejected
SkillsExplicit native skill inputSession plugin generated when neededNative --skill per skill path
CancellationTurn interrupt, then drain the authoritative terminal eventCLI interruptabort, then the aborted stop reason
ResumeCodex thread IDClaude session IDSession file path or partial session ID
ForkUnsupported in the current adapterNative via resume plus fork_sessionNative via fork
Approval/sandbox configCommon session fieldsCommon fields with Claude-native valuesRejected; pi does not sandbox
SteeringCapability currently unsupportedCapability currently unsupportedSupported by pi, not yet exposed by the harness
Terminal signalCodex terminal turn eventClaude ResultMessageagent_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.