ExAthena

July 20, 2026 · View on GitHub

Hex.pm Docs License

Provider-agnostic agent loop for Elixir. Drop-in replacement for the Claude Code SDK that runs on Ollama, OpenAI-compatible endpoints (OpenAI, OpenRouter, LM Studio, vLLM, Groq, Together, llama.cpp server…), Google Gemini, or Anthropic Claude itself — with the same tools, hooks, permissions, and streaming semantics across every provider.

Status (v0.18): the agent loop, tools, MCP, permissions, hooks, compaction, subagents and sessions are all shipped — ExAthena.run/2 is the entry point, ExAthena.query/2 and stream/3 remain for plain inference, and embed/2, list_models/2 and extract_structured/2 round out the API. Two front-ends ride on top: a full-screen terminal TUI (mix athena.chat) and a Phoenix LiveView web UI (mix athena.web) with session recall, fork, and a diff viewer; their deps (ex_ratatui, phoenix, phoenix_live_view, bandit) are optional, so the core library stays lean.

v0.18 adds embeddings (ExAthena.embed/2), provider-agnostic model listing (ExAthena.list_models/2), and Elixir code intelligenceworkspace_symbol/document_symbol on the lsp tool, so agents navigate by symbol instead of grepping. It also hardens confinement: symlinks are resolved before the roots check, the web_fetch SSRF guard applies even unconfined and re-validates every redirect, subagents inherit their parent's confinement and permissions, and plan mode is deny-by-default. Breaking: the Claude Code provider's list_models/1 is now list_models_from/1 — run mix ex_athena.upgrade 0.17.0 0.18.0. See the v0.18.0 changelog; v0.17 added workspace confinement + OS sandboxing, v0.16 web_search, usage_rules and OpenRouter.

The operational harness it builds on — file-based memory (AGENTS.md/CLAUDE.md), Claude Code-style skills, a five-stage compaction pipeline, 14 hook events, five permission modes, custom agents with git-worktree isolation, and append-only session storage with checkpointing — is documented under The operational harness.

Why

If you're using claude_code today and want to switch to a local Ollama model — or route per-task to OpenAI-compatible endpoints, or try Groq behind the same Elixir code — you don't want to rewrite every orchestrator. ExAthena is that abstraction layer. Pick a provider, run the same call, get back the same shape.

Install

The one-liner (Igniter auto-installs + writes sensible config):

mix igniter.install ex_athena

Or manually — add to mix.exs:

def deps do
  [
    {:ex_athena, "~> 0.18"},
    # optional — only needed for the Claude provider:
    {:claude_code, "~> 0.36"},
    # optional — only needed for the TUI (`mix athena.chat`):
    {:ex_ratatui, "~> 0.10"},
    # optional — only needed for the web UI (`mix athena.web`):
    {:phoenix, "~> 1.7"},
    {:phoenix_live_view, "~> 1.0"},
    {:bandit, "~> 1.5"}
  ]
end

The core agent loop has no Phoenix/TUI dependency; add the optional deps above only if you want mix athena.chat or mix athena.web. Then run mix ex_athena.install once to wire up defaults, or configure manually (see Configuration).

Configuring providers

