Legion (Legion v0.5.0)

View Source

Legion is an Elixir runtime for AI agents that live inside your application and get things done by writing code.

Define an agent's responsibilities, give it tools to interact with your app safely, and hand it a task from one of your users. It will read the source of the modules you expose, write a Lua (or Elixir) snippet, run it in a sandbox, look at the result, and write the next one - until the task is done.

One evaluation can filter, branch, and loop - work that would cost a tool-calling agent an LLM round trip per step. Anthropic on why code execution beats tool calling.

Usage

  1. Expose existing or new modules as tools and hand them to an agent:
defmodule MyApp.Tools.ScraperTool do
  use Legion.Tool

  @doc "Fetches recent posts from HackerNews"
  def fetch_posts do
    Req.get!("https://hn.algolia.com/api/v1/search_by_date").body["hits"]
  end
end

defmodule MyApp.Tools.DatabaseTool do
  use Legion.Tool

  @doc "Saves a post title to the database"
  def insert_post(title), do: Repo.insert!(%Post{title: title})
end

defmodule MyApp.ResearchAgent do
  @moduledoc "Fetch posts, evaluate their relevance and quality, and save the good ones."
  use Legion.Agent

  def tools, do: [MyApp.Tools.ScraperTool, MyApp.Tools.DatabaseTool]
end
  1. Run it!
Legion.execute(MyApp.ResearchAgent, "Find cool Elixir posts about Advent of Code and save them")
#=> {:ok, "Found 3 relevant posts and saved 2 that met quality criteria."}

To solve this task, the agent wrote and ran:

local posts = ScraperTool.fetch_posts()
local relevant = {}
for _, post in ipairs(posts) do
  local title = string.lower(post.title or "")
  if title:find("elixir") and (title:find("advent") or title:find("aoc")) then
    table.insert(relevant, post)
  end
end
return relevant

...looked at the output, judged which posts were worth keeping, and followed up with:

local titles = {"Elixir Advent of Code 2024 - Day 5 walkthrough", "My first AoC in Elixir!"}
for _, title in ipairs(titles) do
  DatabaseTool.insert_post(title)
end

Two evaluations, with variables, loops, and conditionals available at every step.

Agents write Lua by default, since it's much easier to sandbox securely. Prefer Elixir? Switch to Legion.Sandbox.Elixir - see Generated code runs in a sandbox.

See the Installation guide for more details.

Features

1. Tools are plain Elixir modules

use Legion.Tool on any module and the LLM reads its source and calls its public functions:

defmodule MyApp.Tools.WeatherTool do
  use Legion.Tool

  @doc "Returns the current temperature in Celsius for a city"
  def temperature(city) do
    Req.get!("https://wttr.in/#{city}?format=j1").body["current_condition"]
    |> hd()
    |> Map.fetch!("temp_C")
    |> String.to_integer()
  end
end

defmodule MyApp.WeatherAgent do
  @moduledoc "Answers questions about the current weather."
  use Legion.Agent

  def tools, do: [MyApp.Tools.WeatherTool]
end

Use it to hand agents your existing app logic directly. With great power comes great responsibility (and authorization): the agent can call any public function of a tool, so scope tools to what it should touch and gate the sensitive parts with Vault (see Credentials never reach the LLM). For large modules you could write a thin facade with defdelegate and a description/0 instead of exposing the full source. If for any reason the source isn't what the LLM should see, define description/0 on the tool and it is sent verbatim instead.

See Legion.Tool for more details.

2. Agents are BEAM processes

Start one, keep it around, and message it like a GenServer.

{:ok, pid} = Legion.start_link(MyApp.AssistantAgent)

{:ok, response} = Legion.call(pid, "Find laptops under $2000")
{:ok, response} = Legion.call(pid, "Now filter for 16GB of RAM")
Legion.cast(pid, "Also check the reviews")

Use it when a conversation spans multiple messages - variables can persist between turns with binding_scope: :conversation. And since agents are just processes, supervision trees and :pg-based pools work out of the box.

See start_link/2, call/3, and cast/2 for more details.

3. Generated code runs in a sandbox

Every evaluation runs in a monitored process with timeout, memory, and CPU budgets. Two sandboxes ship with Legion today, differing in language and trust model, with a third on the way:

  • Legion.Sandbox.Lua (default) - agents write Lua, evaluated by lua, a Lua 5.3 VM in pure Elixir. Lua code cannot reach the host BEAM at all - the only bridges out are the tool functions Legion registers - making it the safer choice for less trusted generation. Tool arguments and results are converted at the boundary (Lua tables to maps/lists and back; Elixir tuples become arrays, atoms become strings).
  • Legion.Sandbox.Elixir - agents write Elixir. Dangerous constructs (defmodule, import, spawn, send, apply, ...) are blocked at the AST level and module access is allowlisted (stdlib + your tools). Powerful, but the allowlist guards an enormous language surface - use it for your own LLM-backed agents with controlled tool access, not arbitrary code from unknown sources.
  • Popcorn (coming soon) - agents write Elixir that runs in the user's browser on popcorn, an AtomVM-based BEAM in WebAssembly, so generated code never touches your server at all.

