LoomEx
An Elixir framework for building AI agents from OTP primitives.
Status: Active Development — LoomEx is being developed and iterated on. APIs may change. Contributions and feedback welcome.
What is LoomEx?
LoomEx weaves conversations, tool calls, and reasoning into coherent AI agents using Elixir's OTP building blocks (GenServer, Task.Supervisor, ETS). Instead of importing a heavyweight framework, you compose agents from simple behaviours and let the BEAM handle concurrency, fault tolerance, and streaming.
Core idea: Define an agent (system prompt + tools + model), call LoomEx.run/3, get streaming results with automatic multi-step tool execution.
defmodule MyAgent do
use LoomEx.Agent
def system_prompt(_ctx), do: "You are a helpful coding assistant."
def tools, do: [LoomEx.Tools.Bash, LoomEx.Tools.ReadFile, LoomEx.Tools.Grep]
def model, do: "fireworks/accounts/fireworks/models/kimi-k2p5"
end
{:ok, result} = LoomEx.run(MyAgent, [LoomEx.Message.user("Find all TODO comments")],
sink: fn {:text_delta, d} -> IO.write(d); _ -> :ok end)Features
- Agent behaviour — Declarative agent definition with callbacks for system prompt, tools, model, temperature, max steps, error handling
- Automatic tool loop — LLM calls tool -> execute -> feed result back -> LLM continues -> ... until done. Parallel tool execution via Task.Supervisor
- Streaming-first — Every token streams through pluggable Sinks (callback function, process message, Phoenix SSE)
- Provider-agnostic — Any OpenAI-compatible API via model string routing (
"provider/model-name") - 7 built-in tools — bash, read_file, write_file, edit_file, grep, glob, human (with more planned)
- Phoenix integration —
LoomEx.Phoenix.Plug.stream_agent/4for one-line SSE streaming with AI SDK v2 protocol compatibility - GenServer agents — Long-lived multi-turn conversations via
LoomEx.start_agent/2andLoomEx.call/3 - Context management — Auto-compaction when messages exceed context window, preserving tool call/result pairs
- Model registry — Fetches metadata for 4,000+ models from models.dev (context window, pricing, capabilities)
- Retry with backoff — Exponential backoff for transient errors (429, 503, connection failures)
- Telemetry — Structured events for agent lifecycle, LLM calls, tool execution, context compaction
- Standalone CLI — Ship as a single binary via Burrito (9MB, zero dependencies)
Installation
Add LoomEx to your mix.exs:
# From GitHub
{:loom_ex, github: "lulucatdev/loom_ex"}
# Or as a path dependency during development
{:loom_ex, path: "../loom_ex"}
Configure a provider in config/runtime.exs:
config :loom_ex,
providers: %{
fireworks: %{
api_key: System.get_env("FIREWORKS_API_KEY"),
base_url: "https://api.fireworks.ai/inference/v1/chat/completions"
},
openrouter: %{
api_key: System.get_env("OPENROUTER_API_KEY"),
base_url: "https://openrouter.ai/api/v1/chat/completions"
}
}Quick Start
Define an Agent
defmodule MyApp.MathAgent do
use LoomEx.Agent
@impl true
def system_prompt(_ctx), do: "You are a math tutor. Use the calculator when needed."
@impl true
def tools, do: [MyApp.Tools.Calculator]
@impl true
def model, do: "fireworks/accounts/fireworks/models/kimi-k2p5"
endDefine a Tool
defmodule MyApp.Tools.Calculator do
use LoomEx.Tool
@impl true
def name, do: "calculator"
@impl true
def description, do: "Evaluate a math expression."
@impl true
def parameters do
%{
type: "object",
properties: %{
expr: %{type: "string", description: "Math expression, e.g. '6 * 7'"}
},
required: ["expr"]
}
end
@impl true
def execute(%{"expr" => expr}, _ctx) do
{result, _} = Code.eval_string(expr)
{:ok, %{"result" => result}}
end
endRun
# Single execution with streaming
{:ok, result} = LoomEx.run(MyApp.MathAgent, [LoomEx.Message.user("What is 123 * 456?")],
sink: fn
{:text_delta, d} -> IO.write(d)
{:tool_call_complete, tc} -> IO.puts("\n[Tool: #{tc.name}]")
_ -> :ok
end)
# result.messages — full conversation history
# result.steps — number of LLM calls
# result.usage — %{"prompt_tokens" => ..., "completion_tokens" => ...}Multi-turn Conversations
{:ok, pid} = LoomEx.start_agent(MyApp.MathAgent)
{:ok, _} = LoomEx.call(pid, "What is 2 + 2?")
{:ok, _} = LoomEx.call(pid, "Now multiply that by 10")
messages = LoomEx.get_messages(pid) # full historyPhoenix Controller
defmodule MyAppWeb.ChatController do
use MyAppWeb, :controller
def chat(conn, %{"messages" => messages}) do
{conn, _result} = LoomEx.Phoenix.Plug.stream_agent(conn, MyApp.ChatAgent, messages)
conn
end
end
The response streams as Server-Sent Events compatible with the Vercel AI SDKuseChat hook.
Built-in Tools
| Tool | Module | Description |
|---|---|---|
| bash | LoomEx.Tools.Bash | Execute shell commands with timeout and output truncation |
| read_file | LoomEx.Tools.ReadFile | Read files with line-numbered pagination |
| write_file | LoomEx.Tools.WriteFile | Write files, auto-create directories |
| edit_file | LoomEx.Tools.EditFile | Exact string replacement with uniqueness check |
| grep | LoomEx.Tools.Grep | Search file contents with regex, glob filtering |
| glob | LoomEx.Tools.Glob | Find files by wildcard pattern |
| human | LoomEx.Tools.Human | Pause agent, ask user for input, continue |
Use them by listing in your agent's tools/0:
def tools, do: [LoomEx.Tools.Bash, LoomEx.Tools.ReadFile, LoomEx.Tools.Grep]CLI Binary
LoomEx can be packaged as a standalone CLI via Burrito:
# Build (requires Zig: brew install zig)
MIX_ENV=prod mix release
# Run
./burrito_out/loom_ex_macos_arm64 "What is 2+2?"
./burrito_out/loom_ex_macos_arm64 chat --tools bash,grep "Find all TODOs"
./burrito_out/loom_ex_macos_arm64 chat -i --model anthropic/claude-sonnet-4-6
# Pipe support
cat file.ex | ./loom_ex "summarize this code"
git diff | ./loom_ex "review this diff"Agent Callbacks
| Callback | Default | Description |
|---|---|---|
system_prompt(ctx) | required | System prompt, receives context map |
tools() | required | List of tool modules |
model() | required |
Model string, e.g. "fireworks/model-name" |
max_steps() | 10 | Maximum tool-call loops before stopping |
temperature() | 0.1 | LLM temperature |
context_window() | 128_000 | Fallback context window (auto-resolved from models.dev) |
extra_body() | %{} | Extra fields merged into LLM request body |
on_step(info, ctx) | :continue | Called after each tool execution step |
on_error(error, ctx) | {:stop, error} | Called on LLM errors |
Architecture
LoomEx.run/3 or LoomEx.start_agent/2 + LoomEx.call/3
|
v
LoomEx.Agent.Runner (core loop)
|
+-- LoomEx.Context.maybe_compact() auto-compress long conversations
+-- LoomEx.LLM.Retry.chat_stream() exponential backoff retry
| +-- LoomEx.LLM.Client Req + SSEParser streaming
| +-- LoomEx.LLM.Provider model string -> provider config
| +-- LoomEx.Models (ETS) models.dev metadata cache
+-- Tool execution Task.Supervisor (parallel)
+-- LoomEx.Sink streaming output
| +-- Callback (fn)
| +-- Process (pid message)
| +-- LoomEx.Phoenix.SSESink AI SDK v2 SSE protocol
+-- LoomEx.Telemetry structured observability eventsTelemetry Events
| Event | Measurements | Metadata |
|---|---|---|
[:loom_ex, :agent, :start] | agent, context | |
[:loom_ex, :agent, :stop] | duration, steps | agent, result |
[:loom_ex, :step, :start] | agent, step | |
[:loom_ex, :step, :stop] | duration | agent, step |
[:loom_ex, :llm, :start] | model, message_count | |
[:loom_ex, :llm, :stop] | duration | model, finish_reason |
[:loom_ex, :llm, :retry] | delay_ms | model, attempt, error |
[:loom_ex, :tool, :start] | tool, tool_call_id | |
[:loom_ex, :tool, :stop] | duration | tool, tool_call_id |
[:loom_ex, :tool, :error] | duration | tool, error |
[:loom_ex, :context, :compact] | tokens_before, tokens_after | removed, kept |
Attach the default logger for development:
LoomEx.Telemetry.attach_default_logger()Design Principles
- OTP-native — Agents are GenServers, tools execute via Task.Supervisor, model registry lives in ETS. No alien abstractions.
- Streaming-first — Every LLM token streams to the consumer in real-time. Backpressure handled naturally by the BEAM.
- Transparent message chain — Messages are explicit parameters, never hidden behind framework state. You always know what the LLM sees.
- Provider-agnostic — Any OpenAI-compatible API. Model string format:
"provider/model-name". - Composable — Agents can delegate to sub-agents via tools. Tools are plain modules implementing a behaviour.
Inspirations
- pi-mono — Minimalist agent framework philosophy, extension system
- Legion — Elixir agent framework, GenServer agent lifecycle, Vault pattern
- Vercel AI SDK — Streaming protocol,
useChathook,maxSteps - Why Elixir/OTP Doesn't Need an Agent Framework — Use OTP primitives directly
- models.dev — Model metadata registry
License
MIT