Runs a tool conversation: send, run whatever Claude asks for, send the results back, repeat until it stops asking.
{:ok, turn} =
Claudex.ToolRunner.run(client, %{
model: "claude-opus-5",
max_tokens: 1024,
tools: MyApp.Tools,
messages: [Claudex.Message.user("What is 12 plus 30?")]
})
Claudex.Message.text(turn.message) # the final reply
turn.messages # the whole conversation
turn.stop # :completed | :refusal | :max_turnsstream/3 hands you one turn at a time instead, so you can watch the
conversation, log it, or stop it:
client
|> Claudex.ToolRunner.stream(params)
|> Enum.reduce_while(nil, fn turn, _last ->
if interesting?(turn), do: {:cont, turn}, else: {:halt, turn}
end)Tools decide what they will and won't do
There's no approval callback here, because the tool is the right place for
that. A tool that refuses raises Claudex.Tool.Error, and Claude sees the
reason as an error result and adapts — it's an ordinary @tool function that
happens to guard itself:
@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)
endAny other exception becomes an error result too, with the exception type in the content and a warning in your logs — a bug in a tool shouldn't read to Claude like a policy decision, and shouldn't end the conversation either.
Whatever a tool returns is the result: a binary is sent as-is, anything else is JSON-encoded. Returning is success; raising is failure. A tool's return value is your own data, so it can't double as an error channel.
:tools takes several modules as a list. Keep tool names unique across
them — Claude is told about both, but only the one from the last module
listed can be called.
Options
:max_turns- how many times to go around before giving up, defaulting to 10. The last turn then carriesstop: :max_turns.
Summary
Functions
Runs the conversation until Claude stops asking for tools, returning the last turn.
Returns a lazy stream of Claudex.ToolRunner.Turn structs, one per reply.
Functions
@spec run(Claudex.Client.t(), map() | keyword(), keyword()) :: {:ok, Claudex.ToolRunner.Turn.t()} | {:error, Claudex.Error.t()}
Runs the conversation until Claude stops asking for tools, returning the last turn.
{:ok, %Turn{message: message, messages: history, stop: :completed}} =
Claudex.ToolRunner.run(client, params)It returns a Claudex.ToolRunner.Turn, the same thing stream/3 yields.
message is the final reply, messages the whole conversation,
and stop says why it ended: :completed, :refusal, or :max_turns.
Match on stop rather than assuming Claude finished; hitting the turn limit
is not an error, and looks identical without it.
Returns {:error, %Claudex.Error{}} if a request fails.
@spec stream(Claudex.Client.t(), map() | keyword(), keyword()) :: Enumerable.t()
Returns a lazy stream of Claudex.ToolRunner.Turn structs, one per reply.
Nothing happens until you enumerate it. Tools for a turn have already run by the time you see that turn, so halting stops the conversation rather than cancelling work — a tool that shouldn't run guards itself instead.
Enumerating raises Claudex.Error if a request fails.