Claudex.ToolRunner (Claudex v0.6.1)

Copy Markdown View Source

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_turns

stream/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)
end

Any 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 carries stop: :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

run(client, params, opts \\ [])

@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.

stream(client, params, opts \\ [])

@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.