Tool Calling: LLM Function-Calling Execution Loop
Tools let an LLM call external functions — weather APIs, calculators, databases — and receive results before composing its final response. This tutorial covers defining tools, building a tool registry, and running the automated execution loop.
Setup
Mix.install([
{:livekit, path: Path.join(__DIR__, "../../..")},
{:kino, "~> 0.14"}
])
alias Livekit.Agents.Tool
alias Livekit.Agents.Tool.{ToolSpec, ToolContext, ToolError}
alias Livekit.Agents.ChatContext
alias Livekit.Agents.ChatContext.{ChatMessage, FunctionCall, FunctionCallOutput}
Defining a Tool with ToolSpec
A ToolSpec has four required fields:
:name— the function name the LLM will use in its call (snake_case string):description— natural language explanation the LLM reads to decide when to call it:parameters— JSON Schema map (OpenAIparametersformat):handler—fn args_map -> {:ok, result_string} | {:error, reason_string} end
weather_tool = ToolSpec.new(
name: "get_weather",
description: "Returns current weather conditions for a given city",
parameters: %{
"type" => "object",
"properties" => %{
"city" => %{
"type" => "string",
"description" => "City name, e.g. London or Tokyo"
},
"units" => %{
"type" => "string",
"enum" => ["celsius", "fahrenheit"],
"description" => "Temperature unit (default: celsius)"
}
},
"required" => ["city"]
},
handler: fn %{"city" => city} = args ->
units = Map.get(args, "units", "celsius")
# Simulated response — in a real tool you would call a weather API here
{:ok, "#{city}: 18°#{String.first(String.upcase(units))}, partly cloudy"}
end
)
IO.inspect(weather_tool, label: "ToolSpec")
Generating OpenAI Function Schema
ToolSpec.to_openai_schema/1 converts a ToolSpec into the JSON structure OpenAI
expects in the tools parameter of a chat completion request.
schema = ToolSpec.to_openai_schema(weather_tool)
IO.puts("OpenAI schema:\n#{Jason.encode!(schema, pretty: true)}")
Building a ToolContext Registry
A ToolContext holds multiple ToolSpec entries indexed by name for O(1) lookup.
to_openai_tools/1 produces the full list you pass to the LLM provider.
calculator_tool = ToolSpec.new(
name: "calculate",
description: "Evaluates a simple arithmetic expression and returns the result",
parameters: %{
"type" => "object",
"properties" => %{
"expression" => %{
"type" => "string",
"description" => "Arithmetic expression to evaluate, e.g. '2 + 3 * 4'"
}
},
"required" => ["expression"]
},
handler: fn %{"expression" => expr} ->
# Safely evaluate only simple numeric expressions
# In production use a proper parser; this is illustrative
result = expr
|> String.replace(~r/[^0-9\+\-\*\/\.\s]/, "")
|> String.trim()
{:ok, "Result of #{expr}: #{result} (computed)"}
end
)
tool_ctx = ToolContext.new([weather_tool, calculator_tool])
IO.puts("Tools registered: #{map_size(tool_ctx.tools)}")
IO.puts("Tool names: #{Map.keys(tool_ctx.tools) |> Enum.join(", ")}")
all_schemas = ToolContext.to_openai_tools(tool_ctx)
IO.puts("\nAll schemas (#{length(all_schemas)} tools):")
IO.puts(Jason.encode!(all_schemas, pretty: true))
Looking Up Tools
case ToolContext.lookup(tool_ctx, "get_weather") do
{:ok, spec} -> IO.puts("Found: #{spec.name} — #{spec.description}")
{:error, :not_found} -> IO.puts("Not found")
end
case ToolContext.lookup(tool_ctx, "nonexistent_tool") do
{:ok, _} -> IO.puts("Found (unexpected)")
{:error, :not_found} -> IO.puts("Not found (expected)")
end
The Execution Loop: Tool.run/3
Tool.run/3 is the heart of function calling. It:
- Calls
llm_module.chat(ctx, opts)to get a response - Looks for
FunctionCallitems in the returned context - Calls each tool's handler and appends a
FunctionCallOutput - Feeds the updated context back to the LLM
- Repeats until the LLM produces no more tool calls, or
max_tool_stepsis reached
To demonstrate this without a real LLM, we build a mock that simulates one tool call before returning a natural completion.
defmodule MockLLMWithToolCall do
use Livekit.Agents.LLM
# step 0: LLM requests a tool call
# step 1: LLM sees tool output and gives final answer
@impl true
def chat(chat_context, _opts) do
has_tool_output =
Enum.any?(chat_context.items, &match?(%FunctionCallOutput{}, &1))
if has_tool_output do
# Second call: compose final answer using the tool result
tool_outputs =
chat_context.items
|> Enum.filter(&match?(%FunctionCallOutput{}, &1))
|> Enum.map(& &1.output)
|> Enum.join(", ")
reply = %ChatMessage{
id: random_id(),
role: :assistant,
content: ["Based on the tool results: #{tool_outputs}. Have a great day!"],
interrupted: false,
created_at: DateTime.utc_now()
}
{:ok, reply}
else
# First call: request the weather tool
fc = %FunctionCall{
id: random_id(),
call_id: "call_mock_001",
name: "get_weather",
arguments: Jason.encode!(%{"city" => "Paris", "units" => "celsius"}),
created_at: DateTime.utc_now()
}
{:ok, fc}
end
end
@impl true
def capabilities, do: %{streaming: false, tool_calling: true, vision: false, max_context_tokens: 8192}
defp random_id, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower)
end
# Run the tool calling loop
initial_ctx =
ChatContext.new()
|> ChatContext.add(ChatContext.new_message(:system, ["You are a weather assistant."]))
|> ChatContext.add(ChatContext.new_message(:user, ["What's the weather in Paris?"]))
{:ok, final_ctx} = Tool.run(MockLLMWithToolCall, initial_ctx,
tool_context: tool_ctx,
max_tool_steps: 5
)
IO.puts("Final context has #{length(final_ctx.items)} items:\n")
Enum.each(final_ctx.items, fn item ->
case item do
%ChatMessage{role: r, content: c} ->
IO.puts(" [#{r}] #{Enum.join(c, " ")}")
%FunctionCall{name: n, arguments: a} ->
IO.puts(" [function_call] #{n}(#{a})")
%FunctionCallOutput{name: n, output: o, is_error: e} ->
IO.puts(" [function_output] #{n} -> #{o} (error=#{e})")
end
end)
The max_tool_steps Safety Cap
Prevent infinite loops by setting max_tool_steps. When the cap is reached, Tool.run/3
returns the context as-is, even if the LLM is still requesting tool calls.
defmodule InfiniteToolLLM do
use Livekit.Agents.LLM
@impl true
def chat(_chat_context, _opts) do
# Always requests a tool call — simulates a stuck LLM
fc = %FunctionCall{
id: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower),
call_id: "call_#{System.unique_integer()}",
name: "get_weather",
arguments: Jason.encode!(%{"city" => "London"}),
created_at: DateTime.utc_now()
}
{:ok, fc}
end
@impl true
def capabilities, do: %{streaming: false, tool_calling: true, vision: false, max_context_tokens: 4096}
end
ctx = ChatContext.new()
|> ChatContext.add(ChatContext.new_message(:user, ["Keep calling tools forever..."]))
{:ok, capped_ctx} = Tool.run(InfiniteToolLLM, ctx,
tool_context: tool_ctx,
max_tool_steps: 3 # hard cap at 3 iterations
)
function_calls = Enum.count(capped_ctx.items, &match?(%FunctionCall{}, &1))
function_outputs = Enum.count(capped_ctx.items, &match?(%FunctionCallOutput{}, &1))
IO.puts("After capping at max_tool_steps: 3")
IO.puts(" FunctionCall items: #{function_calls}")
IO.puts(" FunctionOutput items: #{function_outputs}")
IO.puts(" (loop stopped, no final assistant message)")
Error Isolation
When a tool handler returns {:error, reason}, the execution loop:
- Catches the
ToolError - Creates a
FunctionCallOutputwithis_error: true - Feeds the error back to the LLM so it can respond gracefully
The LLM is never crashed by a bad tool — errors are surfaced as data.
failing_tool = ToolSpec.new(
name: "failing_tool",
description: "Always fails for demonstration",
parameters: %{"type" => "object", "properties" => %{}, "required" => []},
handler: fn _args ->
{:error, "External service unavailable (HTTP 503)"}
end
)
error_tool_ctx = ToolContext.new([failing_tool])
defmodule FailingToolLLM do
use Livekit.Agents.LLM
@impl true
def chat(chat_context, _opts) do
has_output = Enum.any?(chat_context.items, &match?(%FunctionCallOutput{}, &1))
if has_output do
output = Enum.find(chat_context.items, &match?(%FunctionCallOutput{}, &1))
content = if output.is_error do
"I tried to use a tool but it failed: #{output.output}. I'll answer without it."
else
"Tool worked: #{output.output}"
end
{:ok, %ChatMessage{
id: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower),
role: :assistant,
content: [content],
interrupted: false,
created_at: DateTime.utc_now()
}}
else
{:ok, %FunctionCall{
id: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower),
call_id: "call_fail_01",
name: "failing_tool",
arguments: "{}",
created_at: DateTime.utc_now()
}}
end
end
@impl true
def capabilities, do: %{streaming: false, tool_calling: true, vision: false, max_context_tokens: 4096}
end
err_ctx =
ChatContext.new()
|> ChatContext.add(ChatContext.new_message(:user, ["Please use the failing tool."]))
{:ok, result_ctx} = Tool.run(FailingToolLLM, err_ctx,
tool_context: error_tool_ctx,
max_tool_steps: 5
)
IO.puts("Context after error isolation:")
Enum.each(result_ctx.items, fn item ->
case item do
%ChatMessage{role: r, content: c} -> IO.puts(" [#{r}] #{Enum.join(c, " ")}")
%FunctionCall{name: n} -> IO.puts(" [function_call] #{n}")
%FunctionCallOutput{name: n, is_error: true, output: o} -> IO.puts(" [ERROR output] #{n}: #{o}")
%FunctionCallOutput{name: n, output: o} -> IO.puts(" [function_output] #{n}: #{o}")
end
end)
Summary
You now know how to:
- Define a tool with
ToolSpec.new/1(name, description, JSON Schema, handler) - Generate OpenAI function-calling schema with
ToolSpec.to_openai_schema/1 - Build a tool registry with
ToolContext.new/1and look up tools withlookup/2 - Run the automated execution loop with
Tool.run/3 - Cap iterations with
max_tool_stepsto prevent infinite loops - Rely on error isolation: handler failures become
FunctionCallOutput(is_error: true)
Next: 04_deepgram_stt.livemd — real speech-to-text with a Deepgram API key.