Alloy
Minimal, OTP-native agent loop for Elixir.
Alloy is the completion-tool-call loop and nothing else. Send messages to any LLM, execute tool calls, loop until done. Swap providers with one line. No opinions on sessions, persistence, memory, scheduling, or UI — those belong in your application, where OTP already gives you the runtime.
Alloy is a harness, not a framework. Four runtime dependencies, ~10,000 lines — small enough to read in a day, and everything beyond the loop is a recipe built on the primitives, not a subsystem.
{:ok, result} = Alloy.run("Read mix.exs and tell me the version",
provider: {Alloy.Provider.OpenAI, api_key: System.get_env("OPENAI_API_KEY"), model: "gpt-5.4"},
tools: [Alloy.Tool.Core.Read]
)
result.text #=> "The version is 0.13.0"
Why Alloy?
Most agent frameworks try to be everything — sessions, memory, RAG, multi-agent orchestration, scheduling, UI. Alloy does one thing well: the agent loop. The BEAM makes the rest cheaper than a framework can: sub-agents are supervised function calls (recipe), MCP servers mount as tools (recipe), parallel tool execution is Task.Supervisor, and crash isolation is just processes.
Design Boundary
Alloy stays minimal by owning protocol and loop concerns, not application workflows.
What belongs in Alloy:
- Provider wire-format translation
- Tool-call / completion loop mechanics
- Normalized message blocks
- Opaque provider-owned state such as stored response IDs
- Provider response metadata such as citations or server-side tool telemetry
What does not belong in Alloy:
- Sessions and persistence policy
- File storage, indexing, or retrieval workflows
- UI rendering for citations, search, or artifacts
- Scheduling, background job orchestration, or dashboards
- Tenant plans, quotas, billing, or hosted infrastructure policy
Rule of thumb: if the feature is required to speak a provider API correctly, and could help any Alloy consumer, it likely belongs here. If it needs a database table, product defaults, UI decisions, or tenancy logic, it belongs in your application layer.
Non-goals
Alloy does not own multi-agent orchestration; use the
sub-agents recipe, where an agent
spawns itself via a function call. Alloy does not sandbox your machine; use
:bash_executor with containers or another isolated runner. Sessions,
persistence, scheduling, hosted queues, dashboards, and UI stay in your
application layer. The library stays small so those choices remain yours.
What's in the box
- 6 providers — Anthropic, Gemini, OpenAI, Codex, xAI, and OpenAICompat (works with any OpenAI-compatible API: Ollama, OpenRouter, DeepSeek, Mistral, Groq, Together, etc.)
- 4 built-in tools — read, write, edit, bash — plus inline tools defined as data with
Alloy.Tool.inline/1 - Streaming — incremental HTTP provider output; Codex replays final text through the same interface
- Middleware — custom hooks, tool blocking, argument editing
- Context compaction — middleware, on by default: tool-result clearing plus summary-based compaction when approaching token limits, with configurable reserve and fallback to truncation
- Memory tool —
Alloy.Memory.tool/1for any provider (Anthropic gets its nativememory_20250818type). Alloy owns the commands and path validation; you own the store (in-memory, disk, Postgres — whatever fits) - Prompt caching — Anthropic
cache: trueadds cache breakpoints for 60-90% input token savings - Reasoning blocks — OpenAI encrypted reasoning, Anthropic and Gemini signed thinking, and DeepSeek/Kimi/GLM
reasoning_contentare kept as blocks and sent back the way each API requires - Tool guardrails —
concurrent?/0controls parallel execution,max_result_chars/0caps output, prompt-too-long auto-recovery. Note: the bash tool's restricted mode is a guardrail, not a sandbox — see Built-in tools - Structured output —
until_toolforces the loop to continue until a specific tool is called - Provider passthrough —
extra_bodyinjects arbitrary provider-specific params (response_format, temperature, reasoning_effort) - Telemetry — run, turn, provider, and compaction lifecycle events for OTEL/logging/metrics
- Budget limits — a small
:before_completionmiddleware recipe prices usage and halts the run - Pluggable model catalog —
Alloy.ModelCatalogbehaviour; bring your own context-window source (e.g., anllm_dbadapter) - ~10,000 lines — small enough to read, understand, and extend
Installation
Add alloy to your dependencies in mix.exs:
def deps do
[
{:alloy, "~> 0.13"}
]
end
For a supervised agent process — sessions, async dispatch over
Phoenix.PubSub, backpressure, and ready-made memory stores — add
alloy_agent as well. It moved
out of Alloy in 0.13; see Upgrading to 0.13.
Quick Start
Simple completion
{:ok, result} = Alloy.run("What is 2+2?",
provider: {Alloy.Provider.Anthropic, api_key: "sk-ant-...", model: "claude-sonnet-5-5"}
)
result.text #=> "4"
Agent with tools
{:ok, result} = Alloy.run("Read mix.exs and summarize the dependencies",
provider: {Alloy.Provider.Gemini,
api_key: "...", model: "gemini-3.5-flash"},
tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Bash],
max_turns: 10
)
For new Gemini integrations, Google recommends gemini-3.8-flash or
gemini-3.5-flash-lite; 2.5 access is restricted to prior active users. The
built-in catalog budgets context for Gemini 2.5 Pro/Flash/Flash-Lite,
gemini-3.1-pro-preview, 3.1 and 3.5 Flash-Lite, and 3.5–3.8 Flash. See
provider compatibility.
Swap providers in one line
# The same tools and conversation work with any provider
opts = [tools: [Alloy.Tool.Core.Read], max_turns: 10]
# Anthropic
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.Anthropic, api_key: "...", model: "claude-sonnet-5-5"}} | opts])
# OpenAI (Responses API)
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-6.1-sol"}} | opts])
# Gemini
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.Gemini, api_key: "...", model: "gemini-3.8-flash"}} | opts])
# xAI (Responses API; its Chat Completions API is legacy and returns no reasoning)
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.XAI, api_key: "...", model: "grok-4.7"}} | opts])
# DeepSeek (thinking mode; reasoning_content is sent back as the API requires)
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.OpenAICompat, api_key: "...", api_url: "https://api.deepseek.com", model: "deepseek-v4-pro"}} | opts])
# Any OpenAI-compatible API (Ollama, OpenRouter, Mistral, Groq, etc.)
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.OpenAICompat, api_url: "http://localhost:11434", model: "llama4"}} | opts])
Streaming
For a one-shot run, use Alloy.stream/3:
{:ok, result} =
Alloy.stream("Explain OTP", fn chunk ->
IO.write(chunk)
end,
provider: {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-5.4"}
)
HTTP providers stream incrementally; Codex emulates streaming by replaying final text. If a custom provider doesn't implement
stream/4, the turn loop falls back to complete/3 automatically.
Alloy.run/2 remains the buffered convenience API. Use Alloy.stream/3
when you want the same one-shot flow with token streaming.
Provider-owned state
Some provider APIs keep state on their side, such as stored OpenAI/xAI response IDs or an Anthropic code-execution container. That transport concern lives in Alloy; your app decides whether and how to persist it.
Results expose provider-owned state in result.metadata.provider_state.
Pass it back with the conversation when you continue a run, and the provider
reuses what it needs (for example the code-execution container):
{:ok, next_result} =
Alloy.run("Keep going",
messages: result.messages,
provider: {Alloy.Provider.Anthropic,
api_key: System.get_env("ANTHROPIC_API_KEY"),
model: "claude-sonnet-5-5",
provider_state: result.metadata.provider_state
}
)
By default every request carries the full conversation. To continue an
OpenAI or xAI response stored server-side instead, pass its ID as
previous_response_id: together with only the new messages: the server
prepends the stored conversation, so sending the full history too would
duplicate it and bill it twice.
{:ok, next_result} =
Alloy.run("Keep going",
provider: {Alloy.Provider.XAI,
api_key: System.get_env("XAI_API_KEY"),
model: "grok-4.7",
previous_response_id: result.metadata.provider_state.response_id
}
)
Without previous_response_id (and with store not true), the OpenAI and
xAI providers run stateless: they request encrypted reasoning and send
returned reasoning items back verbatim before their related function calls;
result.text ignores those opaque blocks.
Provider-native tools and citations
Responses-compatible providers can expose built-in server-side tools without leaking those wire details into your app layer.
For xAI search tools:
{:ok, result} =
Alloy.run("Summarise the latest xAI docs updates",
provider: {Alloy.Provider.XAI,
api_key: System.get_env("XAI_API_KEY"),
model: "grok-4.7",
web_search: %{allowed_domains: ["docs.x.ai"]},
include: ["inline_citations"]
}
)
Citation metadata is exposed in two places:
result.metadata.provider_response.citationsfor provider-level citation data- assistant text blocks may include
:annotationsfor inline citation spans
Overriding model metadata
Alloy derives the compaction budget from the configured provider model when it knows that model's context window. If you need to support a just-released model before Alloy ships a catalog update, override it in config:
{:ok, result} = Alloy.run("Summarise this repository",
provider: {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-5.4-2026-03-05"},
model_metadata_overrides: %{
"gpt-5.4" => 900_000,
"acme-reasoner" => %{limit: 640_000, suffix_patterns: ["", ~r/^-\d{4}\.\d{2}$/]}
}
)
Set max_tokens explicitly when you want a fixed compaction budget. Otherwise
Alloy derives it from the current model, including after
Alloy.Agent.Config.with_provider/2 switches to a different provider model.
Pluggable model catalog
The built-in catalog is deliberately small — Alloy is a harness, not a model
database, and a hand-curated list will always lag the release treadmill. For a
richer source of truth, implement the Alloy.ModelCatalog behaviour and pass
it via model_catalog:. For example, backed by
llm_db (45+ providers, maintained against
models.dev):
defmodule MyApp.LlmDbCatalog do
@behaviour Alloy.ModelCatalog
@impl true
def context_window(model) do
case LLMDb.model(model) do
%{context_window: limit} when is_integer(limit) -> limit
_ -> nil
end
end
end
{:ok, result} = Alloy.run("Summarise this repository",
provider: {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-5.4"},
model_catalog: MyApp.LlmDbCatalog
)
Resolution order: explicit max_tokens → model_metadata_overrides →
model_catalog → default window. Returning nil for an unknown model is
fine — Alloy falls back to the default.
Use compaction: when you want to tune how much room Alloy reserves before it
compacts older context:
{:ok, result} = Alloy.run("Summarise this repository",
provider: {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-5.4"},
compaction: [
reserve_tokens: 12_000,
keep_recent_tokens: 8_000,
clear_tool_results: true,
keep_recent_tool_results: 3,
fallback: :truncate
]
)
Alloy first clears old tool_result content in one batch, preserving the
newest keep_recent_tool_results results, then only calls the summarizer if
the run is still over budget. Batched clearing matters with prompt caching:
clearing invalidates cached prefixes, so Alloy amortizes that cost instead of
dripping changes across turns. The estimate counts the system prompt, tool
definitions and every message block.
Whenever compaction changes history, every thinking block is removed, including the turn in progress: Claude 5.x rejects signed thinking whose earlier history changed, and removing all of it is the documented way to keep the transcript valid. Under manual thinking, which requires the turn in progress to start with thinking, that turn keeps it. Compaction never splits a tool call from its result.
Set summary_system_prompt: and summary_prompt: inside compaction: when
your application needs to own the handoff format. Both values must be strings;
omitting them uses Alloy's default summary prompts.
Compaction is ordinary middleware, Alloy.Context.Compactor, which
Alloy.run/2 puts first in :middleware. Pass compaction: false to turn it
off (for example when your application manages context itself), or list the
compactor in :middleware to choose where it runs. When the provider rejects
a request as too long, the loop runs the :on_context_overflow hook — the
compactor forces a compaction there — and retries once if the history
changed.
Budget limits
Price the accumulated usage in :before_completion middleware, which runs
before every provider request, and halt when the run reaches its limit:
defmodule MyApp.BudgetGuard do
@behaviour Alloy.Middleware
# USD per million tokens for the model you run.
@input_per_m 3.0
@output_per_m 15.0
@impl true
def call(:before_completion, state) do
limit = Map.fetch!(state.config.context, :max_budget_cents)
usage = Alloy.Usage.estimate_cost(state.usage, @input_per_m, @output_per_m)
if usage.estimated_cost_cents >= limit,
do: {:halt, "budget of #{limit} cents reached"},
else: state
end
def call(_hook, state), do: state
end
Alloy.run(prompt,
provider: provider,
middleware: [MyApp.BudgetGuard],
context: %{max_budget_cents: 50}
)
A halted run returns {:error, result} with status: :halted.
estimate_cost/3 prices input and output tokens only; usage.input_tokens
is uncached input, so add your provider's cache read and write prices
(cache_read_input_tokens, cache_creation_input_tokens) if you use prompt
caching. (The max_budget_cents: option was removed in 0.13: no built-in
provider reports a cost, so it never fired with them.)
Anthropic prompt caching
Enable prompt caching to save 60-90% on input tokens. Alloy automatically adds
cache_control breakpoints to the system prompt, the last tool definition
that is not defer_loading, and the end of the conversation:
{:ok, result} = Alloy.run("Explain this codebase",
provider: {Alloy.Provider.Anthropic,
api_key: "...", model: "claude-sonnet-5-5",
cache: true
},
tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Bash],
system_prompt: "You are a senior Elixir developer."
)
# Cache usage is reported in result.usage
result.usage.cache_creation_input_tokens #=> 1500
result.usage.cache_read_input_tokens #=> 1500 (on subsequent calls)
Memory
Alloy exposes memory as a behaviour — Alloy.Memory — and a tool built on
it, matching the split Anthropic uses in their own Python SDK: Alloy owns
the protocol (six commands on a /memories/ tree, return-string formats,
path validation); your code owns the backing store. No bytes touch the
provider's servers.
defmodule MyApp.Memory.Disk do
@behaviour Alloy.Memory
@impl true
def view(store, path), do: # read from disk
@impl true
def create(store, path, text), do: # write
@impl true
def str_replace(store, path, old, new), do: # ...
@impl true
def insert(store, path, line, text), do: # ...
@impl true
def delete(store, path), do: # ...
@impl true
def rename(store, old_path, new_path), do: # ...
end
{:ok, result} = Alloy.run("Remember the user prefers SI units",
provider: {Alloy.Provider.Anthropic, api_key: "sk-ant-...", model: "claude-sonnet-5-5"},
tools: [Alloy.Memory.tool({MyApp.Memory.Disk, root: "/var/agent/memories"})]
)
memory: {MyApp.Memory.Disk, opts} is shorthand for that tool. It is an
ordinary tool named memory: it runs through the tool executor, so
:before_tool_call middleware, tool events and :tool_timeout apply, and
its calls run one at a time in the order the model made them. Every path is
validated against the /memories root before your store sees it. Anthropic
receives it as its native memory_20250818 type (generally available, no
beta header); every other provider gets a function tool with the same six
commands. alloy_agent ships
in-memory and disk stores.
The store term (second element of {module, opts}) is opaque — pass a
keyword list, a map, a pid(), or a struct, whichever your store needs.
Alloy does not bake session scoping into the contract; if you want
per-session memory trees, thread session_id: "..." through your store
opts and namespace inside your implementation.
Reasoning model support (DeepSeek, Kimi, GLM)
OpenAI-compatible reasoning models that return reasoning_content are parsed
into thinking blocks, and the reasoning is sent back on later assistant
messages, which DeepSeek's thinking mode requires during tool calls:
{:ok, result} = Alloy.run("Solve this step by step",
provider: {Alloy.Provider.OpenAICompat,
api_url: "https://api.deepseek.com",
api_key: "...", model: "deepseek-v4-pro"
}
)
# Thinking blocks are preserved in message content
[thinking, text] = hd(result.messages).content
thinking.type #=> "thinking"
thinking.thinking #=> "Step 1: Let me consider..."
text.type #=> "text"
text.text #=> "The answer is 42."
Provider-specific parameters (extra_body)
Pass arbitrary provider-specific parameters via extra_body. It merges last,
so it can override any default field:
{:ok, result} = Alloy.run("Return JSON",
provider: {Alloy.Provider.OpenAICompat,
api_url: "https://api.deepseek.com",
api_key: "...", model: "deepseek-v4-pro",
extra_body: %{
"response_format" => %{"type" => "json_object"},
"temperature" => 0.3
}
}
)
Works for any provider param: reasoning_effort, max_completion_tokens,
presence_penalty, etc.
Telemetry
Alloy emits telemetry events for observability. Attach handlers for OTEL, logging, or custom metrics:
:telemetry.attach_many("my-handler", [
[:alloy, :run, :start],
[:alloy, :run, :stop],
[:alloy, :run, :exception],
[:alloy, :turn, :start],
[:alloy, :turn, :stop],
[:alloy, :turn, :exception],
[:alloy, :provider, :request],
[:alloy, :compaction, :cleared],
[:alloy, :compaction, :done],
[:alloy, :tool, :start],
[:alloy, :tool, :stop],
[:alloy, :event]
], &MyApp.Telemetry.handle_event/4, nil)
| Event | Measurements | Metadata |
|---|---|---|
[:alloy, :run, :start] |
system_time |
model |
[:alloy, :run, :stop] |
duration_ms |
status, turns, model |
[:alloy, :run, :exception] |
duration_ms |
kind, reason, stacktrace, model |
[:alloy, :turn, :start] |
system_time, monotonic_time |
turn, telemetry_span_context |
[:alloy, :turn, :stop] |
duration, monotonic_time |
turn, status, telemetry_span_context |
[:alloy, :turn, :exception] |
duration, monotonic_time |
turn, kind, reason, stacktrace |
[:alloy, :provider, :request] |
duration_ms |
provider, model, streaming, attempt, result |
[:alloy, :compaction, :cleared] |
results_cleared, bytes_cleared |
turn |
[:alloy, :compaction, :done] |
messages_before, messages_after |
turn |
[:alloy, :tool, :start] |
— | tool identity, correlation |
[:alloy, :tool, :stop] |
duration_ms |
tool identity, result |
Each turn's stop fires when that turn ends. A turn the loop continues past
reports status: :running; the run's outcome is on [:alloy, :run, :stop].
Structured output with until_tool
Force the model to call a specific tool before the loop completes. This is more reliable than response format instructions because the tool schema is validated at the API level:
defmodule SubmitAnswer do
@behaviour Alloy.Tool
def name, do: "submit_answer"
def description, do: "Submit your final answer as structured data."
def input_schema do
%{type: "object", properties: %{
answer: %{type: "string"},
confidence: %{type: "number", minimum: 0, maximum: 1}
}, required: ["answer", "confidence"]}
end
def execute(input, _ctx), do: {:ok, "Received: #{input["answer"]}"}
end
{:ok, result} = Alloy.run("What is the capital of France?",
provider: {Alloy.Provider.Anthropic, api_key: "...", model: "claude-sonnet-5-5"},
tools: [SubmitAnswer],
until_tool: "submit_answer"
)
The target tool's call has to succeed: a call that errors, is blocked by
middleware, or names an unknown tool does not satisfy until_tool.
Middleware: editing tool arguments
Edits pass through later middleware; block and halt stop the chain. See loop controls for result hooks, schema validation, successful tool completion, request preparation, and transcript repair.
Middleware can return {:edit, modified_call} from :before_tool_call to rewrite
tool arguments before execution (e.g., policy enforcement, input sanitization):
defmodule SanitizeBash do
@behaviour Alloy.Middleware
def call(:before_tool_call, state) do
call = state.config.context[:current_tool_call]
if call[:name] == "bash" && String.contains?(call[:input]["command"], "rm ") do
{:edit, %{call | input: %{"command" => "echo 'rm commands are blocked'"}}}
else
state
end
end
def call(_hook, state), do: state
end
Supervised agents and async dispatch
A GenServer agent with conversation state, PubSub broadcast for LiveView,
and request queueing lives in the
alloy_agent project
(not yet published to Hex as of this release):
{:ok, agent} = AlloyAgent.start_link(
provider: {Alloy.Provider.Anthropic, api_key: "...", model: "claude-sonnet-5-5"},
tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Edit, Alloy.Tool.Core.Bash]
)
{:ok, response} = AlloyAgent.chat(agent, "What does this project do?")
Without it, Alloy.run/2 inside your own Task.Supervisor or GenServer is
all a supervised agent needs: pass messages: previous_result.messages to
continue a conversation.
Providers
| Vendor | Recommended Module | Example Models |
|---|---|---|
| Anthropic | Alloy.Provider.Anthropic |
claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5 |
| Gemini | Alloy.Provider.Gemini |
gemini-3.8-flash, gemini-3.5-flash-lite |
| OpenAI | Alloy.Provider.OpenAI |
gpt-6.1-sol, gpt-6-astra, gpt-6-luna |
| xAI | Alloy.Provider.XAI |
grok-4.7, grok-4.3 |
| Codex CLI (ChatGPT subscription) | Alloy.Provider.Codex |
any model your Codex login can use |
| Other OpenAI-compatible APIs | Alloy.Provider.OpenAICompat |
DeepSeek deepseek-v4-pro, Moonshot kimi-k2.6, plus Ollama, OpenRouter, Mistral, Groq, Together |
The table lists current example IDs, not a built-in catalog or a live-tested
model allowlist. Models outside the built-in catalog use a 200,000-token
compaction budget; set model_metadata_overrides or a model_catalog for
them. Check provider compatibility for
context overrides, adaptive thinking, and MCP version constraints.
Use Alloy.Provider.OpenAI for OpenAI Responses and Alloy.Provider.XAI for
xAI Responses. Current OpenAI models (GPT-5.4 and later) only call tools with
reasoning on through the Responses API, so use Alloy.Provider.OpenAI rather
than OpenAICompat for them. Use Alloy.Provider.Gemini for Gemini's native
GenerateContent API, and Alloy.Provider.OpenAICompat for chat-completions
compatible APIs and local runtimes: set api_url, model, and optionally
api_key and chat_path.
No built-in provider sends a default output cap except Anthropic, whose API
requires one (16,000 tokens by default). Reasoning counts against the cap on
current models, so set :max_tokens only when you want a hard limit; a
truncated answer completes with result.stop_reason == :max_tokens.
Anthropic thinking. Configure thinking through extra_body (the
extended_thinking option was removed in 0.13; Claude 4.7 and later reject
manual budgets anyway):
{Alloy.Provider.Anthropic,
api_key: "...",
model: "claude-sonnet-5-5",
extra_body: %{"thinking" => %{"type" => "adaptive", "display" => "summarized"}}}
Codex. Alloy.Provider.Codex drives the codex CLI (0.122.0 or later)
with your ChatGPT login and reports token usage. It uses your CODEX_HOME,
including its AGENTS.md and skills. For agents, log in to a dedicated home
(CODEX_HOME=~/.codex-alloy codex login) and pass codex_home: — that also
keeps concurrent agents from refreshing the same token as your interactive
Codex.
Built-in Tools
| Tool | Module | Description |
|---|---|---|
| read | Alloy.Tool.Core.Read |
Read text files, paged with offset/limit; refuses binary files |
| write | Alloy.Tool.Core.Write |
Write files to disk |
| edit | Alloy.Tool.Core.Edit |
Exact search-and-replace; keeps CRLF line endings and BOMs |
| bash | Alloy.Tool.Core.Bash |
Execute shell commands (restricted shell by default) |
Tool calls run in the order the model made them; consecutive calls to
concurrency-safe tools run in parallel. Input that is not a map, or is
missing a schema's required keys, is returned to the model as an error
before execute/2 runs.
Bash is a guardrail, not a sandbox. Restricted mode (
bash -r) blockscd,PATHchanges, and redirection in the top-level shell only — a nestedbash -cor any interpreter onPATH(python3 -c,perl -e) gives full capability, so treat it as a speed bump against accidents, not an isolation boundary. What bash does guarantee: on timeout it kills the command's whole process group; output is capped while it streams; and variables whose names containKEY,TOKEN,SECRET,PASSWORDorCREDENTIALare not passed to commands (set:bash_envin context to choose the environment, or:inheritfor all of it). For real isolation, supply your own:bash_executorin context (container, jail, firejail, or a remote runner).:allowed_pathsrestricts the read, write and edit tools to those directories (symlinks resolved); it does not restrict bash. Treat the agent's shell access as you would a contractor's laptop on your network.
Custom tools
defmodule MyApp.Tools.WebSearch do
@behaviour Alloy.Tool
@impl true
def name, do: "web_search"
@impl true
def description, do: "Search the web for information"
@impl true
def input_schema do
%{
type: "object",
properties: %{query: %{type: "string", description: "Search query"}},
required: ["query"],
additionalProperties: false
}
end
@impl true
def strict?, do: true
@impl true
def execute(%{"query" => query}, _context) do
# Your implementation here
{:ok, "Results for: #{query}"}
end
end
Prefer strict mode for tools that have stable schemas. Set strict?/0 on a
module tool, or strict: true on an inline tool, and include
additionalProperties: false in the top-level JSON Schema. Alloy validates
that requirement instead of silently rewriting your schema. OpenAI strict mode
also requires every property to be listed in required.
For Anthropic advanced tool use, add input_examples/0 or input_examples:
with representative argument maps to improve parameter selection. Set
defer_loading?/0 or defer_loading: true for tools that should be exposed
through Anthropic's deferred-loading path; other providers ignore these fields.
Inline tools
For one-off tools, or tools discovered at runtime (an MCP server's tool
list, user-defined actions from a database), define a tool as data with
Alloy.Tool.inline/1 — no module needed. Inline tools and tool modules
mix freely in tools::
weather =
Alloy.Tool.inline(
name: "get_weather",
description: "Get current weather for a location",
input_schema: %{
type: "object",
properties: %{location: %{type: "string"}},
required: ["location"]
},
execute: fn %{"location" => loc}, _context ->
{:ok, MyApp.Weather.current(loc)}
end
)
{:ok, result} = Alloy.run("What's the weather in Sydney?",
provider: provider,
tools: [weather, Alloy.Tool.Core.Read]
)
Optional fields mirror the behaviour's optional callbacks: concurrent?:,
max_result_chars:, allowed_callers:, result_type:. See
Alloy.Tool.Inline for details.
Code execution (Anthropic)
Enable Anthropic's server-side code execution sandbox:
{:ok, result} = Alloy.run("Calculate the first 20 Fibonacci numbers",
provider: {Alloy.Provider.Anthropic, api_key: "...", model: "claude-sonnet-5-5"},
code_execution: true
)
Anthropic runs server tools itself, so Alloy never answers their calls. To let
Claude call one of your tools from inside code execution (programmatic tool
calling), set allowed_callers: [:code_execution] on the tool; Alloy keeps
the code-execution container across turns in provider_state. Add other
server tools — web search, web fetch, mcp_toolset, tool search — with the
provider's :server_tools option, alongside your own tools:
{Alloy.Provider.Anthropic,
api_key: "...",
model: "claude-sonnet-5-5",
server_tools: [%{type: "web_search_20260209", name: "web_search", max_uses: 5}]}
Architecture
Alloy.run/2 One-shot agent loop (pure function)
Alloy.Agent.Turn Single turn: call provider → execute tools → return
Alloy.Provider Behaviour: translate wire format ↔ Alloy.Message
Alloy.Tool Behaviour: name, description, input_schema, execute
Alloy.Middleware Pipeline: custom hooks, tool blocking
Alloy.Context.Compactor Compaction middleware (on by default)
Alloy.Memory Memory store behaviour and the memory tool
The supervised runtime (GenServer agents, PubSub, sessions, memory stores)
is the separate alloy_agent
package.
Sessions, persistence, multi-agent coordination, scheduling, skills, and UI belong in your application layer. See Anvil for a reference Phoenix application built on Alloy.
License
MIT — see LICENSE.
Releases
Hex.pm publishing is handled by GitHub Actions on v* tags.
When ALLOY_WEBSITE_SYNC_TOKEN is configured, successful publishes also
dispatch the landing-site version sync workflow. The token needs Contents:
write on alloy-ex/alloy-ex.github.io; without it the dispatch is skipped
with a warning. GitHub Release entries are separate from Hex publishing.
Check Hex for published package versions.