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:
- You control the loop — Pattern match on struct types. Decide what happens next.
- Swap anything — Backends, providers, models. Same interface.
- See everything — Hooks and telemetry at every stage.
- Test everything — Capture trajectories. Apply graders.
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: trueis 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:
on_call_start/3— Before LLM call (can transform content or halt)on_call_end/3— After successful call (can transform response)on_call_error/3— On call failureon_stream_start/3,on_stream_chunk/3,on_stream_end/2— Stream lifecycleon_backend_request/2,on_backend_response/2— Backend request/responseon_compaction_start/3,on_compaction_end/2— Compaction lifecycle
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:
- Lua by TV Labs - Ergonomic Elixir interface to Luerl
- Luerl by Robert Virding - Lua VM implemented in Erlang
- ReqLLM - Multi-provider LLM client for Elixir
- BAML - Type-safe structured outputs for LLMs
License
Apache License 2.0