Alloy

Hex.pmCIDocsLicense

Model-agnostic agent harness for Elixir.

Alloy gives you the agent loop: send messages to any LLM, execute tool calls, loop until done. Swap providers with one line. Run agents as supervised GenServers. Build multi-agent teams with fault isolation. Zero framework lock-in.

{: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.2"},
  tools: [Alloy.Tool.Core.Read]
)

result.text #=> "The version is 0.1.0"

Why Alloy?

Most agent frameworks target Python and single-script usage. Alloy targets what happens after — when you need agents running in production with supervision, fault isolation, concurrency, and multi-agent orchestration.

Installation

Add alloy to your dependencies in mix.exs:

def deps do
  [
    {:alloy, "~> 0.5"}
  ]
end

Quick Start

Simple completion

{:ok, result} = Alloy.run("What is 2+2?",
  provider: {Alloy.Provider.Anthropic, api_key: "sk-ant-...", model: "claude-sonnet-4-6"}
)

result.text #=> "4"

Agent with tools

{:ok, result} = Alloy.run("Read mix.exs and summarize the dependencies",
  provider: {Alloy.Provider.Google, api_key: "...", model: "gemini-2.5-flash"},
  tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Bash],
  max_turns: 10
)

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-4-6"}} | opts])

# OpenAI
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-5.2"}} | opts])

# Local (Ollama — no API key needed)
Alloy.run("Read mix.exs", [{:provider, {Alloy.Provider.Ollama, model: "llama4"}} | opts])

Streaming

Stream tokens as they arrive — works with every provider:

{:ok, agent} = Alloy.Agent.Server.start_link(
  provider: {Alloy.Provider.OpenAI, api_key: "...", model: "gpt-5.2"},
  tools: [Alloy.Tool.Core.Read]
)

{:ok, result} = Alloy.Agent.Server.stream_chat(agent, "Explain OTP", fn chunk ->
  IO.write(chunk)  # Print each token as it arrives
end)

All 8 providers support streaming. If a custom provider doesn't implement stream/4, the turn loop falls back to complete/3 automatically.

For Anthropic extended thinking with streaming events:

{:ok, agent} = Alloy.Agent.Server.start_link(
  provider: {Alloy.Provider.Anthropic,
    api_key: "...",
    model: "claude-opus-4-6",
    extended_thinking: [budget_tokens: 5000]
  }
)

on_event = fn
  %{v: 1, event: :text_delta, payload: chunk} ->
    IO.write(chunk)

  %{v: 1, event: :thinking_delta, payload: chunk} ->
    IO.write("[thinking: #{chunk}]")

  %{v: 1, event: :tool_start, seq: seq, correlation_id: corr, payload: %{name: name, input: input}} ->
    IO.puts("\n[tool:start ##{seq} #{corr}] #{name} #{inspect(input)}")

  %{v: 1, event: :tool_end, seq: seq, correlation_id: corr, payload: %{name: name, duration_ms: ms, error: nil}} ->
    IO.puts("[tool:end ##{seq} #{corr}] #{name} (#{ms}ms)")

  %{v: 1, event: :tool_end, seq: seq, correlation_id: corr, payload: %{name: name, error: error}} ->
    IO.puts("[tool:end ##{seq} #{corr}] #{name} error=#{inspect(error)}")
end

{:ok, result} = Alloy.Agent.Server.stream_chat(agent, "Solve this step by step",
  fn _chunk -> :ok end,
  on_event: on_event
)

Supervised GenServer agent

{:ok, agent} = Alloy.Agent.Server.start_link(
  provider: {Alloy.Provider.Anthropic, api_key: "...", model: "claude-sonnet-4-6"},
  tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Edit, Alloy.Tool.Core.Bash],
  system_prompt: "You are a senior Elixir developer."
)

{:ok, response} = Alloy.Agent.Server.chat(agent, "What does this project do?")
{:ok, response} = Alloy.Agent.Server.chat(agent, "Now refactor the main module")

