README.md

# CodexAppServer

Elixir client for the [OpenAI Codex app-server](https://developers.openai.com/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](https://github.com/fun-fx/symphony) orchestration service.

## Installation

Add `codex_app_server` to your `mix.exs` dependencies:

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

## Prerequisites

- [Codex CLI](https://github.com/openai/codex) installed and available on `$PATH`
- `bash` available (used to spawn the subprocess)

## Quick Start

```elixir
{: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:

```elixir
{: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:

```elixir
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:

```elixir
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 |
|--------|------|---------|-------------|
| `:command` | `String.t()` | `"codex app-server"` | Codex CLI command |
| `:approval_policy` | `String.t() \| map()` | `%{"reject" => ...}` | Approval policy for the thread |
| `:sandbox` | `String.t()` | `"workspace-write"` | Thread sandbox mode |
| `:tools` | `[map()]` | `[]` | Dynamic tool specs to register |
| `:read_timeout_ms` | `pos_integer()` | `5_000` | Timeout for protocol responses |
| `:client_info` | `map()` | `%{name: "codex_app_server_elixir", ...}` | Client identity for `initialize` |

### Turn Options (`run_turn/3`)

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `:title` | `String.t()` | `""` | Turn title (e.g., `"ABC-123: Fix bug"`) |
| `:approval_policy` | `String.t() \| map()` | session default | Override approval policy for this turn |
| `:sandbox_policy` | `map()` | `%{"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_ms` | `pos_integer()` | `3_600_000` (1 hour) | Total turn timeout |
| `:auto_approve` | `boolean()` | 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](https://developers.openai.com/codex/app-server/):

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

```bash
mix deps.get
mix test
mix credo --strict
```

## License

MIT — see [LICENSE](LICENSE).