Provider settings resolve in three layers, highest priority first:

  1. Per-call optsExAthena.query("…", provider: :groq, model: "…") — overrides everything.
  2. Application configconfig :ex_athena, :ollama, model: "…" in config.exs.
  3. JSON files~/.config/ex_athena/providers/*.json — named providers loaded at startup.

Quickstart with a JSON provider (Groq):

mkdir -p ~/.config/ex_athena/providers
cp priv/provider_examples/groq.json ~/.config/ex_athena/providers/
export GROQ_API_KEY=gsk_…
mix athena.chat

ExAthena reads groq.json at startup and makes "groq" available by name. Full schema, security notes, and example files for Groq, Together, Fireworks, DeepSeek, and OpenRouter are in guides/providers.md.

Quick start

# config/config.exs
config :ex_athena, default_provider: :ollama
config :ex_athena, :ollama, base_url: "http://localhost:11434", model: "llama3.1"

# anywhere
{:ok, response} = ExAthena.query("Tell me a joke")
IO.puts(response.text)

# streaming
ExAthena.stream("Explain quantum computing", fn event ->
  case event.type do
    :text_delta -> IO.write(event.data)
    :stop -> IO.puts("\n[done]")
    _ -> :ok
  end
end)

Swap the provider by changing one option:

ExAthena.query("hi", provider: :openai_compatible, model: "gpt-4o-mini")
ExAthena.query("hi", provider: :claude, model: "claude-opus-4-8")
ExAthena.query("hi", provider: :ollama, model: "qwen2.5-coder")
ExAthena.query("hi", provider: :gemini, model: "gemini-2.5-flash")

Attach images with the images: shorthand — same API across every provider:

png = File.read!("diagram.png")
{:ok, response} = ExAthena.query("Describe this diagram",
  provider: :ollama,
  model: "llava",
  images: [%{data: png, media_type: "image/png"}]
)

See the Multimodal guide for inline images, URL references, and per-provider notes.

Embed text for retrieval through the same providers — pass a list to embed a whole batch in one round-trip:

config :ex_athena, :ollama, embedding_model: "nomic-embed-text"

{:ok, %ExAthena.Embedding{embeddings: vectors}} =
  ExAthena.embed(["def foo", "def bar"], provider: :ollama)

See the Embeddings guide.

Try it: mix athena.chat

Drop into an interactive chat REPL against a local Ollama model:

ollama serve &              # if not already running
ollama pull llama3.1        # any model you like

mix athena.chat
mix athena.chat --model qwen2.5-coder:14b --mode plan_and_solve

Tokens stream in real time. A pinned status line at the bottom tracks the current model, runner mode, iteration count, token usage, and cost. Slash commands switch state without restarting:

CommandWhat it does
/modelLive-list the current provider's models and pick one
/modeSwitch between react, plan_and_solve, reflexion
/toolsShow the tools the agent currently has access to
/clearWipe conversation history (start a fresh thread)
/helpPrint the command reference
/exit (or Ctrl-D)Leave

Defaults: :ollama provider, the model in config :ex_athena, :ollama, :model, :react runner, every builtin tool, permission_mode: :default.

Try it: mix athena.web

A browser-based chat UI with the same agent loop, served on localhost by default:

mix athena.web                  # http://localhost:4000 (this machine only)
mix athena.web --port 8080      # custom port
mix athena.web --lan            # expose on your network, gated by a token

The sidebar lets you switch provider, model, and mode without restarting. Features:

FeatureDescription
Session recallEvery completed turn is auto-saved to ~/.ex_athena/web/sessions/. Click "▼ Sessions" in the sidebar to load or delete past conversations.
ForkEach assistant message has a ⑂ fork button. It snapshots the conversation history at that point and opens a new branch — the original session is untouched.
Diff viewerFile edits show a "▼ view" button next to the tool result. Click it for a color-coded line diff (+ green / red) computed server-side. Bash tool calls show exit code, runtime, and stdout. File reads show the content.
Action indicatorWhile the model is running, a ⚡ Reading · foo.ex pill in the message header tracks the current tool call in real time.
MarkdownCompleted responses are rendered with headings, fenced code blocks (with language label), inline code, bold/italic, lists, links, and horizontal rules — no CDN or build step required.

The web UI is a Phoenix LiveView application that requires no separate server process — mix athena.web starts everything in one command. Sessions are serialized with :erlang.term_to_binary and survive restarts. The JS bundle is served directly from the installed phoenix and phoenix_live_view hex packages, so there is no npm or esbuild step.

Note: phoenix, phoenix_live_view, and bandit are optional dependencies. Add them to your mix.exs (see Install) before running mix athena.web.

Providers

ProviderModuleNotes
:ollamaExAthena.Providers.ReqLLMLocal Ollama, /api/chat. Native tool-calls on modern models.
:openai_compatibleExAthena.Providers.ReqLLM/v1/chat/completions — covers OpenAI, OpenRouter, LM Studio, vLLM, Groq, Together, llama.cpp server mode, etc.
:openaiExAthena.Providers.ReqLLMAlias for :openai_compatible.
:llamacppExAthena.Providers.ReqLLMAlias for local llama.cpp server.
:claudeExAthena.Providers.ReqLLMAnthropic Claude via req_llm.
:geminiExAthena.Providers.ReqLLMGoogle Gemini via AI Studio (routed through req_llm's Google adapter). Native tool calls + streaming. See setup guide.
:openrouterExAthena.Providers.ReqLLMFirst-class built-in: OpenAI-compatible gateway to ~hundreds of models. Needs OPENROUTER_API_KEY; models are auto-discovered from /models (searchable in the web/TUI dropdowns).
:mockExAthena.Providers.MockIn-memory test double.

Pass a custom module that implements ExAthena.Provider directly if you have an endpoint that doesn't fit the above.

Configuration

config :ex_athena,
  default_provider: :ollama

config :ex_athena, :ollama,
  base_url: "http://localhost:11434",
  model: "llama3.1"

config :ex_athena, :openai_compatible,
  base_url: "https://api.openai.com/v1",
  api_key: System.get_env("OPENAI_API_KEY"),
  model: "gpt-4o-mini"

config :ex_athena, :claude,
  api_key: System.get_env("ANTHROPIC_API_KEY"),
  model: "claude-opus-4-8"

OpenRouter works out of the box — just set OPENROUTER_API_KEY (its base_url, OpenAI-compatible tag, and model discovery ship as a built-in spec).

Web search backend for the web_search tool defaults to DuckDuckGo (no key). Swap it per environment:

EX_ATHENA_SEARCH_BACKEND=brave    # or tavily | searxng | duckduckgo (default)
EX_ATHENA_SEARCH_API_KEY=...      # required for brave/tavily
EX_ATHENA_SEARCH_ENDPOINT=...     # required for searxng

Code intelligence for the lsp tool gives AST-accurate navigation — preferred over grep for Elixir. Its actions: workspace_symbol (find a module/function/type by name project-wide), document_symbol (outline a file), definition, references, hover, and diagnostics.

For Elixir it launches Expert (the official next-gen Elixir language server) if an expert binary is on PATH, and falls back to ElixirLS (elixir-ls) otherwise. Install Expert (Apple Silicon shown; use expert_darwin_amd64 on Intel, expert_linux_amd64 on Linux):

curl -L -o expert \
  https://github.com/elixir-lang/expert/releases/download/v0.1.5/expert_darwin_arm64
chmod +x expert
mkdir -p ~/.local/bin && mv expert ~/.local/bin/expert   # ensure ~/.local/bin is on PATH
xattr -d com.apple.quarantine ~/.local/bin/expert        # macOS: clear Gatekeeper quarantine
expert --help                                            # verify

The release binary is self-contained (bundles its own Erlang/Elixir runtime). Override the server per language — this replaces the default, e.g. to pin ElixirLS or point at a Burrito artifact:

config :ex_athena, :lsp_servers, %{
  elixir: %{binary: "elixir-ls", args: []}
  # elixir: %{binary: "/path/to/expert_darwin_arm64", args: ["--stdio"]}
}

Servers for other languages (pyright-langserver, rust-analyzer, gopls, typescript-language-server) are auto-detected on PATH when present.

Agent nesting depth is capped by config :ex_athena, max_agent_depth: 5 (orchestrate workers may delegate sub-agents up to this depth).

Workspace confinement keeps tools inside a project. Opt in per run:

ExAthena.run(prompt, cwd: project, confine: true)          # confine to cwd
ExAthena.run(prompt, cwd: project, allowed_roots: [project, "/tmp"])

When on, filesystem tools reject paths outside the roots (/etc/passwd, ../secret) and Bash runs under an OS sandbox (sandbox-exec/bubblewrap) that blocks out-of-root writes. Library calls default to unconfined; mix athena.web and mix athena.chat confine to the open project by default (EX_ATHENA_CONFINE=0 to disable).

Independent of confinement, WebFetch refuses private/loopback/link-local hosts (SSRF guard) on the initial URL and every redirect hop — always on, even unconfined. Fetching from localhost/private hosts (e.g. a dev server) is an explicit per-run opt-in: ExAthena.run(prompt, allow_local_hosts: true).

Resolution is tiered — per-call opts always beat app env:

ExAthena.query("…",
  provider: :openai_compatible,          # overrides default_provider
  base_url: "https://openrouter.ai/api/v1",  # overrides :openai_compatible, base_url
  api_key: System.get_env("OPENROUTER_API_KEY"),
  model: "anthropic/claude-opus-4.1")

Tool calls

ExAthena.ToolCalls handles both protocols and auto-falls-back between them:

  • Native — OpenAI tool_calls arrays and Anthropic tool_use blocks. Parsed into canonical ExAthena.Messages.ToolCall structs.
  • TextTagged~~~tool_call {json} fenced blocks embedded in assistant prose, for models without native support.

The agent loop picks the protocol based on the provider's declared capabilities, and falls back when the model gets it wrong.

The operational harness (v0.4)

The "1.6% reasoning, 98.4% harness" upgrade — built around the Claude Code paper's observation that production agent value comes from the operational scaffolding, not the loop itself.

File-based context. Drop an AGENTS.md (or CLAUDE.md) at the project root and ex_athena prepends it as user-context on every turn. Drop a SKILL.md with YAML frontmatter under .exathena/skills/<name>/ and its description joins the system-prompt catalog at ~50 tokens; the body loads only when the model writes [skill: <name>]. See the memory + skills guide.

Five-stage compaction. The default Compactor.Pipeline runs cheapest-first: budget reduction (truncate huge tool results) → snip (drop stale ones) → microcompact (collapse runs of identical calls) → context collapse (read-time-only projection) → LLM summary. When a provider returns "context too long" the pipeline forces every stage and retries.

14-event hook surface. Every transition in the loop is observable + interceptable: SessionStart/End, UserPromptSubmit, ChatParams, Stop, StopFailure, all the *ToolUse* variants, PermissionRequest/Denied, Subagent*, three compaction events, Notification. Hooks can {:inject, msg} to add context or {:transform, prompt} to rewrite the user's message. See hooks reference.

Five permission modes. Add :accept_edits (auto-allow file edits, still prompt for bash) and :trusted (skip prompts; with optional respect_denylist: false for full YOLO) on top of the existing :plan / :default / :bypass_permissions. The denylist always wins, including in bypass — that's locked in a doctest. See permissions.

Subagents v2. Define custom agents in .exathena/agents/<name>.md with frontmatter (tools, permissions, mode, isolation); spawn by name via agent: "explore". Optional git-worktree isolation creates an isolated checkout per subagent (with safety checks + graceful fallback). Sidechain transcripts persist the full subagent conversation to disk so the parent only spends tokens on the final text. Three builtin definitions ship: general, explore, plan. See agents + subagents.

Storage + checkpoint. ExAthena.Sessions.Store is an append-only event log behaviour with two stores: in-memory (default) and ETS-buffered JSONL with periodic flush. Sessions emit :user_message / :assistant_message / :tool_result events; Session.resume/2 rebuilds the message history from any store. File-checkpoint snapshots fire before every Edit / Write, and Checkpoint.rewind/3 restores files + truncates the session log to a chosen UUID. See sessions + checkpoints.

Guides

License

Apache 2.0 — see LICENSE.