CodexAppServer

Elixir client for the OpenAI Codex app-server JSON-RPC 2.0 protocol over stdio.

This library provides a clean, dependency-free interface for spawning a Codex CLI process in app-server mode and driving coding agent sessions programmatically. It handles the full protocol lifecycle: subprocess management, session handshake, turn execution, approval/tool/input dispatch, and structured event callbacks.

Extracted and generalized from the Symphony orchestration service.

Installation

Add codex_app_server to your mix.exs dependencies:

def deps do
  [
    {:codex_app_server, github: "fun-fx/codex_app_server"}
  ]
end

Prerequisites

Quick Start

{:ok, result} = CodexAppServer.run("/path/to/workspace", "Fix the authentication bug in lib/auth.ex",
  title: "ABC-123: Fix auth bug",
  approval_policy: "never",
  sandbox: "workspace-write",
  sandbox_policy: %{"type" => "workspaceWrite"}
)

IO.inspect(result.session_id)  # "thread-abc-turn-1"
IO.inspect(result.result)      # :turn_completed

Multi-Turn Sessions

For multi-turn conversations, manage the session lifecycle explicitly:

{:ok, session} = CodexAppServer.start_session("/path/to/workspace",
  command: "codex app-server",
  approval_policy: "never",
  sandbox: "workspace-write"
)

{:ok, turn1} = CodexAppServer.run_turn(session, "Fix the bug in auth.ex",
  title: "ABC-123: Fix auth",
  sandbox_policy: %{"type" => "workspaceWrite"}
)

{:ok, turn2} = CodexAppServer.run_turn(session, "Now add tests for the fix",
  title: "ABC-123: Fix auth",
  sandbox_policy: %{"type" => "workspaceWrite"}
)

CodexAppServer.stop_session(session)

The same Codex subprocess and thread stay alive across turns, preserving conversation context.

Dynamic Tools

Register client-side tools that Codex can invoke during a turn:

tools = [
  %{
    "name" => "query_database",
    "description" => "Run a read-only SQL query against the project database.",
    "inputSchema" => %{
      "type" => "object",
      "required" => ["sql"],
      "properties" => %{
        "sql" => %{"type" => "string", "description" => "SQL SELECT statement"}
      }
    }
  }
]

tool_executor = fn
  "query_database", %{"sql" => sql} ->
    case MyApp.Repo.query(sql) do
      {:ok, result} ->
        %{
          "success" => true,
          "contentItems" => [%{"type" => "inputText", "text" => Jason.encode!(result.rows)}]
        }

      {:error, reason} ->
        %{
          "success" => false,
          "contentItems" => [%{"type" => "inputText", "text" => "Query failed: #{inspect(reason)}"}]
        }
    end

  unknown, _args ->
    CodexAppServer.Protocol.unsupported_tool_result(unknown)
end

CodexAppServer.run(workspace, prompt,
  tools: tools,
  tool_executor: tool_executor
)

Event Callbacks

Monitor session events with the :on_message callback:

CodexAppServer.run(workspace, prompt,
  on_message: fn message ->
    case message.event do
      :session_started -> Logger.info("Session #{message.session_id} started")
      :turn_completed -> Logger.info("Turn completed")
      :turn_failed -> Logger.error("Turn failed: #{inspect(message.details)}")
      :approval_auto_approved -> Logger.debug("Auto-approved: #{message.decision}")
      :tool_call_completed -> Logger.debug("Tool call succeeded")
      :tool_call_failed -> Logger.warning("Tool call failed")
      :notification -> Logger.debug("Notification: #{inspect(message.payload)}")
      _ -> :ok
    end
  end
)

Event Types

Event Description
:session_started Turn handshake completed, session IDs available
:turn_completed Turn finished successfully
:turn_failed Turn failed (model error, etc.)
:turn_cancelled Turn was cancelled
:turn_ended_with_error Turn ended with a client-side error
:turn_input_required Codex requested user input (hard failure in non-interactive mode)
:approval_required Approval needed but auto-approve is off
:approval_auto_approved Request was automatically approved
:tool_call_completed Dynamic tool call succeeded
:tool_call_failed Dynamic tool call returned failure
:unsupported_tool_call Unknown tool was called (error returned to Codex)
:tool_input_auto_answered Freeform input prompt was auto-answered
:notification Informational protocol notification
:malformed Non-JSON line received from subprocess
:other_message Unrecognized JSON message
:startup_failed Session or turn startup failed

Configuration Options

Session Options (start_session/2)

Option Type Default Description
:commandString.t()"codex app-server" Codex CLI command
:approval_policyString.t() | map()%{"reject" => ...} Approval policy for the thread
:sandboxString.t()"workspace-write" Thread sandbox mode
:tools[map()][] Dynamic tool specs to register
:read_timeout_mspos_integer()5_000 Timeout for protocol responses
:client_infomap()%{name: "codex_app_server_elixir", ...} Client identity for initialize

Turn Options (run_turn/3)

Option Type Default Description
:titleString.t()"" Turn title (e.g., "ABC-123: Fix bug")
:approval_policyString.t() | map() session default Override approval policy for this turn
:sandbox_policymap()%{"type" => "workspaceWrite"} Sandbox policy for this turn
:tool_executor(String.t(), map() -> map()) returns unsupported error Tool call handler
:on_message(map() -> any()) no-op Event callback
:turn_timeout_mspos_integer()3_600_000 (1 hour) Total turn timeout
:auto_approveboolean() based on policy Override auto-approve behavior

Architecture

┌──────────────────────────────────────────────────┐
│  CodexAppServer (public API)                     │
│  run/3 · start_session/2 · run_turn/3 · stop/1   │
└───────────────────┬──────────────────────────────┘
                    │
┌───────────────────▼──────────────────────────────┐
│  CodexAppServer.Session                          │
│  Lifecycle: handshake, turn dispatch, cleanup     │
└───────────────────┬──────────────────────────────┘
                    │
┌───────────────────▼──────────────────────────────┐
│  CodexAppServer.Protocol                         │
│  JSON-RPC message construction & stream parsing   │
│  Approval / tool / input dispatch                 │
└───────────────────┬──────────────────────────────┘
                    │
┌───────────────────▼──────────────────────────────┐
│  CodexAppServer.Transport                        │
│  Erlang Port: spawn, send, receive, stop          │
└──────────────────────────────────────────────────┘
                    │
                    ▼
            codex app-server
           (stdio JSON-RPC)

Protocol Reference

This client implements the Codex app-server protocol:

  1. initialize — capability negotiation
  2. initialized — notification confirming readiness
  3. thread/start — create a thread with approval policy, sandbox, and optional tools
  4. turn/start — submit a prompt and begin a coding turn
  5. Stream processing — handle turn/completed, turn/failed, turn/cancelled, approval requests, tool calls, and user input requests

Development

mix deps.get
mix test
mix credo --strict

License

MIT — see LICENSE.