ExAgent (ExAgent v2.2.1)
View SourceExAgent 2.2.1 is available on Hex. When upgrading from 1.x, read the migration guide for the runtime, model, event and snapshot changes. See supported features and limits for deployment requirements.
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). - Skills loaded on demand — the model sees a catalog of
SKILL.mdinstructions (the Agent Skills format shared with other agent harnesses) and loads one when a task needs it; skills can unlock their own tools. - 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 — a lazy, demand-driven view of the same tool/output loop, with provisional deltas and one complete result or partial failure.
- Supervised stateful agents — keep history, accumulate usage, thread stateful models across runs, and emit versioned events over PubSub (LiveView-ready).
- Opt-in checkpoints — confirm complete state through a Store and restore a conversation on restart. ETS is in-process; Postgres uses your DB/repo. This does not replay or roll back arbitrary in-flight effects.
- Multi-agent sessions — coordinated turns over shared state with pluggable
turn policies (
round_robin,initiative, or your own). - Orchestration — scoped delegation with ancestor budgets/permissions and hand-off between session participants, durable sequences, routing and bounded parallel flows.
- Persisted human approval — pause at an admitted boundary and resume using versioned continuation records, explicit authority and recovery rules.
- Robustness & safety — context compaction, usage/cost limits, and per-tool
permissions (
allow/ask/deny). - Model-agnostic — one stock ReqLLM backend with explicitly qualified profiles,
custom specs/gateways and the public
ExAgent.Modelextension behaviour. - External tools (MCP) — consume stdio or opt-in Streamable HTTP tools with caller-owned timeouts, pending limits and transport cleanup.
- Observable —
:telemetry, app-levelExAgent.Eventenvelopes and opt-in native OpenTelemetry tracing and metrics. ExAgent owns its execution spans; the ReqLLM bridge supplies metadata without duplicate model spans. Content is disabled by default; the application owns its SDK and exporter. - Offline-first testing — a deterministic
ExAgent.Models.Testmodel drives the full loop with no API key and no network.
Requirements
- Elixir 1.18+ (required by ReqLLM's mandatory
llm_dbdependency) - Runtime targets: Elixir 1.18 / OTP 28 and Elixir 1.20 / OTP 29
See supported features and limits for runtime and dependency requirements. These targets do not cover every patch release or dependency combination.
ReqLLM starts its own supervisor and Finch pool alongside ExAgent. Its default
startup loads .env in the host's working directory. Applications that manage
credentials themselves should set config :req_llm, load_dotenv: false before
startup. ExAgent.Models.ReqLLM.new/1 is the general backend; custom Model/Test
remain extension points. Tools, Ecto-tool output and streaming use an explicit
Chat profile: :chat_tools_v1, or :openrouter_chat_tools_v1 for OpenRouter
provider routing. Native JSON Schema uses a separate opt-in profile. Catalogue
resolution alone does not enable those capabilities. See
models and limits for credentials, profiles,
routing, normalized usage and host retention boundaries.
Installation
Add the published package to your application:
def deps do
[{:exagent, "~> 2.2.1"}]
endTo develop against a source checkout, replace that dependency with
{:exagent, path: "../exAgent"} and adjust the path to your clone.
The 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, "~> 2.2"}
])
agent = ExAgent.new(model: "test", instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")Resolve a stock catalogue string or tuple with explicit instance credentials:
{:ok, model} = ExAgent.Model.resolve({:openai, id: "gpt-4o"},
api_key: System.fetch_env!("OPENAI_API_KEY"))
agent = ExAgent.new(model: model, instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")
Summary
Functions
Build an agent from options. Use :output for the output spec.
Resume a persisted approval from an explicitly supplied trusted agent and configuration.
Open a new lazy stream for a persisted continuation; the earlier paused stream stays terminal.
Run the agent against prompt, returning {:ok, result} or {:error, _}.
Run synchronously and return the output value directly, raising on error.
Run an auxiliary/delegated agent under an existing run's execution scope.
Run the agent, returning a lazy stream of events as the model generates.
Types
@type output_type() :: :text | module()
@type prompt() :: String.t() | [ExAgent.Message.Part.User.content_part()]
@type result() :: %{ output: term(), messages: [ExAgent.Message.t()], new_messages: [ExAgent.Message.t()], usage: ExAgent.Message.Usage.t(), run_step: non_neg_integer(), model: ExAgent.Model.model(), run_id: String.t(), root_run_id: String.t(), parent_run_id: String.t() | nil, model_request_id: String.t() | nil, request_count: non_neg_integer(), tool_calls: non_neg_integer(), cost_cents: number() | nil, cost_status: :known | :unknown, status: :succeeded | :failed | :cancelled | :running, usage_status: :complete | :partial, pending_response: ExAgent.Message.Response.t() | nil }
@type t() :: %ExAgent{ capabilities: [module() | struct()], instructions: [ExAgent.Message.Part.System.t()], max_history_bytes: pos_integer(), max_payload_bytes: pos_integer(), max_steps: pos_integer(), model: ExAgent.Model.model(), name: String.t() | nil, observability: ExAgent.Observability.OpenTelemetry.t() | nil, on_max_steps: :error | :final, output_mode: :tool | :native, output_retries: non_neg_integer(), output_type: output_type(), settings: ExAgent.ModelSettings.t(), tool_timeout: pos_integer(), tools: [ExAgent.Tool.t()], usage_limits: ExAgent.UsageLimits.t() | nil }
Functions
Build an agent from options. Use :output for the output spec.
Ecto output defaults to output_mode: :tool. Explicit :native sends a
separate JSON schema to a model declaring native support; the actual changeset
still validates the final object. There is no automatic fallback between modes.
Valid tool schemas are prepared once on the reusable definition. Invalid
schemas still fail as operational RunErrors when running the agent; each
run also checks that a prepared validator matches the tool's current schema.
Retention defaults are max_payload_bytes: 1_048_576 and
max_history_bytes: 8_388_608, configurable from1 through64MiB. They measure
uncompressed Erlang external term size after decode, not live RAM. A tool may
already have performed its effect when its returned payload exceeds a limit:
its outcome/identity are preserved with an explicit payload_omitted marker
and a terminal RunError. Such history cannot be executed again automatically.
:skills takes a list of ExAgent.Skills that the model loads on demand. It
adds a load_skill tool (plus read_skill_file and the skills' gated tools),
an ExAgent.Skills.Gate before :capabilities when a skill has tools, and
ExAgent.Skills.Restore after them; see ExAgent.Skills. Invalid skills or
clashing tool names raise ArgumentError.
:on_max_steps chooses what the last allowed step does. :error (default)
keeps the previous behaviour. :final asks the model for its final answer in
step max_steps: function tools stay declared but cannot be called, and a
request-only instruction says the step limit was reached. Function calls in
that step are not executed and the run fails with :max_steps_exceeded.
Resume a persisted approval from an explicitly supplied trusted agent and configuration.
Open a new lazy stream for a persisted continuation; the earlier paused stream stays terminal.
Run the agent against prompt, returning {:ok, result} or {:error, _}.
prompt is a string or a non-empty list of content parts (text, images and
documents; see ExAgent.Message.Part.User). An invalid part fails with
{:invalid_prompt_part, index} and an empty list or other value with
:invalid_prompt, before any model request.
Operational errors contain an ExAgent.RunError: its reason is the cause
and partial preserves history, usage and the last confirmed model. Retrying
the prompt is a new execution and can repeat effects.
Options:
:deps— dependency value threaded intoRunContext.:message_history— priorMessage.t()list to continue from.:max_payload_bytes,:max_history_bytes— finite overrides of the definition's postdecode retention bounds. Results expose:retentionmeasurements of history and public data, excluding live model/configuration.:model_settings— per-run settings overrides.:stream_text— stream model text through the same agent loop.:estimate_cost—(usage -> cents)for a homogeneous model tree, or(model, usage -> cents)for model-aware pricing; nil/:unknownremains unknown. Monetary limits require a valid estimator before model admission.:deadline— optional absolute monotonic milliseconds; inherited descendants may only shorten it. Admission and native IO timeouts honor it, but arbitrary application callbacks are not preempted.:max_concurrent_requests— optional positive cap, shared with descendants; saturated admission fails explicitly without adding a waiting queue.:on_progress— optional runtime callback receiving full result-shaped snapshots; these contain a live model and must not be exported as JSON. While a batch is in flight, unresolved calls havestatus: :unknownin these snapshots; final history replaces them with the collected outcomes.:observability— optionalExAgent.Observability.OpenTelemetryconfig; overrides the agent default.falsedisables instrumentation for this run.:trace_context— ephemeral context captured with the OTel adapter; use for application-owned process boundaries, never persisted snapshots.
Run synchronously and return the output value directly, raising on error.
Run an auxiliary/delegated agent under an existing run's execution scope.
Accepts a RunContext or the state passed to a model capability. The helper
inherits dependencies and fixes ancestry after caller options; a builder cannot
replace the inherited scope. Child limits/policies add restrictions to all
ancestors. Child progress is not forwarded as parent conversation history.
@spec run_stream(t(), prompt(), keyword()) :: Enumerable.t()
Run the agent, returning a lazy stream of events as the model generates.
This is the streaming variant. It yields:
{:delta, binary}— incremental output text (one per model text chunk),{:result, map}— the final result once the stream completes.
On failure it yields one {:error, %ExAgent.RunError{}} terminal. Deltas
are provisional text from all model requests; the final output is validated
by the same tools/output/hooks/limits loop as run/3.
Each enumeration starts a new run. Halting enumeration or killing its owner cancels the owned worker; effects already performed are not rolled back. A suspended Enumerable continuation must be resumed or explicitly halted.