You could add a custom sandbox by implementing the Legion.Sandbox behaviour.

4. Agents orchestrate agents

Give an agent the built-in AgentTool and its generated code can delegate.

defmodule MyApp.OrchestratorAgent do
  @moduledoc "Coordinates research and writing sub-agents to produce finished content."
  use Legion.Agent

  def tools, do: [Legion.Tools.AgentTool]
  def tool_config(Legion.Tools.AgentTool), do: [agents: [MyApp.ResearchAgent, MyApp.WriterAgent]]
end

The orchestrator writes code like:

local _, research = table.unpack(AgentTool.call(ResearchAgent, "Find info about Elixir 1.18"))
local _, draft = table.unpack(AgentTool.call(WriterAgent, "Write a blog post using: " .. research))

Listed sub-agents are auto-aliased to their short names, and the {:ok, result} tuples tools return arrive in Lua as arrays.

Sub-agents are linked processes - when a parent dies, its children stop too. From the outside, fan out with parallel/2 or chain with pipeline/1:

{:ok, [posts, trends]} = Legion.parallel([
  {MyApp.ResearchAgent, "Find recent Elixir posts"},
  {MyApp.AnalysisAgent, "Summarize Elixir trends"}
])

{:ok, result} = Legion.pipeline([
  {MyApp.ResearchAgent, "Find Elixir blog posts from this week"},
  {MyApp.WriterAgent, &"Summarize these posts: #{&1}"}
])

See Legion.Tools.AgentTool for more details.

5. Conversations survive restarts

Plug in the Postgres store (it can reuse your Ecto repo) and resume any conversation by id, even after a deploy.

defmodule MyApp.AgentStore do
  use Legion.Store.Postgres, repo: MyApp.Repo
end

# config/config.exs
config :legion, :store, MyApp.AgentStore

{:ok, pid} = Legion.start_link(MyApp.AssistantAgent, agent_id: "user_42:chat_7")

{:ok, response} = Legion.call(pid, "Remember that my budget is $100")

GenServer.stop(pid)

# Later, in another process - or after a deploy
{:ok, pid} = Legion.resume("user_42:chat_7")
{:ok, response} = Legion.call(pid, "What was my budget again?")
# `start_link/2` links to the caller - supervise it yourself to outlive a request
DynamicSupervisor.start_child(MyApp.AgentSupervisor, {MyApp.AssistantAgent, agent_id: "user_42:chat_7"})

See Legion.Store, Legion.resume/2, and Legion.lookup/1 for more details.

6. Credentials never reach the LLM

Set auth context before the agent starts, read it inside tools at runtime via Vault. Generated code has no access to it.

Vault.init(current_user: %{id: user.id})
{:ok, result} = Legion.execute(MyApp.PostsAgent, "Find my posts from today and summarize them")
defmodule MyApp.Tools.PostsTool do
  use Legion.Tool

  def get_my_posts do
    %{id: user_id} = Vault.get(:current_user)
    Repo.all(from p in Post, where: p.user_id == ^user_id)
  end
end

See Vault for more details.

7. Rate limiting baked in

Configure a limiter and a default policy:

defmodule MyApp.RateLimiter do
  use Legion.RateLimiter.Postgres, repo: MyApp.Repo
end

# config/config.exs
config :legion, :rate_limit,
  limiter: MyApp.RateLimiter,
  default_policy: %Legion.RateLimiter.Policy{
    window_ms: :timer.minutes(1),
    max_agents: 10,
    max_tokens: 100_000
  }

Then name the groups an agent belongs to when it starts - a turn runs only if every rule allows it.

Legion.start_link(ChatAgent,
  rate_limit: [
    rules: [
      # This rule uses the default policy from `config.exs`
      %Legion.RateLimiter.Rule{identity: %{"ip" => "203.0.113.42"}},
      # This rule uses a custom policy
      %Legion.RateLimiter.Rule{
        identity: %{"email" => "someone@example.com", "tenant" => "acme"},
        policy: %Legion.RateLimiter.Policy{window_ms: :timer.hours(24), max_agents: 5}
      }
    ]
  ]
)

A rule without a policy takes the default one - give it a policy, or pass a limiter, to override the config for that agent. Sub-agents inherit their parent's settings. Legion.RateLimiter.Postgres comes with Legion and extends the Postgres store from Conversations survive restarts. Implement the Legion.RateLimiter behaviour to keep limit state anywhere else.

See Legion.RateLimiter for more details.

8. Structured output when you need it

Define output_schema/0 on the agent to get typed, validated responses.

See Legion.Agent for this and the other agent callbacks (system_prompt/0, config/0, action_types/0) - all optional with sensible defaults.

Configuration

