The Message Batches API — send up to 100,000 Messages requests at once for asynchronous processing, at half the token cost.
The flow is submit, check back, collect:
requests = [
%{
custom_id: "ticket-1",
params: %{
model: "claude-opus-5",
max_tokens: 1024,
messages: [%{role: "user", content: "..."}]
}
}
]
{:ok, batch} = Batches.create(client, requests)
# later, from a job runner
{:ok, batch} = Batches.retrieve(client, batch.id)
if Claudex.Messages.Batch.ended?(batch) do
{:ok, results} = Batches.results(client, batch.id)
Enum.each(results, &handle/1)
endClaudex 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.
Each request's params are validated asynchronously, and a validation error
arrives with that request's result once the batch has ended — so a batch
containing a request the Messages API would reject outright is still
accepted. Check a request's shape against Claudex.Messages.create/2 before
batching a lot of them.
Results come back in whatever order the requests finished, so match them to
your requests by custom_id. results/2 streams them, so a batch far too
big to hold in memory is still fine to walk.
Summary
Functions
Asks for a batch to be canceled.
Submits a batch.
Deletes a batch. It must have finished processing first.
Lists your batches, newest first.
Streams a finished batch's results as Claudex.Messages.BatchResult structs.
Looks up a batch. This is the endpoint to poll for completion.
Functions
@spec cancel(Claudex.Client.t(), String.t()) :: {:ok, Claudex.Messages.Batch.t()} | {:error, Claudex.Error.t()}
Asks for a batch to be canceled.
Cancellation isn't immediate: the batch moves to "canceling", and requests
already in flight may still finish, so expect a mix of canceled and
succeeded results.
@spec create(Claudex.Client.t(), [map()]) :: {:ok, Claudex.Messages.Batch.t()} | {:error, Claudex.Error.t()}
Submits a batch.
Each request is a map with a :custom_id — 1 to 64 characters of letters,
digits, hyphens, and underscores, unique within the batch — and :params,
which takes exactly what Claudex.Messages.create/2 takes. :tools in
those params accepts a module the same way, and max_tokens must be at
least 1.
Examples
requests = [
%{
custom_id: "ticket-1",
params: %{
model: "claude-opus-5",
max_tokens: 1024,
messages: [Claudex.Message.user("Summarise: the printer is offline")]
}
},
%{
custom_id: "ticket-2",
params: %{
model: "claude-opus-5",
max_tokens: 1024,
messages: [Claudex.Message.user("Summarise: billed twice in March")]
}
}
]
{:ok, batch} = Claudex.Messages.Batches.create(client, requests)
batch.id
#=> "msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d"
@spec delete(Claudex.Client.t(), String.t()) :: :ok | {:error, Claudex.Error.t()}
Deletes a batch. It must have finished processing first.
@spec list(Claudex.Client.t(), keyword()) :: {:ok, Claudex.Page.t(Claudex.Messages.Batch.t())} | {:error, Claudex.Error.t()}
Lists your batches, newest first.
Takes :limit, :after_id, and :before_id — the same id cursors
Claudex.Models.list/2 uses.
@spec results(Claudex.Client.t(), String.t()) :: {:ok, Enumerable.t()} | {:error, Claudex.Error.t()}
Streams a finished batch's results as Claudex.Messages.BatchResult structs.
Returns {:error, %Claudex.Error{}} if the batch hasn't ended yet — check
Claudex.Messages.Batch.ended?/1 first, or just match on the error and try
again later.
The stream reads the results file as it goes rather than pulling it all into
memory, so enumerating it lazily (Stream.filter/2, Enum.reduce/3) keeps
a 100,000-request batch manageable. Enumerating raises Claudex.Error if
the download fails part-way.
@spec retrieve(Claudex.Client.t(), String.t()) :: {:ok, Claudex.Messages.Batch.t()} | {:error, Claudex.Error.t()}
Looks up a batch. This is the endpoint to poll for completion.