Tool calling is the step that moves a local model from chatting to working: the model decides when it needs an external tool, issues a structured call, and folds the result back into its answer. Ollama supports the mechanism in three forms — single-shot, parallel, and looping — plus a streaming variant. This page walks the protocol details and client code for each [1].
Single-shot
The basic form fires one tool call, which the client executes and returns in a follow-up request [1]. Over the native API, tool descriptions are plain JSON Schema [1]:
curl -s http://localhost:11434/api/chat -H "Content-Type: application/json" -d '{
"model": "qwen3",
"messages": [{"role": "user", "content": "What is the temperature in New York?"}],
"stream": false,
"tools": [
{
"type": "function",
"function": {
"name": "get_temperature",
"description": "Get the current temperature for a city",
"parameters": {
"type": "object",
"required": ["city"],
"properties": {
"city": {"type": "string", "description": "The name of the city"}
}
}
}
}
]
}'The assistant reply carries a tool_calls array; the client executes it and returns the result as a tool-role message with the tool name and original call info attached [1]:
curl -s http://localhost:11434/api/chat -H "Content-Type: application/json" -d '{
"model": "qwen3",
"messages": [
{"role": "user", "content": "What is the temperature in New York?"},
{
"role": "assistant",
"tool_calls": [
{
"type": "function",
"function": {
"index": 0,
"name": "get_temperature",
"arguments": {"city": "New York"}
}
}
]
},
{"role": "tool", "tool_name": "get_temperature", "content": "22°C"}
],
"stream": false
}'The Python SDK is lighter: a plain function with a docstring passes straight into the tools list, parsed into a schema automatically [1]:
from ollama import chat
def get_temperature(city: str) -> str:
"""Get the current temperature for a city
Args:
city: The name of the city
Returns:
The current temperature for the city
"""
temperatures = {
"New York": "22°C",
"London": "15°C",
"Tokyo": "18°C",
}
return temperatures.get(city, "Unknown")
messages = [{"role": "user", "content": "What is the temperature in New York?"}]
# pass functions directly as tools in the tools list or as a JSON schema
response = chat(model="qwen3", messages=messages, tools=[get_temperature], think=True)Return the result and generate the final answer [1]:
messages.append(response.message)
if response.message.tool_calls:
# only recommended for models which only return a single tool call
call = response.message.tool_calls[0]
result = get_temperature(**call.function.arguments)
# add the tool result to the messages
messages.append({"role": "tool", "tool_name": call.function.name, "content": str(result)})
final_response = chat(model="qwen3", messages=messages, tools=[get_temperature], think=True)
print(final_response.message.content)Taking tool_calls[0] directly suits only models guaranteed to return a single call; multi-call responses deserve a loop [1].
Parallel calls
Ask for the weather and temperature in two cities and the model emits four calls at once — a temperature and conditions pair per city [1]. The Python client dispatches by function name and returns every result before the final generation [1]:
messages = [{'role': 'user', 'content': 'What are the current weather conditions and temperature in New York and London?'}]
# The python client automatically parses functions as a tool schema so we can pass them directly
# Schemas can be passed directly in the tools list as well
response = chat(model='qwen3', messages=messages, tools=[get_temperature, get_conditions], think=True)
# add the assistant message to the messages
messages.append(response.message)
if response.message.tool_calls:
# process each tool call
for call in response.message.tool_calls:
# execute the appropriate tool
if call.function.name == 'get_temperature':
result = get_temperature(**call.function.arguments)
elif call.function.name == 'get_conditions':
result = get_conditions(**call.function.arguments)
else:
result = 'Unknown tool'
# add the tool result to the messages
messages.append({'role': 'tool', 'tool_name': call.function.name, 'content': str(result)})
# generate the final response
final_response = chat(model='qwen3', messages=messages, tools=[get_temperature, get_conditions], think=True)
print(final_response.message.content)Parallel tool_calls carry index fields 0 through 3, and the returned results should follow the same order. The parallel form saves round trips but requires client code able to execute any number of calls — a name-to-function map is the sturdiest dispatch [1].
The agent loop
The loop form hands over full control: the model decides how many calls to make and when to stop, and the loop ends when a response contains no tool_calls [1]. A practical tip from the docs: telling the model it is in a loop and may call tools multiple times helps [1]. The example computes (11434+12341)*412 with add and multiply functions [1]:
def add(a: int, b: int) -> int:
"""Add two numbers"""
"""
Args:
a: The first number
b: The second number
Returns:
The sum of the two numbers
"""
return a + b
def multiply(a: int, b: int) -> int:
"""Multiply two numbers"""
"""
Args:
a: The first number
b: The second number
Returns:
The product of the two numbers
"""
return a * bThe loop body is a standard while with one termination condition — no more tool calls [1]:
available_functions = {
'add': add,
'multiply': multiply,
}
messages = [{'role': 'user', 'content': 'What is (11434+12341)*412?'}]
while True:
response: ChatResponse = chat(
model='qwen3',
messages=messages,
tools=[add, multiply],
think=True,
)
messages.append(response.message)
print("Thinking: ", response.message.thinking)
print("Content: ", response.message.content)
if response.message.tool_calls:
for tc in response.message.tool_calls:
if tc.function.name in available_functions:
print(f"Calling {tc.function.name} with arguments {tc.function.arguments}")
result = available_functions[tc.function.name](**tc.function.arguments)
print(f"Result: {result}")
# add the tool result to the messages
messages.append({'role': 'tool', 'tool_name': tc.function.name, 'content': str(result)})
else:
# end the loop when there are no more tool calls
breakProductionizing this loop adds two things: a maximum-iteration cap against runaway loops, and exception handling so one failing tool does not sink the conversation. Printing thinking each round makes the model's planning visible [1].
Streaming with tools
Streaming improves the wait, but combined with tool calls it introduces a new problem: thinking, content, and tool_calls all arrive as increments, so the client must accumulate the fragments and return the complete fields together with any tool results in the follow-up request [1]. Setup and accumulation [1]:
messages = [{'role': 'user', 'content': "What is the temperature in New York?"}]
while True:
stream = chat(
model='qwen3',
messages=messages,
tools=[get_temperature],
stream=True,
think=True,
)
thinking = ''
content = ''
tool_calls = [] done_thinking = False
# accumulate the partial fields
for chunk in stream:
if chunk.message.thinking:
thinking += chunk.message.thinking
print(chunk.message.thinking, end='', flush=True)
if chunk.message.content:
if not done_thinking:
done_thinking = True
print('\n')
content += chunk.message.content
print(chunk.message.content, end='', flush=True)
if chunk.message.tool_calls:
tool_calls.extend(chunk.message.tool_calls)
print(chunk.message.tool_calls)
# append accumulated fields to the messages
if thinking or content or tool_calls:
messages.append({'role': 'assistant', 'thinking': thinking, 'content': content, 'tool_calls': tool_calls})
if not tool_calls:
breakThree details matter: the assistant message must return all three fields as a whole — dropping thinking or content breaks the model's context; the done_thinking flag prints one newline at the thinking-to-content transition; and no tool calls means break [1]. The loop simultaneously delivers live streaming output and intact pass-back — the two needs never conflict.
Functions as tools
The last item is the Python SDK's convenience: functions pass straight into the tools list with schemas derived from their docstrings; explicit JSON schemas remain available when fine control is needed, and both styles mix freely [1]:
from ollama import chat
def get_temperature(city: str) -> str:
"""Get the current temperature for a city
Args:
city: The name of the city
Returns:
The current temperature for the city
"""
temperatures = {
'New York': '22°C',
'London': '15°C',
}
return temperatures.get(city, 'Unknown')
available_functions = {
'get_temperature': get_temperature,
}
# directly pass the function as part of the tools list
response = chat(model='qwen3', messages=messages, tools=available_functions.values(), think=True)Docstring quality decides call accuracy — every Args line lands in the schema, and vague descriptions leave the model guessing. Passing the dict's values() wholesale plus name-based dispatch is the least-code combination these examples demonstrate [1].
Adapted from the Tool calling section of the Ollama documentation[1], released under the MIT License.
Sources
- 1Tool calling | Ollama docsOllama docs (MIT)
Cite this page: Tool calling with Ollama. TellWorld, https://www.tellworld.net/practice/practice-ollama-tool-calling. Updated 2026-11-14