Agent Framework
View SourceAn agent is a TEA module where input comes from LLMs and tools instead of a keyboard. Same init/update/view loop, same OTP supervision, same crash isolation. The "user" is an AI model issuing commands and processing results.
For agent payment capabilities (wallets, spending controls, cross-chain transfers), see Agentic Commerce. For the interactive coding agent built on this framework, see Coding Agent. For the learning and recall layers an agent can opt into, see Self-Improvement and Memory.
Quick start
defmodule MyAgent do
use Raxol.Agent
def init(_ctx), do: %{findings: []}
def update({:agent_message, _from, {:analyze, file}}, model) do
{model, [shell("wc -l #{file}")]}
end
def update({:command_result, {:shell_result, %{output: out}}}, model) do
{%{model | findings: [out | model.findings]}, []}
end
end
{:ok, _} = Raxol.Agent.Session.start_link(app_module: MyAgent, id: :my_agent)
Raxol.Agent.Session.send_message(:my_agent, {:analyze, "lib/raxol.ex"})How it works
use Raxol.Agent
|
v
Agent.Session (GenServer)
|-- wraps Lifecycle with environment: :agent
|-- skips terminal driver and plugin manager
|-- registers in Agent.Registry for discovery
|
v
TEA cycle: init/1 -> update/2 -> view/1 (optional)
|
v
Commands: async/1, shell/1, send_agent/2use Raxol.Agent sets up the standard TEA callbacks (init/1, update/2, view/1, subscribe/1) with defaults, and injects three command helpers:
async(fun): async command with a sender callbackshell(command, opts \\ []): shell command via Portsend_agent(target_id, message): message another agent
All callbacks are overridable. view/1 defaults to nil, which means no rendering. Useful for headless agents that only process messages.
Agent session
Raxol.Agent.Session is the GenServer hosting a single agent. It wraps Lifecycle with environment: :agent, which skips the terminal driver and plugin manager.
# Start an agent
{:ok, _pid} = Raxol.Agent.Session.start_link(
id: :code_reviewer,
app_module: CodeReviewAgent
)
# Send a message (async, arrives as {:agent_message, from, payload} in
# update/2; from is the sender's id when attributed with the :from option,
# nil otherwise -- the framework never guesses a sender)
:ok = Raxol.Agent.Session.send_message(:code_reviewer, {:review, "lib/app.ex"})
# Read the agent's current model
{:ok, model} = Raxol.Agent.Session.get_model(:code_reviewer)
# Read the agent's rendered view tree
{:ok, tree} = Raxol.Agent.Session.get_view_tree(:code_reviewer)Agents auto-register in Raxol.Agent.Registry by their :id. If the agent is dead, lookups return {:error, :not_found}.
Communication
Raxol.Agent.Comm has three messaging primitives:
alias Raxol.Agent.Comm
# Fire and forget. Pass from: to identify yourself; without it the
# target sees from = nil (attribution is caller-asserted, not verified).
:ok = Comm.send(:target_agent, {:task, data}, from: :my_agent)
# Arrives in target's update/2 as {:agent_message, :my_agent, {:task, data}}
# Synchronous call with timeout. The target must answer with
# Comm.reply(caller, ref, response) from its update/2:
# def update({:agent_message, _from, {:call, caller, ref, q}}, model) do
# Comm.reply(caller, ref, answer(q, model))
# {model, []}
# end
{:ok, reply} = Comm.call(:target_agent, {:query, params}, 5_000)
# Broadcast to every agent in a team (delivery filtered by the receiving
# session's team_id)
:ok = Comm.broadcast_team(:my_team, {:status_update, status})
# Arrives in each teammate's update/2 as
# {:agent_message, nil, {:team_broadcast, :my_team, {:status_update, status}}}Teams
Raxol.Agent.Team is an OTP Supervisor for agent groups:
{:ok, _} = Raxol.Agent.Team.start_link(
team_id: :review_team,
coordinator: {ReviewCoordinator, [id: :coordinator]},
workers: [
{FileAnalyzer, [id: :analyzer_1]},
{FileAnalyzer, [id: :analyzer_2]}
],
strategy: :rest_for_one
)Coordinator starts first. With :rest_for_one, a coordinator crash restarts all workers. Workers crash independently.
Command types
Commands returned from update/2 are processed by Lifecycle:
| Command | Helper | Result in update/2 |
|---|---|---|
| Async | async(fn sender -> ... end) | {:command_result, {:async_result, value}} |
| Shell | shell("ls -la") | {:command_result, {:shell_result, %{output: ..., exit_status: ...}}} |
| Send Agent | send_agent(:target, msg) | Delivered to target as {:agent_message, from, msg} |
Headless agents
When view/1 returns nil (the default), no rendering happens. The agent is a pure message-processing loop, good for background workers, data pipelines, or agents that only talk to other agents.
AI backend streaming
Raxol.Agent.Backend.HTTP does real SSE streaming to LLM providers:
{:ok, stream} = Raxol.Agent.Backend.HTTP.stream(
[%{role: "user", content: "Explain OTP"}],
api_key: System.get_env("ANTHROPIC_API_KEY"),
provider: :anthropic,
model: "claude-sonnet-4-20250514"
)
# Stream elements:
# {:chunk, "text delta"}
# {:done, %{content: full_text, usage: %{...}}}
# {:error, "message"}Supports Anthropic, OpenAI, Ollama, Proton's Lumo, Kimi 2.5/moonshot, OpenRouter, Meituan's LongCat, and DeepSeek.
Provider is auto-detected from :base_url or set via :provider.
Without an explicit :provider, detection matches the :base_url: anthropic picks Anthropic, ollama (or the default Ollama port) picks Ollama, moonshot picks Kimi, and anything else is treated as OpenAI-compatible. The FREE_AI=true / AI_API_KEY backend switch is a convention of the example agents under examples/agents/, not the Backend.HTTP layer.
The :openrouter harness (via Backend.Selector) targets OpenRouter, an OpenAI-compatible aggregator. It attaches app-attribution headers (HTTP-Referer, X-OpenRouter-Title, X-OpenRouter-Categories) so Raxol's usage appears on openrouter.ai/rankings. Pass the key via ExecutorConfig auth: %{api_key: ...}.
The :longcat harness targets Meituan's LongCat (https://api.longcat.chat/openai, model LongCat-2.0), also OpenAI-compatible. It rides the :openai request/SSE path, which already handles LongCat's non-standard frames (a full message chunk instead of delta, the reasoning_content channel, and the underscore-less finishreason key). Pass the key via ExecutorConfig auth: %{api_key: ...}.
The :deepseek harness targets DeepSeek's OpenAI-compatible endpoint (https://api.deepseek.com, default model deepseek-v4-flash, key from DEEPSEEK_API_KEY). Its usage frames carry prompt_cache_hit_tokens / prompt_cache_miss_tokens, and Raxol.Agent.LlmPrices prices them exactly through its backend-scoped table (cache tier and UTC peak clock, ADR-0035); the rows are keyed to this harness, so the same model name via OpenRouter or LLM7 stays unpriced rather than borrowing the direct rate.
Turn driver
Raxol.Agent.Backend.HTTP streams one model call. Raxol.Agent.Turn drives a whole
self-improving turn: it assembles tool context from the agent module's callbacks, runs the
reasoning loop, records the turn to a conversation log, then fires the background side
effects.
{:ok, items} =
Raxol.Agent.Turn.run(MyAgent, "refactor lib/foo.ex",
backend: MyBackend,
log: log_server,
conversation_id: cid,
agent_id: "my-agent",
user_id: "user-123", # optional, with :user_model
user_model: MyApp.UserModel,
session_search: MyApp.SessionSearch
)build_context/2builds the tool context, each key present only when configured: memory (frommemory_providers/memory_provider), skills (skills_provider), user context, and session search.run/3runsStream.react/2with that context and records the stream into a Conversation Log.after_turn/4fires self-improvement, the user-model refresh, and session indexing.
The agent module declares which providers it wants through zero-arity callbacks; the caller supplies the running server instances through opts. Turn is the canonical driver other runtimes can adopt.
Native multi-vendor harness
An agent can run its own reasoning loop, or hand the loop to a vendor CLI (Claude Code,
Cursor) and expose Raxol's tools to it over MCP. Raxol.Agent.ExecutorConfig
(%{harness, model, auth, opts}) plus Raxol.Agent.Backend.Selector.select/1 map a harness
atom to a backend:
| Harness | Backend |
|---|---|
:anthropic, :openai, :kimi, :ollama, :lm_studio, :llm7, :longcat, :deepseek, :openrouter | Backend.HTTP |
:lumo | Backend.Lumo |
:claude_native | Backend.ClaudeCode |
:grok_native | Backend.GrokBuild |
:cursor | Backend.Cursor |
:mock | Backend.Mock |
Raxol.Agent.Backend.Catalog is the single declaration this table, the resolver's provider
registry and ExecutorConfig.backend() are all derived from (Catalog.ids/0 lists every
backend); see ADR-0034.
A native backend reports handles_tools_internally?/0 as true, which tells the framework
not to drive the reasoning loop: the CLI runs its own loop and calls Raxol's tools through an
injected MCP server (Raxol.Agent.Harness.McpToolConfig writes the --mcp-config). The
:codex harness is reserved (it speaks a stateful app-server protocol served by
Raxol.Symphony.Runners.Codex, not an agent backend), so select/1 returns
{:error, {:backend_not_implemented, :codex}} for it.
Authorization (ALLOW/ASK/DENY)
Raxol.Agent.Authorization is a three-way policy engine, richer than the deny-only
PermissionHook. It is what mix raxol.code gates every mutating tool on.
Engineis a pure reducer over a list of policies. It folds withreduce_while: a DENY short-circuits, an ALLOW merges whitelisted label writes, an ASK escrows writes and accumulates a prompt. Final precedence is deny > ask > allow.Policyis a data struct (phases,conditions,writable_labels,scope). Scope is:once,:session, or:root; a remembered ASK auto-allows within its scope, so "approve once covers the tree" works.Serveris a per-workflow GenServer holding the policies and pending ASKs;Hookcomposes the engine into theCommandHookchain at the:tool_callphase, resolving an ASK through a synchronous prompter.
Conversation item-log
Raxol.Agent.Conversation is a durable, append-only record of what an agent did, separate
from its compacted working memory.
Itemis an immutable typed entry (message, tool_call, tool_result, reasoning, error, and more) with a stable id"<conversation_id>:<seq>"and a monotonic, store-assigned seq.Storeis a behaviour with cursor pagination (:after/:before/:limit/:order/:type);Store.ETSis the shipped adapter (anordered_setkeyed{conversation_id, seq}). Append is the only writer.Logis a GenServer that wraps a store (durability) with in-process subscriber fan-out (liveness) and no replay buffer.subscribe/3returns the snapshot and registers the subscriber in one serialized call, so the snapshot and the live tail partition exactly: every item once, no gap, no duplicate. Reconnect with an:aftercursor.RecorderbridgesStreamevents into items (tool_use to tool_call, done to message, and so on). Session search indexes this log.
Tunnel (reverse co-drive)
Raxol.Agent.Tunnel lets a teammate attach to an agent running on your machine over a single
outbound link, without your files or credentials leaving it. The host dials out to a server;
many logical channels multiplex over the one link; when a peer opens a channel, its frames
tunnel to the host, which spawns the channel's handler locally.
Framehas four kinds::hello(host identity, once),:open,:data(base64 when binary),:close. Kinds are decoded through a whitelist, neverString.to_atomon link input.Tunnelis the endpoint GenServer (role: :hostor:server), transport-agnostic: outbound frames go through asend_fun, inbound bytes arrive as{:tunnel_recv, binary}.Tunnel.Link.connect/2wires two endpoints in-process for tests and same-node co-driving. The cross-machine transport (a WebSocket host and server) is a drop-in doing the same two things.
Examples
FREE_AI=true mix run examples/agents/zero_system.exs # ZERO System cockpit w/ live LLM reasoning
# framework primitives run from the package (they need Raxol.Agent):
cd packages/raxol_agent && mix run examples/agents/react_agent.exs # Actions + ReAct + tools + shell
cd packages/raxol_agent && mix run examples/agents/agent_team.exs # coordinator + workers