guides/cookbook/custom-tools.md

# Cookbook: Custom Tools

Tools allow AI models to call your Elixir functions. This recipe shows how to build
and test tool modules.

## Basic Calculator Tool

A simple arithmetic tool with no external dependencies:

```elixir
defmodule MyApp.CalculatorTool do
  @behaviour PhoenixAI.Tool

  @impl true
  def name, do: "calculate"

  @impl true
  def description, do: "Performs basic arithmetic operations"

  @impl true
  def parameters_schema do
    %{
      type: :object,
      properties: %{
        operation: %{
          type: :string,
          enum: ["add", "subtract", "multiply", "divide"],
          description: "The arithmetic operation to perform"
        },
        a: %{type: :number, description: "First operand"},
        b: %{type: :number, description: "Second operand"}
      },
      required: [:operation, :a, :b]
    }
  end

  @impl true
  def execute(%{"operation" => "add", "a" => a, "b" => b}, _opts) do
    {:ok, "#{a + b}"}
  end

  def execute(%{"operation" => "subtract", "a" => a, "b" => b}, _opts) do
    {:ok, "#{a - b}"}
  end

  def execute(%{"operation" => "multiply", "a" => a, "b" => b}, _opts) do
    {:ok, "#{a * b}"}
  end

  def execute(%{"operation" => "divide", "a" => _a, "b" => 0}, _opts) do
    {:error, "Division by zero"}
  end

  def execute(%{"operation" => "divide", "a" => a, "b" => b}, _opts) do
    {:ok, "#{a / b}"}
  end
end
```

Usage:

```elixir
{:ok, response} = AI.chat(
  [%PhoenixAI.Message{role: :user, content: "What is 42 * 7?"}],
  provider: :openai,
  tools: [MyApp.CalculatorTool]
)

IO.puts(response.content)
# => "42 multiplied by 7 equals 294."
```

## Tool with External API (GitHub Search)

A tool that calls an external API and handles errors:

```elixir
defmodule MyApp.GitHubSearchTool do
  @behaviour PhoenixAI.Tool

  @impl true
  def name, do: "github_search"

  @impl true
  def description, do: "Search GitHub repositories by keyword"

  @impl true
  def parameters_schema do
    %{
      type: :object,
      properties: %{
        query: %{
          type: :string,
          description: "Search keywords"
        },
        language: %{
          type: :string,
          description: "Filter by programming language (optional)"
        },
        limit: %{
          type: :integer,
          description: "Number of results (1-10, default 5)"
        }
      },
      required: [:query]
    }
  end

  @impl true
  def execute(args, _opts) do
    query = args["query"]
    language = args["language"]
    limit = min(args["limit"] || 5, 10)

    q =
      if language do
        "#{query} language:#{language}"
      else
        query
      end

    url = "https://api.github.com/search/repositories"

    case Req.get(url,
           params: [q: q, per_page: limit, sort: "stars"],
           headers: [{"accept", "application/vnd.github+json"}]
         ) do
      {:ok, %{status: 200, body: %{"items" => items}}} ->
        results =
          Enum.map(items, fn repo ->
            "#{repo["full_name"]} (⭐ #{repo["stargazers_count"]}): #{repo["description"]}"
          end)
          |> Enum.join("\n")

        {:ok, results}

      {:ok, %{status: 403}} ->
        {:error, "GitHub API rate limit exceeded"}

      {:ok, %{status: status}} ->
        {:error, "GitHub API returned status #{status}"}

      {:error, reason} ->
        {:error, "Request failed: #{inspect(reason)}"}
    end
  end
end
```

Usage:

```elixir
{:ok, response} = AI.chat(
  [%PhoenixAI.Message{role: :user, content: "Find popular Elixir web frameworks on GitHub"}],
  provider: :openai,
  tools: [MyApp.GitHubSearchTool]
)
```

## Multi-Tool Agent

Combine multiple tools in an agent:

