# 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).