Claudex

An Elixir SDK for the Claude API: messages, streaming, tools, files and batches.

Installation

Add :claudex to your dependencies:

def deps do
[
{:claudex, "~> 0.6"}
]
end

Documentation is on HexDocs.

Quick start

client = Claudex.new(api_key: "sk-ant-...")
{:ok, message} =
Claudex.Messages.create(client, %{
model: "claude-opus-5",
max_tokens: 1024,
messages: [Claudex.Message.user("Hello, Claude")]
})
Claudex.Message.text(message)
#=> "Hello! How can I help you today?"

Claudex.Message.user/1, assistant/1 and tool_results/1 build the message maps, so you don't have to remember which role a tool result goes back under (it's user), and append/2 adds them to a history. The system prompt is the :system request parameter rather than a message — the API rejects a role: "system" entry at the start of messages.

A failed request returns {:error, %Claudex.Error{}} rather than raising — see Claudex.Error for the full set of error types.

Configuration

Connection settings can live in application config, so an app names them once:

# config/runtime.exs
config :claudex,
api_key: System.fetch_env!("ANTHROPIC_API_KEY"),
base_url: "https://api.anthropic.com",
max_retries: 2,
receive_timeout: :timer.minutes(10),
connect_timeout: :timer.seconds(5),
beta: ["files-api-2025-04-14"],
req_options: [finch: MyApp.Finch]
client = Claudex.new() # picks all of that up
scoped = Claudex.new(api_key: other_key) # one override, the rest from config

Anything passed to Claudex.new/1 wins over config. :api_key has one more fallback below config — the ANTHROPIC_API_KEY environment variable — so scripts and iex work with nothing configured at all.

Streaming

stream!/2 returns a lazy stream of events. Nothing is sent until you enumerate it, and the process that enumerates owns the request — it only reads more of the response when you ask for the next event, so a slow consumer slows the download rather than filling a mailbox.

alias Claudex.Stream.Event
client
|> Claudex.Messages.stream!(%{
model: "claude-opus-5",
max_tokens: 1024,
messages: [%{role: "user", content: "Write a haiku about Erlang"}]
})
|> Enum.each(fn
%Event.ContentBlockDelta{delta: {:text, chunk}} -> IO.write(chunk)
_event -> :ok
end)

Stop enumerating (Enum.take/2, an exception, a break) and the request is cancelled. Want the finished message instead of the events? Claudex.Stream.final_message/1 folds them back into the same %Claudex.Message{} a non-streaming call returns, and Claudex.Stream.text/1 gives you just the reply text:

{:ok, text} = client |> Claudex.Messages.stream!(params) |> Claudex.Stream.text()

Streaming to a process

A GenServer or LiveView can't sit and block on a stream, so stream_to/3 runs it in its own process and sends you messages:

{:ok, handle} = Claudex.Messages.stream_to(client, params)
def handle_info({:claudex, ref, {:event, %Event.ContentBlockDelta{delta: {:text, chunk}}}}, socket)
when ref == socket.assigns.handle.ref do
{:noreply, stream_insert(socket, :chunks, chunk)}
end
def handle_info({:claudex, ref, :done}, socket), do: {:noreply, assign(socket, streaming: false)}
def handle_info({:claudex, ref, {:error, error}}, socket), do: {:noreply, put_error(socket, error)}

The forwarding process is linked to the caller, so it goes away with your LiveView. Claudex.Stream.cancel/1 stops it early. Errors arrive as {:error, %Claudex.Error{}} messages rather than raising — that's the difference from stream!/2, which raises.

Tool use

Tag a function with @tool and Claudex builds the JSON schema from its @spec and @doc:

defmodule MyApp.Tools do
use Claudex.Tool
@doc "Adds two numbers."
@tool true
@spec add(number(), number()) :: number()
def add(a, b), do: a + b
end
{:ok, message} =
Claudex.Messages.create(client, %{
model: "claude-opus-5",
max_tokens: 1024,
tools: MyApp.Tools,
messages: [%{role: "user", content: "What's 12 plus 30?"}]
})

tools: accepts the module directly — Claudex.Messages.create/2 expands it for you. It also accepts a list mixing modules with plain tool maps.

When Claude wants a tool, you usually shouldn't drive that by hand — Claudex.ToolRunner runs the whole conversation:

