An agent framework for Elixir — structured output, tool-calling, streaming, stateful agents, multi-agent sessions and durable persistence, powered by the BEAM.
ExAgent is layered and opt-in: use just the one-shot core, or stack on the
stateful runtime, persistence and coordination as you need them. It is built the
Elixir way — recursion, behaviours, Ecto changesets, cheap concurrency for tools,
supervision/durability, :telemetry, and events that plug straight into LiveView.
Layer 3 ExAgent.Session coordinated multi-agent turns + shared state
Layer 2 ExAgent.Store snapshots: resume after crash / restart
Layer 1 ExAgent.Server a supervised, stateful, event-emitting agent
Layer 0 ExAgent.run/3 the one-shot model ⇄ tools loop
──────────────────────── events (ExAgent.Event) over ExAgent.PubSubFeatures
- One-shot agentic loop — a model ⇄ tools recursion built as idiomatic Elixir.
- Type-derived tool schemas — define tools as plain functions; JSON Schema is
generated from
name :: Typeannotations and@docstrings (no hand-written schemas). - Structured output — any Ecto
embedded_schemabecomes the output spec; JSON Schema is derived from the schema and its changeset validations, validated with retry-on-failure. - Streaming — text deltas as a lazy stream for typewriter/chat UIs.
- Supervised stateful agents — keep history, accumulate usage, thread stateful models across runs, and emit versioned events over PubSub (LiveView-ready).
- Durable snapshots & resume — checkpoint after every run, rehydrate on restart; ETS by default, Postgres for multi-node durability (your DB, your repo).
- Multi-agent sessions — coordinated turns over shared state with pluggable
turn policies (
round_robin,initiative, or your own). - Orchestration — delegation (agent-as-tool) and hand-off between participants.
- Robustness & safety — context compaction, usage/cost limits, and per-tool
permissions (
allow/ask/deny). - Model-agnostic — OpenAI, OpenRouter, Anthropic and Z.AI built in; bring your
own by implementing the
ExAgent.Modelbehaviour. - External tools (MCP) — consume any Model Context Protocol server's tools.
- Observable —
:telemetryevents plus app-levelExAgent.Eventenvelopes. - Offline-first testing — a deterministic
ExAgent.Models.Testmodel drives the full loop with no API key and no network.
Requirements
- Elixir 1.17+
- Erlang/OTP 25+
Installation
Add :exagent to your list of dependencies in mix.exs:
def deps do
[{:exagent, "~> 1.0"}]
endThe library starts its own supervised ExAgent.Finch HTTP pool, a Registry
(ExAgent.PubSub.Local), a Task.Supervisor, an ExAgent.Store.ETS table and
an ExAgent.AgentSupervisor, so it works out of the box. Tune the Finch pool
with:
config :exagent, :finch_pools, %{:default => [size: 32]}
ExAgentdoes not shadow OTP'sAgentunless you alias it asAgent.
Quick start
The fastest way to try ExAgent is with Mix.install/2 (Livebook or a script) —
using the built-in ExAgent.Models.Test model, no API key needed:
Mix.install([
{:exagent, "~> 1.0"}
])
agent = ExAgent.new(model: "test", instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")Point it at a real provider with a "provider:model" string:
agent = ExAgent.new(model: "openai:gpt-4o", instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")Table of Contents
- Layer 0 — the one-shot loop
- Layer 1 — a stateful, supervised agent
- Layer 2 — snapshots & resume
- Layer 3 — multi-agent sessions
- Coordination
- Robustness & safety
- External tools (MCP)
- Events & PubSub
- Models
- Examples
- Documentation
- Contributing
- License
Layer 0 — the one-shot loop
The core is a small loop: UserPromptNode → ModelRequestNode ⇄ CallToolsNode → End.
agent = ExAgent.new(model: "test", instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")run/3 always returns {:ok, result} | {:error, reason} (it never raises). The
result map carries :output, :messages, :new_messages, :usage
(%{input_tokens:, output_tokens:}), :run_step and the (possibly updated)
:model.
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())deftool receives the RunContext as its first arg (named ctx by convention);
tool_plain takes only parameters. Each parameter is name :: Type, so the JSON
Schema is derived for you. A tool may return value, {:ok, value} or
{:error, reason}.
Structured output
Any embedded_schema becomes the output spec; JSON Schema is derived from the
schema and its changeset validations (validate_inclusion → enum,
validate_number → minimum/maximum, validate_length → minLength/
maxLength), then validated with the changeset, with retry-on-failure.
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])
|> Ecto.Changeset.validate_number(:temp_c, greater_than: -100, less_than: 100)
end
end
# → the model is told temp_c is a number in (-100, 100) and condition is one of
# the enum values, so it can comply instead of guessing and being retried.
agent = ExAgent.new(model: "anthropic:claude-3-5-haiku", output: WeatherReport)
{:ok, %{output: %WeatherReport{}}} = 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()ExAgent.run_stream/3 yields {:delta, binary} per chunk then {:result, map}. It is
text-focused and best suited to chat/streaming UIs; for a full agentic tool loop
use ExAgent.run/3.
Serialization / durable runs
The core is DB-free: it doesn't own a database or job queue. It provides best-effort message-history serialization so you can persist a conversation anywhere and resume it:
json = ExAgent.Message.to_json(result.messages) # store this
{:ok, history} = ExAgent.Message.from_json(json) # load it back
ExAgent.run(agent, "follow up", message_history: history)For crash-safe, resumable runs, wrap ExAgent.run/3 in an Oban job — see
examples/durable_oban.exs. Or use Layer 1's built-in store.
Layer 1 — a stateful, supervised agent
ExAgent.Server keeps an agent alive across runs: it preserves history,
accumulates usage, threads stateful models, and emits events.
{:ok, dm} =
ExAgent.AgentSupervisor.start_agent(
agent: ExAgent.new(model: "openai:gpt-4o", instructions: "You are a DM."),
agent_id: "dm",
pubsub: :local
)
{:ok, %{output: _}} = ExAgent.Server.chat(dm, "I enter the tavern.") # synchronous
{:ok, %{output: _}} = ExAgent.Server.chat(dm, "I pick the lock.") # sees prior turn
# Async: returns immediately, result arrives as a :run_finished event
{:ok, request_id} = ExAgent.Server.send_message(dm, "describe the room")
ExAgent.Server.abort(dm) # cancel the in-flight run (stays responsive)
ExAgent.Server.health(dm) # %{status: :idle, pending: 0}While a run is in flight, chat/3 returns {:error, :busy} and send_message/3
enqueues up to max_pending (default 8) then returns {:error, :queue_full}.
Layer 2 — snapshots & resume
Point a Server at a store and it checkpoints after every run and rehydrates on restart — surviving crashes:
ExAgent.AgentSupervisor.start_agent(
agent: agent_template,
agent_id: "dm",
store: :ets # ExAgent.Store behaviour; ETS ships by default
)The persisted ExAgent.Server.Snapshot carries only serializable state
(history + usage + metadata): never pids, secrets or tool closures. The live
model/tools come from the app-supplied template on restart. The default
ExAgent.Store.ETS is in-process; for durability across nodes, use
ExAgent.Store.Postgres (needs ecto_sql + postgrex):
ExAgent.Store.Postgres.migrate(MyApp.Repo) # once
ExAgent.AgentSupervisor.start_agent(
agent: agent_template, agent_id: "dm",
store: {ExAgent.Store.Postgres, MyApp.Repo}
)Layer 3 — multi-agent sessions
ExAgent.Session coordinates participants (agents or humans) taking turns over
a piece of shared state, through a pluggable TurnPolicy. The Session is the
single writer of shared_state.
alias ExAgent.Session
alias ExAgent.Session.Participant
{:ok, game} =
Session.start_link(
shared_state: %{log: []},
policy: {:initiative, order: ["rogue", "fighter", "wizard"]},
participants: [
Participant.new(id: "rogue", kind: :agent),
Participant.new(id: "fighter", kind: :human)
],
pubsub: :local
)
:ok = Session.start(game)
{:ok, world, next} =
Session.take_turn(game, "rogue", fn s -> {:ok, %{s | log: ["rogue acts" | s.log]}} end)
# `next` is now "fighter"; it sees the rogue's change via Session.read_state/1Tools inside an agent run read/propose state through an
ExAgent.Session.SharedState handle in RunContext.deps — never a mutable
reference. Policies: RoundRobin, Initiative (custom :order),
SupervisorPolicy (a coordinator alternates with workers).
Coordination
ExAgent.Coordination adds the classic orchestration patterns on top of a
Session (levels 2 & 3):
alias ExAgent.Coordination
# Delegation (agent-as-tool): the parent calls a sub-agent; both runs' tokens
# are counted together.
helper = ExAgent.new(model: "openai:gpt-4o-mini", instructions: "You summarize.")
parent =
ExAgent.new(
model: "openai:gpt-4o",
tools: [Coordination.delegation_tool(helper, name: "summarize")]
)
# Hand-off: transfer control between participants directly.
{:ok, "wizard"} = Coordination.handoff(session, "wizard")Robustness & safety
Long sessions and cost stay under control, all opt-in:
alias ExAgent.{Compaction, CostGuard, Permissions, UsageLimits}
# Summarize old turns once the context grows (capability hook).
compaction = %Compaction.Capability{
compactor: Compaction.Summary,
opts: [threshold_tokens: 6000, keep_recent: 8, summarize: &MyApp.summarize/1]
}
# Per-tool admission control (allow/ask/deny with globs).
perms = Permissions.new!(rules: [{"*", :deny}, {"read", :allow}, {"bash", :ask}])
agent =
ExAgent.new(
model: "anthropic:claude-3-5-haiku", # cache: true → prompt caching
capabilities: [compaction],
usage_limits: %UsageLimits{request_limit: 20, tool_calls_limit: 15, max_budget_cents: 25}
)
ExAgent.run(agent, "go",
permissions: perms,
approve: &MyApp.ask_human/1, # called on :ask
estimate_cost: CostGuard.estimator(%{input_per_1k_cents: 250, output_per_1k_cents: 1000})
)External tools (MCP)
Consume any Model Context Protocol server's
tools as plain ExAgent.Tools:
alias ExAgent.MCP.Client
{:ok, fs} =
Client.start_link(
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "./data"]
)
{:ok, tools} = Client.tools(fs) # [ExAgent.Tool.t(), ...]
agent = ExAgent.new(model: "anthropic:claude-3-5-haiku", tools: tools)The client owns the stdio JSON-RPC connection (handshake, tools/list,
tools/call, line buffering); transport exits and errors surface cleanly.
Events & PubSub
Every layer emits versioned ExAgent.Event envelopes (distinct from
:telemetry). Subscribe to drive a UI:
:ok = ExAgent.PubSub.subscribe({ExAgent.PubSub.Local, []}, ExAgent.Event.agent_topic("dm"))
receive do
{:exagent_event, %ExAgent.Event{type: :run_finished, payload: p}} ->
IO.puts("done: #{inspect(p)}")
endExAgent.PubSub is a behaviour: None (default, no-op), Local (Registry),
Phoenix (delegates to Phoenix.PubSub dynamically — no hard dependency), or
your own.
Models
Resolve from a string or pass a struct:
ExAgent.new(model: "openai:gpt-4o")
ExAgent.new(model: "openrouter:deepseek/deepseek-v4-flash") # one gateway, many backends
ExAgent.new(model: "anthropic:claude-3-5-haiku-20241022")
ExAgent.new(model: "zai:glm-4.5-air") # Z.AI's Anthropic-compatible endpoint (GLM)The loop is provider-agnostic and the parsers tolerate the malformed responses
real providers occasionally return (empty choices, content: null, partial
usage). Bring your own provider by implementing the ExAgent.Model behaviour.
Examples
examples/demo.exs— offline loop with the TestModel.examples/openrouter.exs— live tool-calling via OpenRouter.examples/structured_output.exs— live structured output via Ecto.examples/streaming.exs— live SSE streaming.examples/stateful_agent.exs— supervised stateful agent + events.examples/multi_agent_session.exs— two agents, round-robin, shared state.examples/dnd_session.exs— a mini D&D round: DM + bot + human over a shared world, coordinated by a Session (SupervisorPolicy), offline.
Run any of them with mix run examples/<name>.exs (live ones need an API key in
the environment).
Documentation
- Full module reference on hexdocs
DESIGN.md— architecture, principles and rationale.ROADMAP.md— development phases and progress.CHANGELOG.md— release history.
Contributing
Bug reports and pull requests are welcome on GitHub.
mix check # compile (warnings-as-errors) + format + test
MIX_ENV=test mix test # full test suite
mix run examples/demo.exs # offline smoke test (no API key)
The default test suite is fully offline (it uses the TestModel). Tests that need
a live provider are tagged :integration (opt in with --only integration);
Store.Postgres tests are tagged :postgres and auto-skip without a database.
License
Copyright (c) 2025 kukapu
Licensed under the MIT License — see LICENSE.