An agent framework for Elixir — structured output, tool-calling and streaming for LLMs, powered by the BEAM. Built the Elixir way: recursion, behaviours, Ecto changesets, concurrent tool execution, supervision and telemetry.
Why
Python agent libraries are delightful when types + validation + an agentic loop
work together. ExAgent brings that ergonomics (type-derived tool schemas,
structured output with retry, model-agnostic agents) to Elixir, while leaning on
BEAM strengths: cheap concurrency for tools, supervision/durability,
:telemetry, and streaming that plugs straight into LiveView.
Features
- Agent loop — recursive
User → Model ⇄ CallTools → End. - Model-agnostic providers — OpenAI, OpenRouter (OpenAI-Chat format),
Anthropic + Z.AI/GLM (native Messages API), and an offline
TestModel. - Tools —
deftoolmacro derives JSON Schema from::type annotations +@doc; runs in parallel (Task.async_stream) with per-tool timeout & retry budget. - Structured output — any
embedded_schemabecomes the output spec; JSON Schema is derived and validated with a changeset, with retry-on-failure. - Streaming —
run_stream/3returns a lazyStreamof{:delta, text}/{:result, map}over real SSE (OpenAI + Anthropic). - Capabilities — composable middleware (
before_model_request,after_tool_execute, …) via a behaviour with no-op defaults. - Production bits — supervised
ExAgent.Finchpool, typedRequestError,UsageLimitssafety net, and:telemetryevents.
Quick start
def deps do
[{:exagent, "~> 0.1.0"}]
endalias ExAgent
agent = ExAgent.new(model: "test", instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")Set OPENAI_API_KEY before using openai:* models.
Tools with derived schemas
defmodule MyApp.Tools do
use ExAgent.Tools
@doc "Get the weather for a city."
deftool get_weather(ctx, city :: String.t(), days :: integer()) do
{:ok, "#{city}: sunny"}
end
end
agent = ExAgent.new(model: "openai:gpt-4o", tools: MyApp.Tools.tools())Structured output
defmodule WeatherReport do
use Ecto.Schema
embedded_schema do
field :city, :string
field :temp_c, :float
field :condition, Ecto.Enum, values: [:sunny, :rainy, :cloudy]
end
def changeset(s, a) do
s |> Ecto.Changeset.cast(a, [:city, :temp_c, :condition])
|> Ecto.Changeset.validate_required([:city, :temp_c])
end
end
agent = ExAgent.new(model: "anthropic:claude-3-5-haiku", output: WeatherReport)
{:ok, %{output: %WeatherReport{city: "Madrid", temp_c: 22.0, condition: :sunny}}} =
ExAgent.run(agent, "It's 22 and sunny in Madrid")Streaming
ExAgent.run_stream(agent, "count to five")
|> Stream.each(fn
{:delta, t} -> IO.write(t)
{:result, %{usage: u}} -> IO.puts("\n#{u.output_tokens} tokens")
end)
|> Stream.run()Persistence / durable runs
The framework is DB-free: it doesn't own a database or job queue. What it does provide is best-effort message-history serialization, so you can persist a conversation anywhere (Postgres/Redis/ETS/file) and resume it later:
alias ExAgent.Message
json = Message.to_json(result.messages) # store this
{:ok, history} = Message.from_json(json) # load it back later
ExAgent.run(agent, "follow up", message_history: history)For crash-safe, resumable runs, wrap ExAgent.run in an Oban job in your
app — see examples/durable_oban.exs for a copy-paste recipe (idempotency
keys, checkpoints, retries). Approval workflows can be coordinated in your app
around persisted history. Durability is an application concern, so the library
doesn't force Oban/Postgres on you.
Models
Resolve from a string or pass a struct:
ExAgent.new(model: "openai:gpt-4o")
ExAgent.new(model: "openrouter:deepseek/deepseek-v4-flash")
ExAgent.new(model: "anthropic:claude-3-5-haiku-20241022")
# Z.AI's Anthropic-compatible endpoint (GLM models), needs ZAI_API_KEY:
ExAgent.new(model: "zai:glm-4.5-air")Examples
examples/demo.exs— offline loop with the TestModel (no API key).examples/openrouter.exs— live tool-calling via OpenRouter.examples/zai_anthropic.exs— live native Anthropic format via Z.AI.examples/structured_output.exs— live structured output via Ecto.examples/streaming.exs— live SSE streaming.
Status
Early, feature-complete MVP for the core agent loop. Implemented & verified
against live providers; see the test suite (run mix test).
Notes for host apps
- This library starts a supervised
ExAgent.FinchHTTP pool in itsApplication, so it works out of the box. Tune pool size withconfig :exagent, :finch_pools, %{:default => [size: 32]}. ExAgentdoes not shadow OTP'sAgentunless you alias it asAgent. If you use both in the same file, keep the full name or choose a different alias.
License
MIT