A simple, process-oriented agent framework for Elixir inspired by Claude Code's architecture.
The best agent framework is barely a framework at all.
Clementine rejects the over-abstraction of LangChain-style frameworks in favor of the pattern that actually works in production: a simple loop with tools.
gather context → act → verify → repeat
This is how Claude Code works. This is how humans work. This is how Clementine works.
Add clementine to your list of dependencies in mix.exs:
def deps do
[
{:clementine, "~> 0.1.0"}
]
end# config/config.exs
config :clementine,
api_key: {:system, "ANTHROPIC_API_KEY"}defmodule MyApp.CodingAgent do
use Clementine.Agent,
name: "coding_agent",
model: :claude_sonnet,
tools: [
Clementine.Tools.ReadFile,
Clementine.Tools.WriteFile,
Clementine.Tools.Bash
],
system: """
You are an expert Elixir developer. You have access to the filesystem
and can run commands. Always verify your changes by running tests.
"""
end{:ok, agent} = MyApp.CodingAgent.start_link()
{:ok, result} = Clementine.run(agent, "Add a fibonacci function to lib/math.ex")Tools are functions the agent can call. They're defined with a simple macro:
defmodule MyApp.Tools.Weather do
use Clementine.Tool,
name: "get_weather",
description: "Get the current weather for a location",
parameters: [
location: [type: :string, required: true, description: "City name"]
]
@impl true
def run(%{location: location}, _context) do
# Fetch weather...
{:ok, "72°F and sunny in #{location}"}
end
endTool results are always strings. The LLM consumes text. Don't make it complicated.
Verifiers are optional checks that run after the model returns a final response:
defmodule MyApp.Verifiers.TestsPassing do
use Clementine.Verifier
@impl true
def verify(_result, context) do
case System.cmd("mix", ["test"], cd: context.working_dir) do
{_, 0} -> :ok
{output, _} -> {:retry, "Tests failed:\n#{output}"}
end
end
endWhen a verifier returns {:retry, reason}, the reason is fed back to the model and the loop continues.
Agents are GenServers that run the agentic loop:
defmodule MyApp.Agent do
use Clementine.Agent,
name: "my_agent",
model: :claude_sonnet,
tools: [MyApp.Tools.Weather],
verifiers: [MyApp.Verifiers.TestsPassing],
system: "You are a helpful assistant."
end# Synchronous
{:ok, result} = Clementine.run(agent, "What's the weather?")
# Asynchronous
{:ok, task_id} = Clementine.run_async(agent, "Long running task")
{:ok, :running} = Clementine.status(agent, task_id)Stream responses token-by-token as they arrive from the LLM:
Clementine.Loop.run_stream(
[
model: :claude_sonnet,
system: "You have tools. Use them when asked. Be brief.",
tools: [Clementine.Tools.ListDir]
],
"What files are in the current directory?",
fn
{:text_delta, text} -> IO.write(text)
{:tool_use_start, _id, name} -> IO.write("\n[calling #{name}...] ")
{:tool_result, _id, {:ok, result}} -> IO.puts("[got #{String.length(result)} chars]")
{:tool_result, _id, {:error, err}} -> IO.puts("[error: #{err}]")
_ -> :ok
end
)The callback receives events as they happen:
| Event | Description |
|---|---|
{:text_delta, text} |
A chunk of text from the model |
{:tool_use_start, id, name} |
Model is calling a tool |
{:input_json_delta, json} |
Tool input JSON chunk |
{:tool_result, id, result} |
Tool finished executing |
{:loop_event, event} |
Internal loop events |
defmodule MyAppWeb.ChatLive do
use MyAppWeb, :live_view
def handle_event("submit", %{"prompt" => prompt}, socket) do
pid = self()
Task.start(fn ->
Clementine.Loop.run_stream(
[model: :claude_sonnet, tools: [Clementine.Tools.Bash]],
prompt,
fn
{:text_delta, text} -> send(pid, {:stream, text})
{:tool_use_start, _, name} -> send(pid, {:tool, name})
_ -> :ok
end
)
send(pid, :done)
end)
{:noreply, assign(socket, :streaming, true)}
end
def handle_info({:stream, text}, socket) do
{:noreply, update(socket, :response, &(&1 <> text))}
end
def handle_info({:tool, name}, socket) do
{:noreply, assign(socket, :current_tool, name)}
end
def handle_info(:done, socket) do
{:noreply, assign(socket, :streaming, false)}
end
end# Get history
history = Clementine.get_history(agent)
# Clear history (start fresh)
Clementine.clear_history(agent)
# Fork (create new agent with same history)
{:ok, forked} = Clementine.fork(agent, MyApp.Agent)| Tool | Description |
|---|---|
Clementine.Tools.ReadFile |
Read file contents with optional line ranges |
Clementine.Tools.WriteFile |
Write/create files |
Clementine.Tools.ListDir |
List directory contents |
Clementine.Tools.Bash |
Execute shell commands |
Clementine.Tools.Search |
Grep-like content search |
# config/config.exs
config :clementine,
api_key: {:system, "ANTHROPIC_API_KEY"},
default_model: :claude_sonnet,
max_iterations: 10,
timeout: :timer.minutes(5),
retry: [
max_attempts: 3,
base_delay: 1000,
max_delay: 30_000
]
config :clementine, :models,
claude_sonnet: [
provider: :anthropic,
model: "claude-sonnet-4-20250514",
max_tokens: 8192
],
claude_opus: [
provider: :anthropic,
model: "claude-opus-4-20250514",
max_tokens: 8192
]- Simpler: One loop pattern, not chains of abstractions
- Transparent: Direct API calls, no middleware maze
- Elixir-native: Built on OTP, not ported from Python
- Tool schema generation: Define params in Elixir, get JSON Schema
- Verification built-in: Retry on failure without custom logic
- Process isolation: Tool crashes don't kill the agent
- Supervised execution: OTP patterns for reliability
Every Clementine agent runs the same core loop:
┌─────────────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Gather │───▶│ Act │───▶│ Verify │───┐ │
│ │ Context │ │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │
│ ▲ │ │
│ └───────────────────────────────────────┘ │
│ │
│ until done │
└─────────────────────────────────────────────────────────┘
Gather: Tools fetch files, search, query APIs Act: LLM processes context, calls tools or returns response Verify: Verifiers check output, trigger retry if needed
The loop continues until:
- The model returns a final response (no tool calls) and verifiers pass
- Max iterations reached
- An unrecoverable error occurs
MIT