Elixir Codex Harness Server (ECHS). This umbrella hosts the core runtime, Codex API client, CLI, and protocol surface used by the harness.
ECHS is an Elixir umbrella that runs a Codex/Responses "tool loop":
- Send conversation history to the Responses endpoint (streaming SSE).
- Collect assistant output items.
- If the assistant produced tool calls, execute them locally.
- Append tool outputs back into the history.
- Repeat streaming until the assistant stops calling tools.
This repo is meant to be:
- a runnable local harness (CLI) for experimenting with Codex tool-calling, and
- a reusable runtime + client that other Elixir apps can embed.
echs_core— thread runtime, tools, PubSub events, and blackboard.echs_codex— Codex API auth and streaming client.echs_cli— interactive CLI for local runs.echs_server— HTTP daemon (REST + SSE) exposing a wire interface.echs_protocol— protocol definitions (currently minimal).echs_store— SQLite persistence for threads/messages/history.
Prereqs:
- Elixir
~> 1.19(seeapps/*/mix.exs) codexCLI installed and logged in (see Auth section)
From the umbrella root:
mix deps.get
mix test
# Run an interactive session (uses echs_core + echs_codex under the hood)
mix run -e 'EchsCli.main()'
mix run -e 'EchsCli.main(["/path/to/workdir"])'
# Ratatouille TUI
mix run -e 'EchsCli.Tui.main()'
mix run -e 'EchsCli.Tui.main(["/path/to/workdir"])'
# Shortcut script
./bin/echs-tui
./bin/echs-tui /path/to/workdirechs_server is a small HTTP API intended to be run as a daemon on a server.
Environment variables:
ECHS_BIND(default:0.0.0.0)ECHS_PORT(default:4000)ECHS_API_TOKEN(optional) - if set, requiresAuthorization: Bearer <token>ECHS_UPLOAD_DIR(default:/tmp/echs_uploads) - where/v1/uploadsstores filesECHS_MAX_CONCURRENT_TURNS(default:infinity) - global cap on concurrent turnsECHS_DB_PATH(default:tmp/echs.db) - SQLite path used byechs_storeECHS_DB_POOL_SIZE(default:5) - SQLite connection pool sizeECHS_AUTO_MIGRATE(default:1) - set to0to disable auto-migrationsECHS_TOOL_TIMEOUT_MS(default:120000) - max tool execution timeECHS_TOOL_LOG(default:0) - set to1to log tool calls/resultsCODEX_FORUM_TOKEN/CODEX_FORUM_MCP_TOKEN- enable Codex Forum toolsetCODEX_FORUM_API_BASE_URL(default:http://localhost:4310)CODEX_FORUM_API_PREFIX(default: empty)LOG_LEVEL(default:info) - release runtime logger level (seeconfig/runtime.exs)
Run locally:
ECHS_PORT=4000 ECHS_BIND=127.0.0.1 mix run --no-halt -e 'Application.ensure_all_started(:echs_server)'Health check:
curl -s http://127.0.0.1:4000/healthzOpenAPI:
curl -s http://127.0.0.1:4000/openapi.json | jq .
# Or write a file:
mix echs.openapi --out openapi.jsonECHS is designed to run as a long-lived daemon on a server.
Build a release (from the umbrella root):
mix deps.get
MIX_ENV=prod mix release echs_serverThe release output will be under:
_build/prod/rel/echs_server/
Run it directly:
_build/prod/rel/echs_server/bin/echs_server foregroundFor systemd, see the example unit file:
docs/systemd/echs_server.service
Notes:
echs_codexreads credentials from~/.codex/auth.json, so make sure the service user has runcodex login(or otherwise has that auth file).- Uploads (for
/v1/uploadshandle mode) are stored on disk. Configure the directory withECHS_UPLOAD_DIR(defaults to/tmp/echs_uploads).
This API is intentionally close to the Responses item model ECHS uses internally.
ECHS exposes two API surfaces:
threads(low-level) - direct control over a single thread worker.conversations(high-level) - long-lived container that manages an active thread and auto-compaction into new threads.
curl -s -X POST http://127.0.0.1:4000/v1/threads \\
-H 'content-type: application/json' \\
-d '{
"cwd": "/tmp",
"model": "gpt-5.3-codex",
"reasoning": "medium",
"instructions": "Be concise"
}'Response:
{"thread_id":"thr_..."}curl -s http://127.0.0.1:4000/v1/threads | jq .curl -s -X PATCH http://127.0.0.1:4000/v1/threads/<thread_id> \\
-H 'content-type: application/json' \\
-d '{
"config": {
"instructions": "You are a careful reviewer. Ask clarifying questions.",
"reasoning": "high",
"toolsets": ["core", "codex_forum"],
"tools": ["-apply_patch", "+shell", "+view_image"]
}
}'Supported config keys today:
cwd,model,reasoning,instructions,toolsets,tools
Reasoning values: none, minimal, low, medium, high, xhigh.
toolsets accepts a list of named bundles (e.g. core, codex_forum). When
present, ECHS rebuilds the tool list from those bundles before applying any
tools add/remove modifiers.
tools accepts +name/-name modifiers as well as full tool spec maps.
curl -s -X POST http://127.0.0.1:4000/v1/threads/<thread_id>/messages \\
-H 'content-type: application/json' \\
-d '{
"mode": "queue",
"content": "Hello from the wire API"
}'This is asynchronous: the server returns 202 Accepted with a message_id,
and you consume streaming output via SSE.
Response:
{"ok":true,"thread_id":"thr_...","message_id":"msg_..."}You can also supply configure to hot-swap settings before sending:
curl -s -X POST http://127.0.0.1:4000/v1/threads/<thread_id>/messages \\
-H 'content-type: application/json' \\
-d '{
"mode": "queue",
"configure": { "instructions": "Answer in JSON." },
"content": "Summarize the repo"
}'If you want idempotency, you can provide your own message_id in the request.
curl -N http://127.0.0.1:4000/v1/threads/<thread_id>/eventsEvents are emitted from EchsCore.ThreadWorker and sent as:
event: turn_delta
data: {"thread_id":"...","message_id":"msg_...","content":"..."}
ECHS includes an id: field on SSE events and accepts Last-Event-ID to
resume recent events (buffered per thread, newest 500). Example:
curl -N http://127.0.0.1:4000/v1/threads/<thread_id>/events \\
-H 'Last-Event-ID: 120'If a client disconnects from SSE, you can still query the server for:
- per-message metadata/status (queued/running/completed/error)
- per-message history slices (the items produced by that message)
- the thread's full history (paged)
ECHS also persists threads/messages/history to SQLite (echs_store). If the
daemon restarts, these read endpoints still work for stored threads, and
POST /v1/threads/<thread_id>/messages will automatically restore the thread
into memory before enqueueing.
Stored threads show up in GET /v1/threads with status: "stored".
List messages:
curl -s http://127.0.0.1:4000/v1/threads/<thread_id>/messages | jq .Get message status, and (optionally) its history items:
curl -s http://127.0.0.1:4000/v1/threads/<thread_id>/messages/<message_id> | jq .
curl -s 'http://127.0.0.1:4000/v1/threads/<thread_id>/messages/<message_id>?include_items=1' | jq .Fetch paged history:
curl -s 'http://127.0.0.1:4000/v1/threads/<thread_id>/history?offset=0&limit=200' | jq .By default, history responses redact base64 data: URLs in input_image items.
Use redact=0 if you really want raw payloads.
ECHS supports input_image content items. The easiest way to construct those
from a client is:
POST /v1/uploads(multipart) to get anupload_id+ a content item handle- Send a message with
content: [ ... ]including thatinput_imagehandle
The server will expand upload_id to a base64 data: URL before calling the
Codex API, so clients do not need to send base64 in every request.
Upload:
curl -s -X POST http://127.0.0.1:4000/v1/uploads \\
-F file=@/path/to/image.png | jq .Then send:
curl -s -X POST http://127.0.0.1:4000/v1/threads/<thread_id>/messages \\
-H 'content-type: application/json' \\
-d '{
"content": [
{"type": "input_text", "text": "Describe this image"},
{"type": "input_image", "upload_id": "upl_..."}
]
}'If you want an inline data: URL in the upload response (useful for debugging),
pass inline=1:
curl -s -X POST 'http://127.0.0.1:4000/v1/uploads?inline=1' \\
-F file=@/path/to/image.png | jq -r .image_urlConversations keep a long-lived session across multiple threads. When the context window is exceeded, the server compacts history into a new thread and marks a session break in history.
Create a conversation:
curl -s -X POST http://127.0.0.1:4000/v1/conversations \\
-H 'content-type: application/json' \\
-d '{"model":"gpt-5.3-codex","reasoning":"medium"}'Send a message:
curl -s -X POST http://127.0.0.1:4000/v1/conversations/<conversation_id>/messages \\
-H 'content-type: application/json' \\
-d '{"content":"Hello from conversations"}'Stream events:
curl -N http://127.0.0.1:4000/v1/conversations/<conversation_id>/eventsFetch conversation history (paged):
curl -s 'http://127.0.0.1:4000/v1/conversations/<conversation_id>/history?offset=0&limit=200' | jq .Minimal API usage:
{:ok, _} = Application.ensure_all_started(:echs_core)
{:ok, _} = Application.ensure_all_started(:echs_codex)
{:ok, thread_id} = EchsCore.create_thread(cwd: "/path/to/workdir")
{:ok, history_items} = EchsCore.send_message(thread_id, "Hello from ECHS")When a turn is running, you can send follow-ups in two ways:
# Queue (default): run after current turn completes
EchsCore.queue_message(thread_id, "Follow up after this turn")
# Steer: preempt at a safe boundary (see Turn Control notes)
EchsCore.steer_message(thread_id, "Please prioritize error handling")ECHS runs one EchsCore.ThreadWorker GenServer per thread. A "turn" is executed
as:
- Append the user message into
history_items(Responses-style items). - Start a streaming call via
EchsCodex.stream_response/1. - Broadcast SSE deltas and item boundaries over PubSub.
- When streaming completes, execute any tool calls the model produced.
- Append tool outputs to history and continue streaming until the model stops calling tools.
Each thread broadcasts to topic: thread:<thread_id> (via EchsCore.PubSub).
Common events:
:turn_started:turn_delta(streaming text chunks):item_started/:item_completed:tool_completed:turn_completed/:turn_interrupted/:turn_error
The CLI (echs_cli) subscribes and prints these events for you.
By default, the thread worker exposes a built-in tool surface to the model, including:
- Shell execution:
exec_command/write_stdin(port-backed sessions; interactive-ish, not a full TTY)shell(simplebash -lc, non-interactive)local_shell(legacy item type:local_shell_call)
- File ops:
read_file,list_dir,grep_files - Patching:
apply_patch(Codex "*** Begin Patch" format) - Sub-agents:
spawn_agent,send_to_agent,wait_agents,kill_agent - Coordination:
blackboard_write,blackboard_read - Images:
view_image(attach a local image path to context as aninput_image)
Toolsets:
core(default)shellcodex_forum(requiresCODEX_FORUM_TOKENorCODEX_FORUM_MCP_TOKEN)
You can register tools per thread, along with a handler:
spec = %{
"type" => "function",
"name" => "custom_tool",
"description" => "Custom tool",
"parameters" => %{"type" => "object"}
}
handler = fn args, ctx ->
{:ok, %{args: args, cwd: ctx.cwd}}
end
:ok = EchsCore.register_tool(thread_id, spec, handler)The Responses API supports multi-part message content. In ECHS you can pass a list of content items instead of a plain string:
image_url = "data:image/png;base64,..." # or https://...
items = [
%{"type" => "input_text", "text" => "What is in this screenshot?"},
%{"type" => "input_image", "image_url" => image_url}
]
{:ok, _} = EchsCore.send_message(thread_id, items)By default, ECHS relies on the system codex CLI to provision/refresh credentials.
- Auth file:
~/.codex/auth.json - Initialize or refresh:
codex login - Refresh path used by the code:
codex login status
You can override auth and endpoints via environment variables:
ECHS_CODEX_AUTH_PATH- custom path to the auth JSON fileECHS_CODEX_ACCESS_TOKEN- direct access token (bypasses~/.codex/auth.json)ECHS_CODEX_ACCOUNT_ID- required whenECHS_CODEX_ACCESS_TOKENis setECHS_CODEX_BASE_URL- override responses endpoint base URLECHS_CODEX_COMPACT_URL- override compact endpoint URL
Note: if you use ECHS_CODEX_ACCESS_TOKEN, token refresh via codex login status
is disabled.
mix deps.get
mix test
mix formatSee AGENTS.md for:
- a deeper architectural walkthrough,
- agent-specific working conventions, and
- implementation notes about tools, threading, and blackboard coordination.