Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

72 Commits

Folders and files

Repository files navigation

Echs

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":

  1. Send conversation history to the Responses endpoint (streaming SSE).
  2. Collect assistant output items.
  3. If the assistant produced tool calls, execute them locally.
  4. Append tool outputs back into the history.
  5. 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.

Apps (Umbrella)

  • 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.

Quickstart (Local)

Prereqs:

  • Elixir ~> 1.19 (see apps/*/mix.exs)
  • codex CLI 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/workdir

Running The Daemon (echs_server)

echs_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, requires Authorization: Bearer <token>
  • ECHS_UPLOAD_DIR (default: /tmp/echs_uploads) - where /v1/uploads stores files
  • ECHS_MAX_CONCURRENT_TURNS (default: infinity) - global cap on concurrent turns
  • ECHS_DB_PATH (default: tmp/echs.db) - SQLite path used by echs_store
  • ECHS_DB_POOL_SIZE (default: 5) - SQLite connection pool size
  • ECHS_AUTO_MIGRATE (default: 1) - set to 0 to disable auto-migrations
  • ECHS_TOOL_TIMEOUT_MS (default: 120000) - max tool execution time
  • ECHS_TOOL_LOG (default: 0) - set to 1 to log tool calls/results
  • CODEX_FORUM_TOKEN / CODEX_FORUM_MCP_TOKEN - enable Codex Forum toolset
  • CODEX_FORUM_API_BASE_URL (default: http://localhost:4310)
  • CODEX_FORUM_API_PREFIX (default: empty)
  • LOG_LEVEL (default: info) - release runtime logger level (see config/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/healthz

OpenAPI:

curl -s http://127.0.0.1:4000/openapi.json | jq .

# Or write a file:
mix echs.openapi --out openapi.json

Deploying (Release + systemd)

ECHS 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_server

The release output will be under:

  • _build/prod/rel/echs_server/

Run it directly:

_build/prod/rel/echs_server/bin/echs_server foreground

For systemd, see the example unit file:

  • docs/systemd/echs_server.service

Notes:

  • echs_codex reads credentials from ~/.codex/auth.json, so make sure the service user has run codex login (or otherwise has that auth file).
  • Uploads (for /v1/uploads handle mode) are stored on disk. Configure the directory with ECHS_UPLOAD_DIR (defaults to /tmp/echs_uploads).

HTTP API (REST + SSE)

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.

Create a thread (session)

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_..."}

List threads (with metadata)

curl -s http://127.0.0.1:4000/v1/threads | jq .

Update thread configuration

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.

Send a message (async)

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.

Stream events (SSE)

curl -N http://127.0.0.1:4000/v1/threads/<thread_id>/events

Events 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'

Fetching Previous Output / State

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.

Uploading Images (HTTP)

ECHS supports input_image content items. The easiest way to construct those from a client is:

  1. POST /v1/uploads (multipart) to get an upload_id + a content item handle
  2. Send a message with content: [ ... ] including that input_image handle

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_url

Conversations (high-level API)

Conversations 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>/events

Fetch conversation history (paged):

curl -s 'http://127.0.0.1:4000/v1/conversations/<conversation_id>/history?offset=0&limit=200' | jq .

Usage (As a Library)

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")

Thread Runtime Model (echs_core)

ECHS runs one EchsCore.ThreadWorker GenServer per thread. A "turn" is executed as:

  1. Append the user message into history_items (Responses-style items).
  2. Start a streaming call via EchsCodex.stream_response/1.
  3. Broadcast SSE deltas and item boundaries over PubSub.
  4. When streaming completes, execute any tool calls the model produced.
  5. Append tool outputs to history and continue streaming until the model stops calling tools.

PubSub Events

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.

Tools

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 (simple bash -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 an input_image)

Toolsets:

  • core (default)
  • shell
  • codex_forum (requires CODEX_FORUM_TOKEN or CODEX_FORUM_MCP_TOKEN)

Custom Tools

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)

Sending Images (User Input)

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)

Auth / Codex Client (echs_codex)

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 file
  • ECHS_CODEX_ACCESS_TOKEN - direct access token (bypasses ~/.codex/auth.json)
  • ECHS_CODEX_ACCOUNT_ID - required when ECHS_CODEX_ACCESS_TOKEN is set
  • ECHS_CODEX_BASE_URL - override responses endpoint base URL
  • ECHS_CODEX_COMPACT_URL - override compact endpoint URL

Note: if you use ECHS_CODEX_ACCESS_TOKEN, token refresh via codex login status is disabled.

Development

mix deps.get
mix test
mix format

Notes For AI Agents / Contributors

See AGENTS.md for:

  • a deeper architectural walkthrough,
  • agent-specific working conventions, and
  • implementation notes about tools, threading, and blackboard coordination.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages