Puck

AI Agent primitives for Elixir.

The best AI agents shipped to production share a secret: they're just LLMs calling tools in a loop. Puck gives you the primitives to build exactly that.

client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"})
{:ok, response, _ctx} = Puck.call(client, "Hello!")

One function. Any provider. Build whatever you want on top.

The Primitives

Seven building blocks. Compose them however you need:

Primitive Purpose
Puck.Client Configure backend, model, system prompt
Puck.Context Multi-turn conversation state
Puck.call/4 One function to call any LLM
Puck.Hooks Observe and transform at every stage
Puck.Compaction Handle long conversations
Puck.Eval Capture trajectories, grade outputs
Puck.Sandbox Execute LLM-generated code safely

No orchestration. No hidden control flow. You write the loop.

Why Primitives?

Most LLM libraries are frameworks. They give you abstractions that work until they don't—then you fight the framework.

Puck takes the opposite approach:

Quick Start

Structured Outputs

Define action structs. Create a union schema. Pattern match:

defmodule LookupContact do
defstruct type: "lookup_contact", name: nil
end
defmodule CreateTask do
defstruct type: "create_task", title: nil, due_date: nil
end
defmodule Done do
defstruct type: "done", message: nil
end
def schema do
Zoi.union([
Zoi.struct(LookupContact, %{
type: Zoi.literal("lookup_contact"),
name: Zoi.string(description: "Contact name to find")
}, coerce: true),
Zoi.struct(CreateTask, %{
type: Zoi.literal("create_task"),
title: Zoi.string(description: "Task title"),
due_date: Zoi.string(description: "Due date")
}, coerce: true),
Zoi.struct(Done, %{
type: Zoi.literal("done"),
message: Zoi.string(description: "Final response to user")
}, coerce: true)
])
end

Note: coerce: true is required because LLM backends return raw maps. This tells Zoi to convert the map into your struct.

Build an Agent Loop

defp loop(client, input, ctx) do
{:ok, %{content: action}, ctx} = Puck.call(client, input, ctx, output_schema: schema())
case action do
%Done{message: msg} -> {:ok, msg}
%LookupContact{name: name} -> loop(client, CRM.find(name), ctx)
%CreateTask{} = task -> loop(client, CRM.create(task), ctx)
end
end

That's it. Pattern match on struct types. Works with any backend.

Installation

def deps do
[
{:puck, "~> 0.2.0"}
]
end

Most features require optional dependencies. Add only what you need:

def deps do
[
{:puck, "~> 0.2.0"},
# LLM backends (pick one or more)
{:req_llm, "~> 1.0"}, # Multi-provider LLM support
{:baml_elixir, "~> 1.0"}, # Structured outputs with BAML
# Optional features
{:solid, "~> 0.15"}, # Liquid template syntax
{:telemetry, "~> 1.2"}, # Observability
{:zoi, "~> 0.7"}, # Schema validation for structured outputs
{:lua, "~> 0.4.0"} # Lua sandbox for code execution
]
end

Backends

ReqLLM

Multi-provider LLM support. Model format is "provider:model":

client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"})
# With options
client = Puck.Client.new({Puck.Backends.ReqLLM, model: "anthropic:claude-sonnet-4-5", temperature: 0.7})

Supports Anthropic, OpenAI, Google, OpenRouter, AWS Bedrock. See ReqLLM documentation for details.

BAML

For structured outputs and agentic patterns:

client = Puck.Client.new({Puck.Backends.Baml, function: "ExtractPerson"})
{:ok, result, _ctx} = Puck.call(client, "John is 30 years old")

Runtime Client Registry

Configure LLM providers at runtime without hardcoding credentials:

registry = %{
"clients" => [
%{
"name" => "MyClient",
"provider" => "anthropic",
"options" => %{"model" => "claude-sonnet-4-5"}
}
],
"primary" => "MyClient"
}
client = Puck.Client.new(
{Puck.Backends.Baml, function: "ExtractPerson", client_registry: registry}
)

See BAML Client Registry docs for supported providers.

Mock

For deterministic tests:

client = Puck.Client.new({Puck.Backends.Mock, response: "Test response"})
{:ok, response, _ctx} = Puck.call(client, "Hello!")

Context

Multi-turn conversations with automatic state management:

client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"},
system_prompt: "You are a helpful assistant."
)
context = Puck.Context.new()
{:ok, resp1, context} = Puck.call(client, "What is Elixir?", context)
{:ok, resp2, context} = Puck.call(client, "How is it different from Ruby?", context)

Compaction

Long conversations can exceed context limits. Handle this automatically:

