Skip to content

Adding an agent harness

A runtime (agent harness) is a RuntimeRegistry::Bundle — a struct with twelve slots, one for each seam where driving a vendor CLI differs. The bundle is plain data — a struct of class references Zimmer looks up at runtime.

Bundle = Struct.new(
:runtime, :air_adapter_name, :cli_adapter_class, :retry_strategy_class,
:transcript_source_class, :transcript_normalizer_class, :mcp_status_detector_class,
:prompt_contribution_class, :config_preparer_class, :config_post_processor_class,
:auth_provider_class, :mcp_credential_writer_class,
keyword_init: true
)

Core code never says “Claude.” It asks RuntimeRegistry.for(runtime).

Slotclaude_codecodex
air_adapter_name"claude""codex"
cli_adapter_classClaudeCliAdapterCodexRuntimeAdapter
retry_strategy_classClaudeRetryStrategyCodexRetryStrategy
transcript_source_classClaudeTranscriptSourceCodexTranscriptSource
transcript_normalizer_classClaudeTranscriptNormalizerCodexTranscriptNormalizer
mcp_status_detector_classMcpLogPollerServiceCodexMcpStatusDetector
config_post_processor_classClaudeMcpConfigPostProcessorCodexConfigTomlPostProcessor
mcp_credential_writer_classClaudeMcpCredentialWriterCodexMcpCredentialWriter
prompt_contribution_classClaudeRuntimePromptContributionnil
auth_provider_classnilnil
config_preparer_classnilnil

The three registries that bypass the bundle

Section titled “The three registries that bypass the bundle”

This is the thing that will catch you. Besides the Bundle, there are three separate .for case statements you must also register in:

RuntimeAuthProvider.for(runtime) # + add to RUNTIMES
RuntimePromptContribution.for(runtime)
RuntimeLoginDriver.for(runtime)

And a fourth registry that isn’t a .for at all: ModelCatalog::MODELS[runtime], which resolves its own keys so a model catalog can exist before a bundle does.

execute(prompt:, session_id:, working_dir:, mcp_config_path:, images:,
append_system_prompt:, model:, auto_compact_window:) # → {pid:, stderr_log_path:}
resume(session_id:, working_dir:, prompt:, images:, mcp_config_path:,
append_system_prompt:, model:, auto_compact_window:) # → same shape
binary_name # → String
command_summary(session_id:, prompt:, mcp_config_path: nil, resume: false) # must start with binary_name
retry_strategy(session:, file_system:, process_manager:, rate_limit_tracker:, logger:)
disallowed_tools # default []
runtime_env_vars # default {}

Enforced by test/contracts/runtime_cli_adapter_contract_test.rb, which asserts keyword-set equality via instance_method(:execute).parameters. Add your adapter (and a mock) to RuntimeCliAdapterContractTest::ADAPTERS.

Also include CliSpawnEnv — don’t reimplement env scrubbing.

normal_completion_exit?(status)
context_length_error?(stderr_log_path:)
failed_resume_recovery_needed?(stderr_log_path:)
api_error_for_retry?(working_dir:)
auth_recovery_needed?(working_dir:) # ← the one the docs forget
transcript_directory(working_directory:)
resume_transcript_path(session:, working_directory:) # default nil = "no single-file restore"
locate(session:, working_directory:)
read(path)
parse_events(serialized)
discover_subagent_files(working_directory:, session_id:)
mcp_log_paths(working_directory:)
find_main_transcript(transcript_directory:, session:) # ← NOT on the base class
normalize(raw_event, session:, transcript_index:) # → [OpenTranscripts events]
extract_session_id(raw_event)
mints_own_session_id? # Codex: true. Claude: false.
extract_subagent_links(raw_event)
extract_subagent_spawns(raw_event)

mints_own_session_id? is a correctness landmine. If you return true for a runtime whose session id Zimmer generates, forked sessions collide on the unique session_id index.

  • RuntimePromptContributionguidelines_bullets, clarifying_questions_suffix, project_instructions_filename (CLAUDE.md vs AGENTS.md), delivered_via_file?, system_prompt_filename.
  • RuntimeConfigPostProcessor — a template-method base. Implement config_path, parse_config, empty_config, servers_map, build_server_entry, resolve_and_rewrite!, serialize_config.
  • RuntimeMcpCredentialWriterwrite!(working_directory:, credentials:), credential_key_for(server_name, server_config).
  • RuntimeAuthProvideraccounts, current_account, select_account_for, refresh!, inject_for_session!, activate!, rotation_interval.
  • RuntimeLoginDrivercommand, env(config_dir), parse_verification(buffer), completion_mode (:poll | :paste), capture!(config_dir, account), credentials_ready?.
  1. RuntimeRegistry — new Bundle, add to BUNDLES and LABELS.
  2. ModelCatalog::MODELS["<runtime>"] — exactly one entry with default: true.
  3. CLI adapter — include RuntimeCliAdapter + CliSpawnEnv. Identical kwargs. <runtime>_stderr.log, pgroup: true, NULL stdin/stdout.
  4. Retry strategy — all five predicates.
  5. Transcript source + normalizer — including find_main_transcript and mints_own_session_id?.
  6. Prompt contribution → register in RuntimePromptContribution.for.
  7. Config post-processor.
  8. MCP credential writer.
  9. MCP status detector.
  10. Auth provider → RuntimeAuthProvider.for and RUNTIMES. Login driver → RuntimeLoginDriver.for.
  11. Dockerfile.base — pin the CLI and the matching @pulsemcp/air-adapter-<runtime>. Add to CliStatusService::CLI_TOOLS.
  12. Add the adapter to RuntimeCliAdapterContractTest::ADAPTERS and write a mock in test/support/.

Codex is the honest reference implementation, and it is incomplete:

Other known gaps:

  • Shared code still says “Claude.” TranscriptPollerService logs “Waiting for Claude CLI to create transcript directory…” for every runtime.
  • ELICITATION_SESSION_ID is Claude-only — elicitations silently no-op on Codex.
  • Ao::ExtensionRegistry.spawn_env_contributions is Claude-only — extension env contributions are unreachable from Codex, despite the hook receiving a runtime context.
  • SubagentTranscript#open_transcript_events hardcodes ClaudeTranscriptNormalizer.