Provider-agnostic agent loop for Elixir.
ExAthena runs against Ollama, OpenAI-compatible endpoints (OpenAI, OpenRouter, LM Studio, vLLM, and friends), llama.cpp, Google Gemini, or the Anthropic Claude API — with the same tools, hooks, permissions, and streaming semantics across every provider.
The public surface
Everything listed here is shipped and wired — there is no "coming later" half of this API.
query/2— one-shot inference, returns anExAthena.Response.stream/3— the same call withExAthena.Streaming.Eventdeltas pushed to a callback.run/2— the full agent loop (ExAthena.Loop): infer → tool call → execute → replay → repeat.extract_structured/2— schema-validated JSON extraction (ExAthena.Structured).embed/2— text embeddings, one vector per input (ExAthena.Embedding).list_models/2— enumerate a provider's models asExAthena.Modelstructs (ExAthena.ModelListing).capabilities/1— the provider'sExAthena.Capabilitiesmap. Feature-detect with it before relying on any optional capability.ExAthena.query("Tell me a joke", provider: :ollama, model: "llama3.1") #=> {:ok, %ExAthena.Response{text: "…", …}} ExAthena.run("read mix.exs and list the deps", provider: :ollama, tools: :all, cwd: "/path/to/project") #=> {:ok, %ExAthena.Result{text: "…", iterations: 3, …}}
The agent loop and its harness
run/2 is the entry point to the operational harness: the builtin tool set
(ExAthena.Tools — file read/write/edit, bash, glob, grep, web fetch and
search, subagent spawn), native MCP servers (ExAthena.Mcp), five permission
modes (ExAthena.Permissions), a 14-event hook surface (ExAthena.Hooks), a
five-stage compaction pipeline (ExAthena.Compactor), file-based memory and
skills (ExAthena.Memory, ExAthena.Skills), custom agents with optional
git-worktree isolation (ExAthena.Agents), opt-in workspace confinement
(confine: true / allowed_roots: [...], with an OS sandbox for bash),
and append-only session storage with checkpoint/rewind (ExAthena.Session,
ExAthena.Checkpoint).
See the agent loop guide for the full option list.
Configuring a default provider
# config/config.exs
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"
config :ex_athena, :gemini,
api_key: System.get_env("GEMINI_API_KEY"),
model: "gemini-2.5-flash"Per-call overrides always win:
ExAthena.query("…", provider: :claude, model: "claude-sonnet-4-6")Runtime JSON providers
You can define additional named providers without touching config.exs by
dropping a JSON file into ~/.config/ex_athena/providers/. Each file's
"name" field becomes the string you pass as provider::
ExAthena.query("…", provider: "my-groq")
ExAthena.query("…", provider: "my-groq", model: "mixtral-8x7b-32768")Files are loaded once at application startup via ExAthena.ProviderRegistry.
Per-call opts still override JSON-file defaults. See the
Providers guide for the full schema, security notes, and
ready-to-copy examples for Groq, Together AI, Fireworks, and DeepSeek.
Providers
ExAthena.Providers.ReqLLM— multi-backend viareq_llm. Covers:gemini(Google Gemini),:openai,:claude/:anthropic,:ollama, and:llamacpp.ExAthena.Providers.Mock— test double with scripted responses.
Consumers can also pass a custom module that implements ExAthena.Provider, or
define JSON-file providers as described above.
Request queue
A semaphore caps concurrent in-flight requests per provider (enabled by
default — local inference servers can only serve 1-3 requests at a time).
query/2, stream/3, and extract_structured/2 acquire a slot per call;
run/2 acquires a slot around each provider call inside the loop (not
one slot for the whole run), so concurrent agent loops and subagents
interleave fairly on scarce GPU slots.
Disable via:
config :ex_athena, :request_queue, enabled: falsePass queue: false on any individual call to bypass the queue for that call.
Summary
Functions
Returns the capabilities map for a provider.
Whether first-party hosts (the web UI and TUI) should confine a run to its
working directory by default. On unless EX_ATHENA_CONFINE is 0/false/no.
Embed text. Accepts a single string or a list of strings and returns one vector per input, in input order.
One-shot structured extraction. Returns a validated JSON map.
List the models a provider can serve, as ExAthena.Model structs.
One-shot inference. Returns the final Response struct with the full text.
Run a multi-turn agent loop: infer → tool call → execute → replay → repeat.
Streaming inference. Calls callback with each ExAthena.Streaming.Event as
tokens arrive, and returns the final Response when the stream completes.
Returns true if the library forwards multimodal content parts
(image / image_url / file) to the underlying provider.
Functions
Returns the capabilities map for a provider.
ExAthena.capabilities(:mock)
#=> %{streaming: true, native_tool_calls: true, …}
@spec confine_default?() :: boolean()
Whether first-party hosts (the web UI and TUI) should confine a run to its
working directory by default. On unless EX_ATHENA_CONFINE is 0/false/no.
Library consumers calling run/2 directly are unaffected — they opt in with
confine: true or allowed_roots: [...].
@spec embed( String.t() | [String.t()], keyword() ) :: {:ok, ExAthena.Embedding.t()} | {:error, term()}
Embed text. Accepts a single string or a list of strings and returns one vector per input, in input order.
Batch calls are a single provider round-trip — indexing jobs embed hundreds of chunks per run, and one request per chunk would dominate their runtime.
ExAthena.embed(["def foo", "def bar"], provider: :ollama)
#=> {:ok, %ExAthena.Embedding{embeddings: [[0.1, …], [0.2, …]], …}}Options
:provider— asquery/2. Must implement the optionalembed/2callback; feature-detect withcapabilities(provider)[:embeddings].:model— the embedding model. Defaults to the provider'sembedding_model:config, then the top-level:embedding_modelconfig. The provider's chatmodel:is never used — embedding models are a separate population (e.g.nomic-embed-text).:timeout_ms,:provider_opts,:queue,:queue_timeout— asquery/2.
Configure a default embedding model alongside the chat model:
config :ex_athena, :ollama,
base_url: "http://localhost:11434",
model: "qwen3-coder",
embedding_model: "nomic-embed-text"
One-shot structured extraction. Returns a validated JSON map.
Accepts :queue and :queue_timeout options (see query/2).
See ExAthena.Structured.extract/2 for the full option list.
@spec list_models( atom() | module() | nil, keyword() ) :: {:ok, [ExAthena.Model.t()]} | {:error, term()}
List the models a provider can serve, as ExAthena.Model structs.
ExAthena.list_models(:ollama)
#=> {:ok, [%Model{id: "qwen3-coder:30b", context_window: nil, …}]}Every backend answers this question in its own dialect — Ollama's
/api/tags, an OpenAI-compatible /v1/models, or nothing at all (Anthropic
and Google are read from the llm_db catalog). Hosts building a model picker
get one shape regardless, and Model.id is exactly what to pass back as
model: on the next query/2.
Results are cached for five minutes in the shared model-discovery cache, so
reopening a picker costs nothing; pass cache: false to force a refetch (what
a user-facing "reload models" control should do).
Providers that cannot enumerate their models return a :capability error —
feature-detect ahead of time with capabilities(provider)[:model_listing] and
fall back to a free-text model field.
Options
:base_url,:api_key— asquery/2; default to the provider's config.:cache—falseto bypass the TTL cache for this call.:include_cloud— Ollama only: also list theollama.comcatalogue, each entry suffixed-cloudso it is invocable through a signed-in local daemon. Off by default because it reaches out to the internet.
@spec query( String.t() | nil, keyword() ) :: {:ok, ExAthena.Response.t()} | {:error, term()}
One-shot inference. Returns the final Response struct with the full text.
Options
:provider— provider atom (:ollama,:openai_compatible,:claude,:gemini,:mock), a module that implementsExAthena.Provider, or a string matching a JSON-defined provider loaded from~/.config/ex_athena/providers/(see the Providers guide). Defaults toApplication.get_env(:ex_athena, :default_provider).:model— model name string. Defaults to the provider's configured model.:system_prompt— optional system prompt string.:messages— list of canonical messages;promptis prepended as a user message if given.:max_tokens,:temperature,:top_p,:stop— optional sampling knobs.:timeout_ms— request timeout (default 300_000). The 5-minute default is deliberate: local backends spend minutes prompt-processing a large agent transcript before the first byte comes back.:provider_opts— escape hatch keyword list passed through to the underlying provider.:images— list of image maps to attach to the trailing user message. Each entry is%{data: binary(), media_type: String.t()}for inline images or%{url: String.t()}for remote image URLs. Merged into the user message created fromprompt, or the last user message in:messageswhen no prompt is given.:queue— set tofalseto bypass the request queue for this call (defaulttrue). Has no effect when the request queue is not enabled.:queue_timeout— milliseconds to wait for a queue slot before returning{:error, :request_queue_timeout}(default 5_000).
@spec run( String.t() | nil, keyword() ) :: {:ok, ExAthena.Result.t()} | {:error, term()}
Run a multi-turn agent loop: infer → tool call → execute → replay → repeat.
Queueing happens inside the loop at per-provider-call granularity (one
slot per inference call, released between iterations) — never one slot for
the whole run, which would starve or deadlock concurrent loops on
single-slot local providers. Accepts :queue (default true) and
:queue_timeout (default :infinity — the run's own timeout_ms and
budget caps bound total time).
See ExAthena.Loop.run/2 for the full option list.
@spec stream(String.t() | nil, function(), keyword()) :: {:ok, ExAthena.Response.t()} | {:error, term()}
Streaming inference. Calls callback with each ExAthena.Streaming.Event as
tokens arrive, and returns the final Response when the stream completes.
callback receives one argument — an %ExAthena.Streaming.Event{} struct —
and its return value is ignored. Callbacks must not block the caller; if you
need to do expensive work per-delta, hand off to a Task.
Options are the same as query/2, including :images, :queue,
:queue_timeout, and the :provider string form for JSON-defined providers.
When the request queue is enabled, the slot is held for the full duration of
the stream and released on every exit path (success, error, or callback
exception).
@spec supports_multimodal?() :: true
Returns true if the library forwards multimodal content parts
(image / image_url / file) to the underlying provider.
Callers can use this to decide whether to build ExAthena.Messages.ContentPart
image or file parts, rather than falling back to text-only prompts.