Legion (Legion v0.6.0)
View SourceLegion is an Elixir runtime for AI agents that live inside your application and get things done by writing code.
You give an agent some of your modules as tools. When it gets a task, the agent reads the tools' source, writes a Lua script, runs it in a sandbox, looks at the result and keeps going until the task is done. One step can loop and branch over your data instead of making an LLM round trip per tool call. Anthropic on why code execution beats tool calling.
You can run an agent two ways:
- In your app. Your code hands the agent a task, and Legion calls the LLM, runs the code it writes and gives you the answer.
- Over MCP. Claude Code, Cursor or another MCP client connects to your app. Its model writes the code and Legion runs it in the agent's sandbox, so your app needs no API key.
Both share your tools, the Lua sandbox, persistence and rate limits.
Legion isn't a coding agent - it won't edit your codebase. Use Claude Code or Codex for that.
Installation
# mix.exs
{:legion, "~> 0.6"}
# lib/my_app/application.ex - after your Repo, if you have one
children = [MyApp.Repo, Legion]
# config/runtime.exs - the default model is openai:gpt-5.6-luna
config :req_llm, openai_api_key: System.get_env("OPENAI_API_KEY")Usage
In your app
Wrap the code you want the agent to use in a tool:
defmodule MyApp.Tools.OrdersTool do
use Legion.Tool
@doc "The signed-in customer's orders, newest first"
def my_orders do
for order <- MyApp.Orders.list(Vault.get(:current_user)) do
%{id: order.id, items: Enum.map(order.items, & &1.name)}
end
end
@doc "Carrier tracking status of one of the customer's orders"
def track(order_id), do: MyApp.Orders.tracking(Vault.get(:current_user), order_id)
endGive the tool to an agent. The agent's @moduledoc is its job description:
defmodule MyApp.SupportAgent do
@moduledoc "Helps a signed-in customer with their orders."
use Legion.Agent
def tools, do: [MyApp.Tools.OrdersTool]
endRun it for the customer who's asking. Your tools find them in Vault, which comes with Legion:
Vault.init(current_user: user)
Legion.execute(MyApp.SupportAgent, "Where's the hoodie I ordered last week?")
#=> {:ok, "It shipped on Monday and should arrive tomorrow."}To answer, the agent read the tool's source, then wrote and ran this script:
for _, order in ipairs(OrdersTool.my_orders()) do
for _, item in ipairs(order.items) do
if string.find(string.lower(item), "hoodie") then
return OrdersTool.track(order.id)
end
end
end
return "no hoodie in recent orders"Over MCP
Serve the same agent to MCP clients. MCP support needs the optional
anubis_mcp dependency next to Legion:
# mix.exs
{:anubis_mcp, "~> 2.0"}
# lib/my_app/mcp.ex
defmodule MyApp.MCP do
use Legion.MCP.Server, agent: MyApp.SupportAgent, name: "my_app", version: "1.0.0"
end
# lib/my_app/application.ex - after Legion
{MyApp.MCP, transport: {:streamable_http, start: true}}
# lib/my_app_web/router.ex - outside any pipeline
forward "/mcp", Legion.MCP.Plug, server: MyApp.MCPRun mix phx.server and add http://localhost:4000/mcp to your client as an HTTP server. Its model gets two tools: help to read your tools' source, and repl to run Lua in the agent's sandbox.
Before you expose it, add authorization. Until then, tools see no user and anyone who reaches the endpoint can run code in your sandbox. The MCP guide shows how.
Features
- Tools. Any module with
use Legion.Toolis a tool. The agent reads its source and docs, so there's no schema to keep in sync. Anything public is callable and its result can reach the LLM, so keep each tool a small facade that returns plain maps. SeeLegion.Tool. - Sandbox. Agents write Lua that runs on a Lua VM written in pure Elixir, each evaluation in its own process with time and memory limits. Your tools are the only way out. In-app agents can write Elixir instead, which gives them a richer standard library but runs against an allowlist that's much harder to make airtight. A Popcorn sandbox that runs in the browser is on the way. See the Sandboxes guide.
- Authorization. Tools read the current user from Vault and authorize each call themselves, as a controller would. Generated code can't read or change Vault, so it can't act as someone else or get at credentials kept there. It still picks the arguments, so check them:
track(order_id)above passes the user along soMyApp.Orders.tracking/2can refuse someone else's order. - Long-lived agents.
Legion.start_link/2starts an agent that keeps its conversation across calls toLegion.call/2. Agents are plain processes, so you can supervise them like any other. - Sub-agents. With
Legion.Tools.AgentTool, an agent's code hands subtasks to other agents, one at a time or in parallel. To split the work in your own code rather than leave it to the LLM,Legion.parallel/2runs several agents at once andLegion.pipeline/1feeds each agent's answer to the next. - Persistence. Conversations can be saved in Postgres through your Ecto repo, and
Legion.resume/2brings an agent back after a crash or deploy. SeeLegion.Store.Postgres. - Rate limiting. Each user, IP or tenant gets caps on agents, tokens and code evaluations per time window, and on concurrent turns. A turn is checked before it runs, so a denied one never reaches the LLM. See
Legion.RateLimiter. - Observability. Legion emits telemetry events and ships a default logger. legion_web is a LiveView dashboard where you can follow every conversation step by step.
Configuration
Defaults for all agents go in your config, and each agent can override them in config/0:
# config/config.exs
config :legion, :config, %{model: "openai:gpt-5.6-luna", max_iterations: 10}
# in an agent module
def config, do: %{model: "google:gemini-3.5-flash", max_iterations: 5}model is a provider:model string, and any ReqLLM provider works, local models included. See Legion.Agent for the other options and callbacks, like system_prompt/0 and output_schema/0 (structured output).
Guides
Summary
Functions
Sends a message to a running agent and waits for the result.
Sends a message to a running agent without waiting for a result.
Runs code in a running agent's sandbox and returns what the agent's model
would read: the result, or the error.
Runs an agent on a single task and returns the result.
Returns the valid UTF-8 string id of a running agent. Always set - Legion generates one when none is passed.
Returns the conversation history from a running agent.
Looks up the live process for an agent id.
Runs multiple agent tasks concurrently and collects results.
Runs agent tasks sequentially, threading each result to the next step.
Recovers an interrupted persisted run and waits for it to finish.
Resumes a persisted conversation.
Whether pid - typically one returned by lookup/1 - is alive.
Accepts nil and returns false.
Starts Legion's supervisor.
Starts a long-lived agent process.
Chains an agent task after a previous result.
Functions
Sends a message to a running agent and waits for the result.
Examples
{:ok, pid} = Legion.start_link(AssistantAgent)
{:ok, answer} = Legion.call(pid, "What is the capital of France?")
{:ok, follow_up} = Legion.call(pid, "And its population?")
Sends a message to a running agent without waiting for a result.
Examples
{:ok, pid} = Legion.start_link(ReportAgent)
Legion.cast(pid, "Generate the weekly report and email it")
Runs code in a running agent's sandbox and returns what the agent's model
would read: the result, or the error.
This is the agent without its model. Nothing is sent to an LLM: the code is
taken as the agent's own step and evaluated with its tools, sandbox limits,
eval guard and variables, then appended to the conversation and persisted
like a turn. It is what Legion.MCP.Server runs for a host's model, and what a
console or a test can use to drive an agent by hand. Variables persist
between calls unless :binding_scope is :iteration, and the model's next
turn sees them whatever the scope.
Returns {:ok, text} with the formatted result, {:error, text} when the
code failed to check, was refused or raised, or when the step could not be
saved to the store after it ran, and {:cancel, {:rate_limited, violations}}
when a rate limit denied the call before it ran.
An agent whose action_types/0 allow neither "eval_and_continue" nor
"eval_and_complete" runs no code, so it refuses every call with
{:error, text}, before the rate limit and without saving a step. So is
code that is not valid UTF-8 or longer than the agent's
:max_message_length, since it would be kept in the conversation.
Options
:vault- a keyword list put in the agent process'sVaultfor this call only, for tools to read; the per-call form of the:vaultoption ofstart_link/2. The agent's vault is restored after the call, and the keys Legion sets itself (:agent_id,:parent_agent_id,:agent_module,:sandbox,:store,:rate_limit,:sub_agents) are ignored:require_agent- an agent module. If the agent was started from a different module, the call returns{:error, text}and nothing runs or is saved:require_sandbox- a sandbox module. If the agent runs a different sandbox, the call is refused the same way:exclude_tools- tool modules left out for this call only, or a function given each of the agent's tools that returnstruefor those to leave out: the code cannot call them andHelpdoes not list them:timeout- how long to wait for the call (default::infinity)
Examples
{:ok, pid} = Legion.start_link(MathAgent)
{:ok, text} = Legion.eval(pid, "x = MathTool.add(1, 2)")
{:ok, text} = Legion.eval(pid, "return x * 2")
{:error, text} = Legion.eval(pid, "return (")
{:ok, text} = Legion.eval(pid, "return Reports.mine()", vault: [current_user: user])
Runs an agent on a single task and returns the result.
Starts a temporary agent process, blocks until the task completes, then stops it.
Accepts the same opts as start_link/2, so a one-off run can resume and
persist a conversation by passing :store and :agent_id. Passing :store
overrides the globally configured store for this agent; see Legion.Store.
When a live process already owns the given :agent_id, the task is sent to
that process instead and it is left running afterwards.
Examples
{:ok, summary} = Legion.execute(ResearchAgent, "Summarize the Elixir getting started guide")
{:cancel, :reached_max_iterations} = Legion.execute(ResearchAgent, "impossible task")
{:ok, reply} = Legion.execute(ChatAgent, "next question", store: MyApp.AgentStore, agent_id: "user_42:chat_7")
Returns the valid UTF-8 string id of a running agent. Always set - Legion generates one when none is passed.
When a store is configured but you let Legion generate the id, capture it with this to resume the same conversation on a later start.
Examples
{:ok, pid} = Legion.start_link(ChatAgent)
agent_id = Legion.get_agent_id(pid)
Returns the conversation history from a running agent.
Examples
{:ok, pid} = Legion.start_link(AssistantAgent)
{:ok, _} = Legion.call(pid, "Hello")
messages = Legion.get_messages(pid)
Looks up the live process for an agent id.
agent_id must be a valid UTF-8 string. Raises ArgumentError otherwise.
Uses Legion's cluster-wide runtime index as the source of truth for the
agent_id -> pid mapping. Returns {:ok, pid} when the agent is currently
registered on any connected node, or :error when no live process owns
agent_id.
Examples
{:ok, pid} = Legion.lookup("user_42:chat_7")
:error = Legion.lookup("missing_agent_id")
Runs multiple agent tasks concurrently and collects results.
Returns {:ok, results} if all succeed, or the first {:cancel, reason}.
Examples
# Run two agents in parallel
{:ok, [research, analysis]} =
Legion.parallel([
{ResearchAgent, "Find recent Elixir blog posts"},
{AnalysisAgent, "Summarize market trends"}
])
# With a timeout (in milliseconds)
{:ok, results} =
Legion.parallel(
[{FastAgent, "task 1"}, {FastAgent, "task 2"}],
30_000
)
Runs agent tasks sequentially, threading each result to the next step.
Each step is {agent, task} where task is a string or a function
that receives the previous result and returns a task string.
Halts early if any step returns {:cancel, reason}.
Examples
# Static tasks — each runs independently
{:ok, final} =
Legion.pipeline([
{ResearchAgent, "Find info about Elixir OTP"},
{WriterAgent, "Write a blog post about OTP"}
])
# Thread results — each step receives the previous result
{:ok, post} =
Legion.pipeline([
{ResearchAgent, "Find recent Elixir news"},
{WriterAgent, fn research -> "Write a summary based on: #{research}" end},
{EditorAgent, fn draft -> "Polish this draft: #{draft}" end}
])
Recovers an interrupted persisted run and waits for it to finish.
agent_id must be a valid UTF-8 string. Raises ArgumentError otherwise.
An interrupted run is signaled by a stored payload with status: :running. In addition
only runs without parent_agent_id are recoverable, since sub-agents are not restarted. Unlike
resume/2, a live agent is not returned.
The agent is only driven to completion, the executor result is not returned. :ok means
only that the temporary agent process stopped normally.
Pass :store or configure one globally. Other options are passed through to
start_link/2. Rate-limit rules are not persisted and the recovered run only
finishes the interrupted turn before stopping, so it is never rate-limited
and :rate_limit defaults to [rules: []]. Returns:
:okwhen the temporary process stops normally{:error, reason}when the temporary process stops abnormally{:error, :already_running}when a recoverable run already has a live process{:error, :not_recoverable}when there is no stored payload with an agent module, or when the stored payload is not an interrupted run
Raises when no store is available.
Examples
:ok = Legion.recover("user_42:chat_7", store: MyApp.AgentStore)
{:error, :already_running} =
Legion.recover("active_chat", store: MyApp.AgentStore)
{:error, :not_recoverable} =
Legion.recover("completed_chat", store: MyApp.AgentStore)
Resumes a persisted conversation.
agent_id must be a valid UTF-8 string. Raises ArgumentError otherwise.
Loads the persisted conversation with the store's Legion.Store.get/1 to
determine its agent module,
then atomically starts it under the same agent_id. If a live process already
owns that ID, returns the existing pid instead. A new process restores the
conversation and, when it stopped mid-turn, finishes that turn in the
background: from a saved checkpoint when one exists, or from a prompt with
nothing after it. Otherwise it waits for the next message.
opts are passed through to start_link/2. Rate-limit rules are not
persisted, so the resumed agent is checked on its later turns only when
:rate_limit is passed here again; otherwise it runs without rate limiting.
The vault is not persisted either, so pass :vault again.
Pass :store or configure one globally. Returns:
{:ok, pid}when a new process starts or one already ownsagent_id{:error, :not_resumable}when the store has no payload containing an agent module foragent_id{:error, reason}when the process cannot start
Raises when no store is available.
Examples
{:ok, pid} = Legion.resume("user_42:chat_7")
{:ok, pid} = Legion.resume("user_42:chat_7", store: MyApp.AgentStore)
{:ok, pid} = Legion.resume("user_42:chat_7", vault: [current_user: user])
{:error, :not_resumable} =
Legion.resume("missing_chat", store: MyApp.AgentStore)
Whether pid - typically one returned by lookup/1 - is alive.
Accepts nil and returns false.
Checks local processes directly and processes on connected nodes through RPC.
An unreachable remote node returns false.
Examples
case Legion.lookup("user_42:chat_7") do
{:ok, pid} -> Legion.running?(pid)
:error -> false
end
Starts Legion's supervisor.
Add it to your application's supervision tree after dependencies required by its configured recovery stores. For a Repo-backed store, place it after your Repo.
Examples
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
MyApp.Repo,
Legion
]
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
Starts a long-lived agent process.
The agent is linked to the caller, so one started from a LiveView dies with
it. To keep one running, start it under a DynamicSupervisor:
DynamicSupervisor.start_child(MyApp.AgentSupervisor, {ChatAgent, vault: [current_user: user]})A supervised agent doesn't share the caller's Vault, so pass what its tools
need with :vault.
Options
:store,:agent_id- persist the conversation across restarts; seeLegion.Store. When supplied,:agent_idmust be a valid UTF-8 string. A store set globally withconfig :legion, :store, MyApp.AgentStoreapplies to every agent, so you need only pass:agent_id. If a store is in effect but no:agent_idis given, Legion generates one - read it back withget_agent_id/1. An agent ID can belong to at most one live process across connected nodes, and to one agent module: starting another module under an ID the store holds a conversation for returns{:error, {:agent_module_mismatch, stored}}.:rate_limit- a keyword list with:limiterand:rules, a list ofLegion.RateLimiter.Rules pairing an identity (for example,%{"ip" => "203.0.113.42"}) with a policy, checked for every turn by aLegion.RateLimiter, and for:max_agentswhen the agent starts. The limiter and a default policy can be set globally; rules are given here. Rules without a limiter raise, and a limiter without rules runs the agent without rate limiting and logs a warning unlessrules: []opts out on purpose. A denied start returns{:error, {:rate_limited, violations}}and leaves no process and nothing saved; a denied turn returns{:cancel, {:rate_limited, violations}}. SeeLegion.RateLimiter.:vault- a keyword list put in the agent process'sVault, where its tools and sandbox read it withVault.get/1. The way to hand an agent something request-specific, a current user say, when it is not started from the process that holds it.:idle_timeout- milliseconds without a call after which the agent stops normally. With a store, its state is on record and a later start under the same:agent_idcontinues it (default::infinity).- Any config overrides (
:model,:max_iterations, etc.)
Examples
{:ok, pid} = Legion.start_link(AssistantAgent)
{:ok, pid} = Legion.start_link(ChatAgent, store: MyApp.AgentStore, agent_id: "user_42:chat_7")
{:ok, pid} = Legion.start_link(ChatAgent, agent_id: "user_42:chat_7") # store from app config
{:ok, pid} =
Legion.start_link(ChatAgent,
rate_limit: [
limiter: MyApp.RateLimiter,
rules: [
%Legion.RateLimiter.Rule{
identity: %{"ip" => "203.0.113.42"},
policy: %Legion.RateLimiter.Policy{
window_ms: :timer.minutes(1),
max_agents: 10,
max_tokens: 100_000
}
}
]
]
)
Chains an agent task after a previous result.
Useful for piping from parallel/2 or pipeline/1.
Examples
# Chain after parallel
Legion.parallel([
{ResearchAgent, "Find Elixir news"},
{ResearchAgent, "Find Erlang news"}
])
|> Legion.then(WriterAgent, fn results ->
"Summarize these findings: #{inspect(results)}"
end)
# Passes through cancellations
{:cancel, reason} |> Legion.then(WriterAgent, fn _ -> "ignored" end)
#=> {:cancel, reason}
