Building Agentic Systems with Erlang/Elixir and OTP: Old Patterns for New Hypes
By Matheus de Camargo Marques
Introduction: The Hype and the Pattern
Every few months, someone in the AI space announces a new framework for running autonomous agents. The framework promises isolated state, message passing, supervision hierarchies, and fault recovery. The BEAM community watches, nods, and goes back to work.
This keeps happening because process-based concurrency solves a genuinely hard problem, and the BEAM virtual machine has been solving it since 1986 — not as a library, not as a pattern you adopt, but as the runtime itself.
The Python AI ecosystem is building agent frameworks that independently converge on the same architecture: isolated processes, message passing, supervision hierarchies, fault recovery. The patterns aren't similar to OTP by coincidence. They're similar because the problem demands this shape.
In this article, I want to show how the patterns we've refined over four decades in Erlang/OTP — GenServer, Process, Supervisor — map almost one-to-one onto the agentic patterns that the industry is now "discovering." And I want to show this with working Elixir code, in small enough chunks that you can screenshot and share.
Part I: The Agentic Patterns from Microservices (and Why They're Already OTP)
The Mapping Everyone Is Publishing
In April 2026, Teksystems published an article that maps microservices concepts directly to their agentic AI equivalents:
| Microservices Concept | Agentic AI Equivalent | Why It Matters |
|---|---|---|
| Service Discovery | Agent Capabilities (MCP) | Agent uses protocols like MCP to find and call tools/agents |
| API Gateway | Supervisor / Root Agent | A lead agent routes user intent to the specialized sub-agent |
| Stateless Logic | State Management | Tools like Agent Engine manage "sessions" to keep agents lightweight |
| Payloads and Schema | Structured Context | Context is treated as "compiled views" instead of a messy string |
What strikes me about this table is not what's new. It's what's old. Every single one of these "agentic patterns" has a direct equivalent in OTP:
-
Service Discovery →
Registryand:globalin OTP. -
API Gateway / Supervisor →
SupervisorandDynamicSupervisor. -
State Management →
GenServerstate and:etstables. - Structured Contracts → Pattern matching and message protocols.
The industry is reinventing what the BEAM has had as a native runtime feature for decades.
The "Cognitive Monolith" Problem
VentureBeat captured the core problem perfectly in December 2025: "If you're building enterprise AI by trying to fit 20 pages of system instructions into a single large language model call, you're repeating a familiar architectural failure. You're not building an agent — you're building a cognitive monolith".
This is exactly the same failure mode we identified with monoliths in the early 2010s. A single, all-knowing process becomes a bottleneck. It's hard to specialize, creates a single point of failure, and cannot manage complex, stateful workflows.
The solution, as always, is decomposition. And the unit of decomposition in OTP is the process.
Part II: Why BEAM/OTP Is the Runtime Agents Were Waiting For
The Concurrency Problem, Stated Plainly
Your program needs to do multiple things at once. You have thousands of concurrent conversations, each with its own state. You need to isolate failures so one bad agent doesn't bring down the system. You need to recover gracefully from crashes.
There are two fundamental approaches:
Shared state with locks. Multiple threads access the same memory. You prevent corruption with mutexes, semaphores, and locks. The problem isn't that it doesn't work. It's that it works until it doesn't.
Isolated state with message passing. Each concurrent unit has its own memory. The only way to communicate is by sending messages. No shared memory, no locks, no races. This is the actor model. Carl Hewitt proposed it in 1973. Erlang implemented it as a runtime in 1986.
Every few years, the rest of the industry rediscovers it.
The "Let It Crash" Philosophy Is Not Recklessness
The Zylos Research article on supervisor trees for AI agents states it perfectly: "Building resilient AI agent runtimes requires the same discipline that Erlang engineers applied to telecom systems in the 1980s: accept that processes will fail, isolate the blast radius, and automate recovery".
The "let it crash" philosophy is not about being careless. It's about separating fault-handling logic from business logic. A GenServer that crashes and gets restarted by its supervisor is more reliable than a process that tries to handle every possible error inline.
Part III: Code Examples — Building Agentic Systems in Elixir
Example 1: A Simple GenServer Agent
Let's start with the foundation. A GenServer-based agent that holds state and can process messages.
Snippet 1 — Module structure and public API:
defmodule MyApp.ResearchAgent do
use GenServer
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
def query(agent, prompt) do
GenServer.call(agent, {:query, prompt}, 30_000)
end
Snippet 2 — Initial agent state:
@impl true
def init(opts) do
state = %{
history: [],
tools: Keyword.get(opts, :tools, []),
model: Keyword.get(opts, :model, "claude-sonnet-4"),
max_history: Keyword.get(opts, :max_history, 50)
}
{:ok, state}
end
Snippet 3 — The main query loop:
@impl true
def handle_call({:query, prompt}, _from, state) do
context = build_context(state.history, prompt)
response = call_llm(context, state.model)
new_history = [
%{role: "user", content: prompt},
%{role: "assistant", content: response}
] ++ state.history
Snippet 4 — Trimming history to prevent context dilution:
trimmed_history = Enum.take(new_history, state.max_history)
new_state = %{state | history: trimmed_history}
{:reply, {:ok, response}, new_state}
end
Snippet 5 — Private helpers:
defp build_context(history, prompt) do
messages = Enum.reverse(history)
messages ++ [%{role: "user", content: prompt}]
end
defp call_llm(_messages, _model) do
"Simulated response from #{_model}"
end
end
This is a basic building block. It holds state, processes requests, and returns responses. But alone, it's fragile. If it crashes, everything is lost. That's where supervisors come in.
Example 2: Supervision Tree for Multiple Agents
The core strength of OTP is the supervision tree. A supervisor monitors its children and restarts them on failure.
Snippet 6 — Supervisor startup:
defmodule MyApp.AgentSupervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
Snippet 7 — Registry for agent discovery:
children = [
{Registry, keys: :unique, name: MyApp.AgentRegistry},
{DynamicSupervisor, name: MyApp.AgentDynamicSupervisor,
strategy: :one_for_one},
Snippet 8 — Persistent agents and restart strategy:
%{id: :research_agent,
start: {MyApp.ResearchAgent, :start_link, [[name: :research_agent]]}},
%{id: :code_agent,
start: {MyApp.CodeAgent, :start_link, [[name: :code_agent]]}}
]
Supervisor.init(children, strategy: :one_for_one,
max_restarts: 5, max_seconds: 30)
end
end
This supervision tree gives you something that no Python agent framework gives you out of the box: automatic, isolated recovery. If the ResearchAgent crashes, the supervisor restarts it. The CodeAgent is unaffected.
Example 3: DynamicSupervisor for Agent Pools
In production, you don't want a fixed number of agents. You want to spawn agents on demand.
Snippet 9 — Agent pool API:
defmodule MyApp.AgentPool do
use GenServer
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def spawn_agent(agent_module, opts \\ []) do
GenServer.call(__MODULE__, {:spawn_agent, agent_module, opts})
end
Snippet 10 — Dynamic agent spawning:
@impl true
def handle_call({:spawn_agent, agent_module, opts}, _from, state) do
spec = {agent_module, :start_link, [opts]}
case DynamicSupervisor.start_child(MyApp.AgentDynamicSupervisor, spec) do
{:ok, pid} ->
agent_id = make_ref()
new_agents = Map.put(state.agents, agent_id, pid)
{:reply, {:ok, agent_id, pid}, %{state | agents: new_agents}}
end
end
end
This pattern maps directly to what VentureBeat calls "Conversational Swarms" — multiple agents collaborating. In OTP, it's just a DynamicSupervisor managing a pool of GenServer processes.
Example 4: gen_statem for ReAct Loops
The most interesting pattern for agentic systems is the ReAct loop (Reasoning + Acting). Most developers reach for GenServer. But gen_statem transforms complex loops into declarative state machines.
Snippet 11 — ReAct agent definition:
defmodule MyApp.ReActAgent do
@behaviour :gen_statem
def start_link(opts \\ []) do
:gen_statem.start_link({:local, __MODULE__}, __MODULE__, opts, [])
end
def run(task) do
:gen_statem.call(__MODULE__, {:run, task}, 60_000)
end
Snippet 12 — Initial state machine state:
@impl true
def callback_mode, do: :state_functions
@impl true
def init(opts) do
state = %{
task: nil,
history: [],
max_iterations: Keyword.get(opts, :max_iterations, 10),
iteration: 0
}
{:ok, :idle, state}
end
Snippet 13 — Transition from idle to reasoning:
def idle({:call, from}, {:run, task}, state) do
new_state = %{state | task: task,
history: [%{role: "user", content: task}],
iteration: 0}
{:next_state, :reasoning, new_state, [{:reply, from, :started}]}
end
Snippet 14 — Reasoning state (LLM call):
def reasoning(:internal, _data, state) do
response = call_llm(state.history, state.tools, state.model)
case parse_response(response) do
{:action, tool_name, tool_input} ->
new_history = [%{role: "assistant", content: response} | state.history]
{:next_state, :acting, %{state | history: new_history},
[{:next_event, :internal, {:execute_tool, tool_name, tool_input}}]}
Snippet 15 — Reasoning finalization:
{:final, answer} ->
new_history = [%{role: "assistant", content: answer} | state.history]
{:next_state, :done, %{state | history: new_history},
[{:next_event, :internal, {:final_answer, answer}}]}
end
end
Snippet 16 — Acting state (tool execution):
def acting(:internal, {:execute_tool, tool_name, tool_input}, state) do
tool = Enum.find(state.tools, fn t -> t.name == tool_name end)
result = case tool do
nil -> "Unknown tool: #{tool_name}"
tool -> tool.execute.(tool_input)
end
new_history = [%{role: "tool", content: result} | state.history]
{:next_state, :reasoning, %{state | history: new_history}}
end
end
This is where gen_statem shines. The agent loop is explicit. You see exactly what state the agent is in and what transitions are possible.
Example 5: Tool Registry with ETS
In an agentic system, agents need to discover and call tools. This is the Service Discovery pattern.
Snippet 17 — Tool registry API:
defmodule MyApp.ToolRegistry do
use GenServer
@table __MODULE__
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def register(name, description, schema, executor) do
GenServer.call(__MODULE__, {:register, name, description, schema, executor})
end
Snippet 18 — Tool lookup:
def lookup(name) do
case :ets.lookup(@table, name) do
[{^name, description, schema, executor}] ->
{:ok, %{name: name, description: description,
input_schema: schema, executor: executor}}
[] ->
{:error, :not_found}
end
end
Snippet 19 — Initialization and registration:
@impl true
def init(_) do
:ets.new(@table, [:named_table, :public, :set])
{:ok, %{}}
end
@impl true
def handle_call({:register, name, desc, schema, exec}, _from, state) do
:ets.insert(@table, {name, desc, schema, exec})
{:reply, :ok, state}
end
end
Snippet 20 — Tool executor with error handling:
defmodule MyApp.ToolExecutor do
def execute(tool_name, input) do
case MyApp.ToolRegistry.lookup(tool_name) do
{:ok, tool} ->
try do
{:ok, tool.executor.(input)}
rescue
e -> {:error, Exception.message(e)}
end
{:error, :not_found} ->
{:error, "Tool #{tool_name} not found"}
end
end
end
This is the Model Context Protocol (MCP) pattern, implemented in 50 lines of Elixir. No external dependencies. No Python runtime. No framework.
Example 6: GenServer as an LLM Harness
Snippet 21 — Harness API:
defmodule MyApp.LLMHarness do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
def complete(harness, prompt, opts \\ []) do
GenServer.call(harness, {:complete, prompt, opts}, 60_000)
end
Snippet 22 — Harness state:
@impl true
def init(opts) do
state = %{
provider: opts[:provider] || :anthropic,
model: opts[:model] || "claude-sonnet-4",
api_key: opts[:api_key] || System.get_env("ANTHROPIC_API_KEY"),
tools: opts[:tools] || []
}
{:ok, state}
end
Snippet 23 — Completion loop:
@impl true
def handle_call({:complete, prompt, opts}, _from, state) do
messages = build_messages(prompt, opts)
case call_provider(state.provider, state.model, messages,
state.tools, state.api_key) do
{:ok, response} -> {:reply, {:ok, response}, state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
Snippet 24 — HTTP call to provider:
defp call_provider(:anthropic, model, messages, tools, api_key) do
body = %{model: model, max_tokens: 4096, messages: messages,
tools: Enum.map(tools, &tool_to_schema/1)}
headers = [{"x-api-key", api_key},
{"anthropic-version", "2023-06-01"},
{"content-type", "application/json"}]
Req.post("https://api.anthropic.com/v1/messages", json: body, headers: headers)
end
end
This is the same pattern used by PiEx, Alloy, and Omni Agent — all Elixir libraries that implement the agent loop inside a supervised GenServer.
Part IV: What You Get for Free
Self-Healing Agents
The Jido framework documentation states it plainly: "Each agent runs in its own BEAM process under OTP supervision. Crashes are recovered automatically. If one agent fails, no other agent is affected".
State Recovery After Restart
This is the hardest problem in agentic systems. In OTP, you have several options:
-
Checkpointing: Periodically persist state to
:ets,:dets, or a database. - Event sourcing: Every state change is an event. On restart, replay the events.
- Snapshot + replay: Periodic snapshots plus event replay since the last snapshot.
GenServer has terminate/2 callbacks where you can persist state before a crash.
Process Isolation and Sandboxing
PtcRunner runs generated code in a BEAM-native sandbox with process isolation, timeouts, heap limits, and controlled tool access. This is not a container. It's a lightweight process with its own heap.
Observability
:observer gives you a live view of the process tree. :sys.get_state/1 lets you inspect any GenServer's state. :sys.trace/3 lets you trace message flows.
Part V: The Patterns That Never Die
I return to Matheus Guimaraes's phrase at AWS Summit London 2026: "Patterns never die. They just get new disguises".
The agentic patterns that the industry is "discovering" in 2026 are not new. They are the same patterns that Erlang/OTP has embodied since 1986:
- Single Responsibility → Each agent is a separate process.
-
Service Discovery →
Registry,:global, and:etstables. -
API Gateway →
SupervisorandDynamicSupervisor. - Structured Contracts → Pattern matching and message protocols.
- Fault Tolerance → Supervision trees with configurable restart strategies.
- Process Isolation → BEAM lightweight processes with independent heaps.
As Niko Maroulis wrote on LinkedIn: "The more 'agentic' our systems become, the more Elixir/Erlang/OTP feels like the runtime that was built for it".
Part VI: Getting Started
- Start with GenServer. Build a single agent. Keep it simple. Test the loop.
- Add a Supervisor. Wrap your agent in a supervision tree.
- Use DynamicSupervisor for pools. Spawn agents on demand.
- Explore gen_statem for complex loops. Make states explicit.
-
Use ETS for tool discovery. Register tools in
:ets. -
Add observability. Use
:observerand telemetry. -
Check out the libraries.
Jido,PiEx,Alloy,Omni Agent, andKyber-BEAM.
Conclusion: Old Runtime, New Hype
The hype around agentic AI is real. The patterns are not. They are the same patterns we've been refining for decades.
If you're an Elixir developer, you already have the runtime that the rest of the industry is trying to build. You don't need a new framework. You need to apply what you already know to a new class of workloads.
The BEAM was built for systems that are concurrent, distributed, fault-tolerant, and stateful. Those are exactly the properties that agentic systems require.
The hype will pass. The patterns will remain.
References
AWS Developers Podcast, Episode 206: "The Evolution of Microservices: Agents, Monoliths, and the Patterns That Never Die" — Recorded live at AWS Summit London, April 29, 2026.
Teksystems, Arvind Sambaraj: "From Microservices to Multi-Agent AI Systems" — April 3, 2026.
Zylos Research: "Supervisor Trees and Fault Tolerance Patterns for AI Agent Systems" — March 16, 2026.
Variant Systems: "BEAM OTP: Why Everyone Keeps Reinventing It" — February 22, 2026.
VentureBeat: "AI teams: The new blueprint for enterprise automation" — December 15, 2025.
bombadil-labs/kyber— LLM agent harness built in Elixir/OTP.nshkrdotcom/jido— Autonomous agent framework for Elixir.nshkrdotcom/mabeam— Agent framework with GenServer and supervision.PiEx— Elixir library for AI coding agents.Alloy— Model-agnostic agent harness for Elixir.Omni Agent— Stateful LLM agents in Elixir.Erlang/OTP Design Principles — Supervisor Behaviour.
"Beyond GenServers: Declarative AI Flows With gen_statem" — CodeBEAM Europe 2026.
Matheus de Camargo Marques is a software engineer focused on Elixir, Erlang and distributed systems. This article reflects a personal analysis based on independent research and does not represent the official position of any organization.
Top comments (0)