# Summarize when token threshold exceeded
client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"},
auto_compaction: {:summarize, max_tokens: 100_000, keep_last: 5}
)
# Sliding window (keeps last N messages)
client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"},
auto_compaction: {:sliding_window, window_size: 30}
)

Or compact manually:

{:ok, compacted} = Puck.Context.compact(context, {Puck.Compaction.SlidingWindow, %{
window_size: 20
}})

Hooks

Observe and transform at every stage—without touching business logic:

defmodule MyApp.LoggingHooks do
@behaviour Puck.Hooks
require Logger
@impl true
def on_call_start(_client, content, _context) do
Logger.info("LLM call: #{inspect(content, limit: 50)}")
{:cont, content}
end
@impl true
def on_call_end(_client, response, _context) do
Logger.info("Response: #{response.usage.output_tokens} tokens")
{:cont, response}
end
end
client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"},
hooks: MyApp.LoggingHooks
)

Available hooks:

Eval

Primitives for evaluating agents. Capture what happened. Grade the results.

Capture Trajectory

Every Puck.call and Puck.stream becomes a step:

alias Puck.Eval.{Collector, Graders}
{output, trajectory} = Collector.collect(fn ->
MyAgent.run("Find John's email")
end)
trajectory.total_steps # => 2
trajectory.total_tokens # => 385
trajectory.total_duration_ms # => 1250

Streaming responses are also captured, with step.metadata[:streamed] == true.

Apply Graders

result = Puck.Eval.grade(output, trajectory, [
Graders.contains("john@example.com"),
Graders.max_steps(5),
Graders.max_tokens(10_000)
])
result.passed? # => true

Built-in Graders

# Output graders
Graders.contains("substring")
Graders.matches(~r/pattern/)
Graders.equals(expected)
Graders.satisfies(fn x -> ... end)
# Trajectory graders
Graders.max_steps(n)
Graders.max_tokens(n)
Graders.max_duration_ms(n)
# Step output graders
Graders.output_produced(LookupContact)
Graders.output_produced(LookupContact, times: 2)
Graders.output_matches(fn %LookupContact{name: "John"} -> true; _ -> false end)
Graders.output_not_produced(DeleteContact)
Graders.output_sequence([Search, Confirm, Done])

Custom Graders

Graders are just functions:

my_grader = fn output, trajectory ->
if trajectory.total_tokens < 1000 do
:pass
else
{:fail, "Used #{trajectory.total_tokens} tokens, expected < 1000"}
end
end

Sandbox

Execute LLM-generated code safely with callbacks to your application:

alias Puck.Sandbox.Eval
{:ok, result} = Eval.eval(:lua, """
local products = search("laptop")
local cheap = {}
for _, p in ipairs(products) do
if p.price < 1000 then table.insert(cheap, p) end
end
return cheap
""", callbacks: %{
"search" => &MyApp.Products.search/1
})

Use Puck.Sandbox.Eval.Lua.schema/1 to let LLMs generate Lua code as a structured output. Requires {:lua, "~> 0.4.0"}.

Telemetry

Events are emitted automatically when :telemetry is installed:

Puck.Telemetry.attach_default_logger(level: :info)
# Or attach your own handler
:telemetry.attach_many("my-handler", Puck.Telemetry.event_names(), &handler/4, nil)
Event Description
[:puck, :call, :start] Before LLM call
[:puck, :call, :stop] After successful call
[:puck, :call, :exception] On call failure
[:puck, :stream, :start] Before streaming
[:puck, :stream, :chunk] Each streamed chunk
[:puck, :stream, :stop] After streaming completes
[:puck, :compaction, :start] Before compaction
[:puck, :compaction, :stop] After compaction

More Examples

Streaming

client = Puck.Client.new({Puck.Backends.ReqLLM, "anthropic:claude-sonnet-4-5"})
{:ok, stream, _ctx} = Puck.stream(client, "Tell me a story")
Enum.each(stream, fn chunk ->
IO.write(chunk.content)
end)

Multi-modal

alias Puck.Content
{:ok, response, _ctx} = Puck.call(client, [
Content.text("What's in this image?"),
Content.image_url("https://example.com/photo.png")
])
# Or with binary data
image_bytes = File.read!("photo.png")
{:ok, response, _ctx} = Puck.call(client, [
Content.text("Describe this image"),
Content.image(image_bytes, "image/png")
])

Few-shot Prompting

{:ok, response, _ctx} = Puck.call(client, [
%{role: :user, content: "Translate: Hello"},
%{role: :assistant, content: "Hola"},
%{role: :user, content: "Translate: Goodbye"}
])

Acknowledgments

Puck builds on excellent open source projects:

License

Apache License 2.0