```elixir
{:ok, agent} = PhoenixAI.Agent.start_link(
  provider: :openai,
  model: "gpt-4o",
  system: "You are a research assistant with access to calculations and GitHub search.",
  tools: [MyApp.CalculatorTool, MyApp.GitHubSearchTool]
)

{:ok, r1} = PhoenixAI.Agent.prompt(agent, "Search for Elixir HTTP client libraries")
{:ok, r2} = PhoenixAI.Agent.prompt(agent, "How many total stars do the top 3 have?")
# Agent can call CalculatorTool to add up the stars
```

## Testing Tools with TestProvider

Use `PhoenixAI.Test` to test your tool-using code without real API calls.

### Scripted Tool Call Response

Use `set_handler/1` to simulate a model that calls a tool:

```elixir
defmodule MyApp.CalculatorToolTest do
  use ExUnit.Case, async: true
  use PhoenixAI.Test

  alias PhoenixAI.{Message, Response, ToolCall}

  test "tool is called when model requests it" do
    # First response: model calls the tool
    # Second response: model uses the tool result
    set_handler(fn messages, _opts ->
      last = List.last(messages)

      cond do
        last.role == :user and last.content =~ "42 * 7" ->
          # Simulate model deciding to call the tool
          {:ok,
           %Response{
             content: nil,
             tool_calls: [
               %ToolCall{
                 id: "call_123",
                 name: "calculate",
                 arguments: %{"operation" => "multiply", "a" => 42, "b" => 7}
               }
             ]
           }}

        last.role == :tool ->
          # Model received tool result, now answers
          {:ok, %Response{content: "42 multiplied by 7 is 294."}}

        true ->
          {:ok, %Response{content: "I don't understand"}}
      end
    end)

    {:ok, response} = AI.chat(
      [%Message{role: :user, content: "What is 42 * 7?"}],
      provider: :test,
      api_key: "test",
      tools: [MyApp.CalculatorTool]
    )

    assert response.content == "42 multiplied by 7 is 294."
  end
end
```

### Testing Tool execute/2 Directly

Test the tool itself in isolation:

```elixir
defmodule MyApp.CalculatorToolUnitTest do
  use ExUnit.Case, async: true

  test "adds two numbers" do
    assert {:ok, "10"} =
             MyApp.CalculatorTool.execute(
               %{"operation" => "add", "a" => 7, "b" => 3},
               []
             )
  end

  test "rejects division by zero" do
    assert {:error, "Division by zero"} =
             MyApp.CalculatorTool.execute(
               %{"operation" => "divide", "a" => 5, "b" => 0},
               []
             )
  end

  test "multiplies correctly" do
    assert {:ok, "294"} =
             MyApp.CalculatorTool.execute(
               %{"operation" => "multiply", "a" => 42, "b" => 7},
               []
             )
  end
end
```

### Verifying Calls Were Made

```elixir
test "records tool-related calls" do
  set_responses([
    {:ok, %Response{content: "The answer is 42."}}
  ])

  AI.chat(
    [%Message{role: :user, content: "Answer?"}],
    provider: :test,
    api_key: "test",
    tools: [MyApp.CalculatorTool]
  )

  calls = get_calls()
  assert length(calls) == 1
end
```

## Best Practices

1. **Return strings from execute/2** — The model expects text. Format numbers, lists,
   and structs as human-readable strings.

2. **Handle all error cases** — Return `{:error, "message"}` rather than raising.
   The tool loop will include the error message in the conversation so the model
   can inform the user.

3. **Be specific in descriptions** — The model uses `description/0` and
   `parameters_schema/0` to decide when and how to call your tool. Clear descriptions
   lead to more accurate tool usage.

4. **Validate inputs** — Models can produce unexpected arguments. Pattern match
   defensively and return helpful error messages for invalid inputs.

5. **Keep tools focused** — One tool, one capability. Compose with multiple tools
   rather than building a single tool that does everything.