{:ok, turn} =
Claudex.ToolRunner.run(client, %{
model: "claude-opus-5",
max_tokens: 1024,
tools: MyApp.Tools,
messages: [Claudex.Message.user("What's 12 plus 30?")]
})
Claudex.Message.text(turn.message) # the final reply
turn.messages # the whole conversation
turn.stop # :completed | :refusal | :max_turns

It sends the request, runs whatever Claude asks for, sends the results back, and repeats until Claude stops asking. turn.messages is the whole conversation, ready to append to for the next turn — decoded Claudex structs go straight back in, no conversion. Match on turn.stop rather than assuming Claude finished: hitting the turn limit looks identical without it.

For one turn at a time, stream/3 yields a Claudex.ToolRunner.Turn per reply and you decide when to stop:

client
|> Claudex.ToolRunner.stream(params)
|> Enum.reduce_while(nil, fn turn, _last ->
if interesting?(turn), do: {:cont, turn}, else: {:halt, turn}
end)

A tool decides for itself what it will and won't do — raise Claudex.Tool.Error and Claude sees the reason and adapts:

defmodule MyApp.Tools do
use Claudex.Tool
@doc "Reads a file from the workspace."
@tool true
@spec read_file(String.t()) :: String.t()
def read_file(path) do
unless allowed?(path), do: raise Claudex.Tool.Error, "path is outside the workspace"
File.read!(path)
end
end

Any other exception becomes an error result too, labelled as a failure and logged, so a bug in a tool doesn't read to Claude like a policy decision and doesn't end the conversation. If you'd rather drive the loop yourself, Claudex.Tool.call/3 runs one tool and Claudex.Tool.result/3 builds the block to send back.

A struct or Ecto schema in a @spec (@spec summarize(Ticket.t()) :: String.t()) expands into a nested object schema automatically. A type Claudex can't map raises Claudex.Tool.SchemaError at compile time; pass args_schema: in the @tool options to describe it yourself. See the Claudex.Tool and Claudex.Tool.Schema.StructExpansion module docs for the full picture.

Seeing what the SDK is doing

Claudex writes nothing to your logs on its own. It emits :telemetry events, and ships a logger you can turn on in one line while debugging:

Claudex.Telemetry.attach_default_logger()
[debug] claudex POST /v1/messages claude-opus-5 → 200 in 890.4ms (8 in / 16 out) request_id=req_011CQ...
[debug] claudex tool add → ok in 0.1ms
[debug] claudex tool_runner turn 1 → 1 tool call(s)
[debug] claudex tool_runner finished after 2 turn(s): completed

Claudex.Telemetry.detach_default_logger/0 turns it off. For production, attach your own handler with :telemetry.attach_many/4 and send the events to metrics or tracing instead.

The events cover the things you can't otherwise see: the model and request_id behind each call (Claudex keeps the request id only on errors), streams that ended early, tool outcomes, why a tool conversation stopped, and retries Claudex declined because part of a response had already been delivered. Metadata carries model names, status, token counts, durations and tool names — never prompts, completions, tool arguments, or anything from your client.

Models and token counting

{:ok, page} = Claudex.Models.list(client, limit: 5)
Enum.map(page.data, & &1.id)
#=> ["claude-opus-5", "claude-sonnet-5", ...]
{:ok, model} = Claudex.Models.retrieve(client, "claude-opus-latest")
model.id #=> "claude-opus-5"
model.max_tokens #=> 128000

list/2 returns one %Claudex.Page{}. If page.has_more is true, pass after_id: page.last_id for the next one.

To find out what a request will cost before sending it:

{:ok, tokens} =
Claudex.Messages.count_tokens(client, %{
model: "claude-opus-5",
messages: [%{role: "user", content: "Hello, Claude"}]
})

This endpoint takes :model and :messages — no :max_tokens — and counts the input only: your messages, system prompt, and tools. :tools accepts a module here too.

Files

Upload a file once and reference it by file_id instead of re-sending the bytes on every request:

{:ok, file} = Claudex.Files.upload(client, "report.pdf")
Claudex.Messages.create(client, %{
model: "claude-opus-5",
max_tokens: 1024,
messages: [%{role: "user", content: [
%{type: "document", source: %{type: "file", file_id: file.id}},
%{type: "text", text: "Summarise this."}
]}]
})

