Tool Use
Copy MarkdownSycophant supports LLM tool use (function calling) with both automatic execution and manual handling.
Defining Tools
Tools are defined with a name, description, and a parameter schema. The schema can be a Zoi schema or a JSON Schema map:
# With Zoi schema
weather_tool = %Sycophant.Tool{
name: "get_weather",
description: "Get current weather for a city",
parameters: Zoi.object(%{
city: Zoi.string(),
unit: Zoi.enum(["celsius", "fahrenheit"])
})
}
# With JSON Schema
weather_tool = %Sycophant.Tool{
name: "get_weather",
description: "Get current weather for a city",
parameters: %{
"type" => "object",
"properties" => %{
"city" => %{"type" => "string"},
"unit" => %{"type" => "string", "enum" => ["celsius", "fahrenheit"]}
},
"required" => ["city", "unit"]
}
}Auto-execution
When a tool has a :function set, Sycophant automatically executes it when
the LLM returns a tool call. The result is fed back to the LLM, which
continues generating. This loops up to :max_steps iterations (default 10):
# Zoi-defined tools receive atom keys in function arguments
weather_tool = %Sycophant.Tool{
name: "get_weather",
description: "Get current weather for a city",
parameters: Zoi.object(%{city: Zoi.string()}),
function: fn %{city: city} ->
"72F and sunny in #{city}"
end
}
# JSON Schema-defined tools receive string keys
weather_tool = %Sycophant.Tool{
name: "get_weather",
description: "Get current weather for a city",
parameters: %{"type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"]},
function: fn %{"city" => city} ->
"72F and sunny in #{city}"
end
}
messages = [Sycophant.Message.user("What's the weather in Paris?")]
{:ok, response} = Sycophant.generate_text("openai:gpt-4o-mini", messages,
tools: [weather_tool]
)
response.text
#=> "The current weather in Paris is 72F and sunny."The execution flow:
- LLM receives the prompt and tool definitions
- LLM decides to call
get_weatherwith%{"city" => "Paris"} - Sycophant validates the arguments against the tool's schema
- If using Zoi, keys are coerced to atoms; if JSON Schema, keys stay as strings
- Sycophant executes the function and gets
"72F and sunny in Paris" - Sycophant sends the result back to the LLM
- LLM generates a final response incorporating the tool result
Manual Handling
When a tool has no :function, tool calls are returned in response.tool_calls
for you to handle:
search_tool = %Sycophant.Tool{
name: "search",
description: "Search the knowledge base",
parameters: Zoi.object(%{query: Zoi.string()})
}
{:ok, response} = Sycophant.generate_text("openai:gpt-4o-mini", messages,
tools: [search_tool]
)
# Check if the LLM wants to call tools
if response.tool_calls != [] do
Enum.each(response.tool_calls, fn tool_call ->
IO.puts("Tool: #{tool_call.name}")
IO.puts("Args: #{inspect(tool_call.arguments)}")
end)
endMixing Auto and Manual Tools
You can combine both approaches. Auto-executed tools loop internally while manual tools are returned for external handling:
auto_tool = %Sycophant.Tool{
name: "calculate",
description: "Evaluate a math expression",
parameters: Zoi.object(%{expression: Zoi.string()}),
function: fn %{"expression" => expr} -> "#{Code.eval_string(expr) |> elem(0)}" end
}
manual_tool = %Sycophant.Tool{
name: "send_email",
description: "Send an email to a user",
parameters: Zoi.object(%{to: Zoi.string(), body: Zoi.string()})
}
Sycophant.generate_text("openai:gpt-4o-mini", messages,
tools: [auto_tool, manual_tool],
max_steps: 5
)Tool Parameters
Tool parameters can be defined using Zoi schemas or JSON Schema maps. Both are converted to provider-specific JSON Schema format before being sent to the LLM:
# Zoi schema (recommended for Elixir-native development)
params = Zoi.object(%{
query: Zoi.string(),
limit: Zoi.integer() |> Zoi.default(10),
filters: Zoi.object(%{
category: Zoi.enum(["A", "B", "C"]),
active: Zoi.boolean()
})
})
# Equivalent JSON Schema
params = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"limit" => %{"type" => "integer", "default" => 10},
"filters" => %{
"type" => "object",
"properties" => %{
"category" => %{"type" => "string", "enum" => ["A", "B", "C"]},
"active" => %{"type" => "boolean"}
},
"required" => ["category", "active"]
}
},
"required" => ["query", "filters"]
}Tool arguments are validated against the schema before your function is called. If validation fails, the LLM receives an error message and can self-correct.
Max Steps
The :max_steps option controls the maximum number of tool execution loop
iterations. This prevents infinite loops when the LLM keeps calling tools:
Sycophant.generate_text("openai:gpt-4o-mini", messages,
tools: [weather_tool],
max_steps: 3
)Disabling Auto-execution
Even when tools have a :function set, you can opt out of the auto-execution
loop by passing auto_execute_tools: false. The LLM's tool calls are returned
raw in response.tool_calls for you to inspect, dispatch, or confirm before
running:
{:ok, response} = Sycophant.generate_text("openai:gpt-4o-mini", messages,
tools: [weather_tool],
auto_execute_tools: false
)
response.tool_calls
#=> [%Sycophant.ToolCall{name: "get_weather", arguments: %{"city" => "Paris"}, ...}]This is useful for human-in-the-loop flows, audit logging, or running tool calls in a different process/supervisor than the request pipeline.