config :legion, :store, MyApp.AgentStore
config :legion, :config, %{model: "openai:gpt-5.4", max_iterations: 10}
OptionDefaultDescription
model"openai:gpt-5.4"LLM model string passed to ReqLLM.
sandboxLegion.Sandbox.LuaModule validating and evaluating generated code. See Generated code runs in a sandbox.
max_iterations10Successful execution steps before the turn is stopped.
max_retries3Consecutive failures (bad code, tool errors) before giving up. Resets after each success.
binding_scope:turnHow long variables live: :iteration, :turn, or :conversation.
max_message_length20_000Byte limit for a single message; longer content is truncated. :infinity disables it.
sandbox_timeout60_000Milliseconds one evaluation may run before it is killed. :infinity disables it, leaving sandbox_max_reductions as the only stop for a runaway eval.
sandbox_max_heap256_000_000Memory budget in bytes for the eval process. :infinity disables it.
sandbox_max_reductions:infinityCPU budget in reductions, polled every ~50ms, so a hot loop dies before the clock runs out.
sandbox_priority:lowScheduler priority of the eval process. Raise to :normal if evals hit sandbox_timeout under load.
eval_guardnilLegion.EvalGuard module vetting generated code before it runs, for policy the sandbox cannot express.

Agents override global config by defining config/0 (Legion.Agent documents each key in full):

defmodule MyApp.DataAgent do
  @moduledoc "Fetches and processes data from HTTP APIs."
  use Legion.Agent

  def tools, do: [MyApp.HTTPTool]
  def config, do: %{model: "google:gemini-3.5-flash", max_iterations: 5}
end

Writing code is the one thing models keep getting better at - update the model string and every agent in your app gets smarter, for free.

Telemetry

Legion.Telemetry.attach_default_logger()

Events emitted at every level:

  • [:legion, :agent, :started | :stopped] - agent lifecycle

  • [:legion, :agent, :message, :start | :stop | :exception] - per-message

  • [:legion, :iteration, :start | :stop | :exception] - each execution step

  • [:legion, :llm, :request, :start | :stop | :exception] - LLM API calls

  • [:legion, :sandbox, :eval, :start | :stop | :exception] - code evaluation

  • [:legion, :eval_guard, :denied] - generated code refused by an eval guard
  • [:legion, :rate_limit, :exceeded] - turn denied by a rate limiter

Web Dashboard

legion_web provides a real-time Phoenix LiveView dashboard for monitoring agents, viewing conversation traces, and inspecting generated code.

Legion Web Dashboard

What's next

  • Braintrust integration - trace and evaluate agent runs
  • Datadog integration - agent and LLM telemetry in your existing dashboards
  • Popcorn sandbox - agents write Elixir that runs in the user's browser on popcorn, an AtomVM-based BEAM in WebAssembly, so generated code never touches your server

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 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 or recorded in persisted run metadata - 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

call(pid, message, timeout \\ :infinity)

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?")

cast(pid, message)

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")

execute(agent_module, task, opts \\ [])

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")

get_agent_id(pid)

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)

get_messages(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)

lookup(agent_id)

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")

parallel(tasks, timeout \\ :infinity)

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
  )

pipeline(steps)

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}
  ])

recover(agent_id, opts \\ [])

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. Returns:

  • :ok when 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)

resume(agent_id, opts \\ [])

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 continues execution in the background. It resumes from a saved checkpoint when one exists; otherwise it starts a new executor loop with the restored history.

opts are passed through to start_link/2.

Pass :store or configure one globally. Returns:

  • {:ok, pid} when a new process starts or one already owns agent_id
  • {:error, :not_resumable} when the store has no payload containing an agent module for agent_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)

{:error, :not_resumable} =
  Legion.resume("missing_chat", store: MyApp.AgentStore)

running?(pid)

Whether pid - typically one returned by lookup/1 or recorded in persisted run metadata - is alive. Accepts nil and returns false.

Checks local processes directly and processes on connected nodes through RPC. An unreachable remote node returns false.

A stored pid can outlive the VM that wrote it, so after a restart this remains best-effort: a recycled pid value can collide with an unrelated live process.

Examples

case Legion.lookup("user_42:chat_7") do
  {:ok, pid} -> Legion.running?(pid)
  :error -> false
end

start_link(opts)

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

start_link(agent_module, opts \\ [])

Starts a long-lived agent process.

Options

  • :store, :agent_id - persist the conversation across restarts; see Legion.Store. When supplied, :agent_id must be a valid UTF-8 string. A store set globally with config :legion, :store, MyApp.AgentStore applies to every agent, so you need only pass :agent_id. If a store is in effect but no :agent_id is given, Legion generates one - read it back with get_agent_id/1. An agent ID can belong to at most one live process across connected nodes.
  • :rate_limit - a keyword list with :limiter and :rules, a list of Legion.RateLimiter.Rules pairing an identity (for example, %{"ip" => "203.0.113.42"}) with a policy, checked for every turn by a Legion.RateLimiter. Both are required for a limit to apply; the limiter and a default policy can be set globally. A denied turn returns {:cancel, {:rate_limited, violations}}; see Legion.RateLimiter.
  • 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
          }
        }
      ]
    ]
  )

then(cancelled, agent, fun)

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}