Async dispatch (Phoenix LiveView)

Fire a message without blocking the caller — ideal for LiveView and background jobs:

# Subscribe to receive the result
Phoenix.PubSub.subscribe(Alloy.PubSub, "agent:#{agent_id}")

# Returns {:ok, request_id} immediately — agent works in the background
{:ok, req_id} = Alloy.Agent.Server.send_message(agent, "Summarise this report",
  request_id: "req-123"
)

# Handle the result whenever it arrives
def handle_info({:agent_response, %{text: text, request_id: "req-123"}}, socket) do
  {:noreply, assign(socket, :response, text)}
end

Optional backpressure and cancellation:

{:ok, agent} = Alloy.Agent.Server.start_link(
  provider: {...},
  pubsub: MyApp.PubSub,
  max_pending: 10
)

{:ok, request_id} = Alloy.Agent.Server.send_message(agent, "Long task")
:ok = Alloy.Agent.Server.cancel_request(agent, request_id)

Multi-agent teams

{:ok, team} = Alloy.Team.start_link(
  agents: [
    researcher: [
      provider: {Alloy.Provider.Google, api_key: "...", model: "gemini-2.5-flash"},
      system_prompt: "You are a research assistant."
    ],
    coder: [
      provider: {Alloy.Provider.Anthropic, api_key: "...", model: "claude-sonnet-4-6"},
      tools: [Alloy.Tool.Core.Read, Alloy.Tool.Core.Write, Alloy.Tool.Core.Edit],
      system_prompt: "You are a senior developer."
    ]
  ]
)

# Delegate tasks to specific agents
{:ok, research} = Alloy.Team.delegate(team, :researcher, "Find the latest Elixir release notes")
{:ok, code} = Alloy.Team.delegate(team, :coder, "Write a GenServer that #{research.text}")

Providers

Provider Module API Key Env Var Example Model
Anthropic Alloy.Provider.AnthropicANTHROPIC_API_KEYclaude-sonnet-4-6
OpenAI Alloy.Provider.OpenAIOPENAI_API_KEYgpt-5.2
Google Gemini Alloy.Provider.GoogleGEMINI_API_KEYgemini-2.5-flash
Ollama Alloy.Provider.Ollama(none — local)llama4
OpenRouter Alloy.Provider.OpenRouterOPENROUTER_API_KEYanthropic/claude-sonnet-4-6
xAI (Grok) Alloy.Provider.XAIXAI_API_KEYgrok-3
DeepSeek Alloy.Provider.DeepSeekDEEPSEEK_API_KEYdeepseek-chat
Mistral Alloy.Provider.MistralMISTRAL_API_KEYmistral-large-latest

Adding a provider is ~200 lines implementing the Alloy.Provider behaviour.

CLI

Alloy ships with an interactive REPL:

# Interactive mode
mix alloy
mix alloy --provider gemini --tools read,bash

# One-shot mode
mix alloy -p "What is the capital of France?"
mix alloy -p "Read mix.exs" --tools read --provider openai

Built-in Tools

Tool Module Description
readAlloy.Tool.Core.Read Read files from disk
writeAlloy.Tool.Core.Write Write files to disk
editAlloy.Tool.Core.Edit Search-and-replace editing
bashAlloy.Tool.Core.Bash Execute shell commands
scratchpadAlloy.Tool.Core.Scratchpad Persistent key-value notepad

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"]
    }
  end

  @impl true
  def execute(%{"query" => query}, _context) do
    # Your implementation here
    {:ok, "Results for: #{query}"}
  end
end

Architecture

Alloy.run/2                    One-shot agent loop (pure function)
Alloy.Agent.Server             GenServer wrapper (stateful, supervisable)
Alloy.Team                     Multi-agent supervisor (delegate, broadcast, handoff)
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: logger, telemetry, custom hooks
Alloy.Context.Compactor        Automatic conversation summarization
Alloy.Scheduler                Cron/heartbeat for recurring agent runs

License

MIT — see LICENSE.