Images and PDFs don't need this — inline base64 and URL sources work today, because create/2 passes content blocks through verbatim. Files saves upload time and request size (500 MB per file vs. the 32 MB request limit), not tokens: the content still enters the context window and is still billed.

download/2 is the other direction, and the part with no inline equivalent — it retrieves files Claude created through skills or the code execution tool. Files you uploaded have downloadable: false and downloading one returns a 400.

list/2 pages by cursor rather than by id: pass page: page.next_page until it comes back nil.

Message batches

Send up to 100,000 requests at once, asynchronously, at half the token cost:

{:ok, batch} = Claudex.Messages.Batches.create(client, [
%{custom_id: "ticket-1", params: %{model: "claude-opus-5", max_tokens: 1024,
messages: [%{role: "user", content: "..."}]}}
])
# later, from a job runner
{:ok, batch} = Claudex.Messages.Batches.retrieve(client, batch.id)
if Claudex.Messages.Batch.ended?(batch) do
{:ok, results} = Claudex.Messages.Batches.results(client, batch.id)
Enum.each(results, fn
%{custom_id: id, result: {:ok, message}} -> store(id, message)
%{custom_id: id, result: {:error, error}} -> log(id, error)
%{custom_id: id, result: :expired} -> requeue(id)
%{result: :canceled} -> :ok
end)
end

Claudex doesn't poll for you. A batch has 24 hours to finish, so check on it from your app's job runner with the batch id persisted.

results/2 returns a lazy stream that reads the .jsonl as it goes, so a 100,000-request batch doesn't have to fit in memory. Results arrive in completion order, not request order — match them by custom_id.

Development

mix deps.get
mix test # unit tests — no network, no API key needed
mix format
mix credo --strict
mix dialyzer

Continuous integration

.github/workflows/ci.yml runs on every push to main and every pull request: format check, --warnings-as-errors compile, Credo strict, the offline test suite, and Dialyzer. It makes no API calls and needs no key — the live suite is tagged :live and excluded by test/test_helper.exs.

The workflow pins Elixir and OTP explicitly, so a CI run doesn't drift from the versions the library is tested against.

End-to-end evals

test/claudex/live/ runs real requests against the Claude API, one file per capability:

FileCovers
messages_live_test.exscreate/2, multi-turn context
streaming_live_test.exsstream!/2, stream_to/3, cancel/1 mid-stream
tools_live_test.exstool call → dispatch → tool_result → follow-up, parallel calls
thinking_live_test.exsthinking blocks, streamed and not
caching_live_test.exscache_control, cache write and read token counts
errors_live_test.exs404, 400, 401, and count_tokens rejecting max_tokens
models_live_test.exslist, paging, alias resolution, count_tokens
files_live_test.exsupload, metadata, list, download refusal, delete
batches_live_test.exscreate, retrieve, cancel, list, results-not-ready
tool_runner_live_test.exsrun/3 and stream/3 against real tool calls

They're tagged :live and excluded from mix test (network + cost). To run them locally, export a real key first:

export ANTHROPIC_API_KEY=sk-ant-...
mix test.live

In CI they are a separate, manually triggered workflow — Actions → Live evals → Run workflow — never part of the normal pipeline, because every run costs money. It runs every eval in test/claudex/live/ and nothing else — CI has already run the offline suite — and an optional file input narrows it to one file. It needs an ANTHROPIC_API_KEY repository secret, and fails immediately if that secret is missing rather than skipping every test and reporting success.

Releases

.github/workflows/release-please.yml keeps a release pull request open, built from the conventional commits on main. Merging it bumps the version in mix.exs, writes CHANGELOG.md, and tags the release. Publishing to Hex is a separate mix hex.publish from a tagged commit.

Fixtures

mix test.record runs the same live suite and saves what the API sent into test/fixtures/ — JSON responses, and raw .sse transcripts byte-for-byte off the wire. test/claudex/replay_test.exs then replays those offline as part of the normal mix test run, including re-chunking the SSE at 1, 7, 64, and 4096 bytes to prove the decoder doesn't care where the boundaries fall.

The fast suite therefore runs against payloads the API really sent. Fixtures are committed, so git diff test/fixtures/ after a re-record shows exactly what changed on the API side.

License

MIT — see LICENSE.