In the previous post, we looked at how LLMs work and how to prompt them well. But there's a big limitation we haven't addressed yet — on its own, an LLM can only talk. It can't check the current time, read a file, call an API, or do anything in the real world. It's text in, text out.
That changes when you give it tools.
What tools actually are
Tools aren't anything special — they're just your regular Python functions. What's new is that you describe each function to the LLM using a JSON schema, and the model can then decide to "call" one when it needs to.
The key thing to understand here is that the LLM never actually runs code. When it decides a tool is needed, it returns a structured JSON response saying "call this function with these arguments." Your code reads that, runs the actual function, and sends the result back. The LLM then uses that result to produce its final answer.
So the loop looks like this:
User message
│
▼
LLM reasons
│
├──▶ "I can answer directly" ──▶ text response
│
└──▶ "I need a tool"
│
▼
tool_call { name, arguments }
│
Your code runs the actual function
│
▼
tool result sent back as "role: tool"
│
▼
LLM generates final answer
The model is the decision maker. You are the executor.
Describing tools to the LLM
You describe each tool using a JSON schema — its name, what it does, and what parameters it takes:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city. "
"Use ONLY when the user explicitly asks about weather.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. London"
}
},
"required": ["city"]
}
}
}
]
The description is the most important part. The model reads it to decide when to call the tool and what to pass as arguments. Think of it as a prompt — the better you write it, the more reliably the model uses the tool correctly. Larger models handle vague descriptions better, but a well-written description makes any model more reliable.
Setup
First check your Ollama version — tool use requires 0.3+:
ollama --version
# update if needed (macOS)
brew upgrade ollama
For these exercises, qwen2.5 works best for tool use:
ollama pull qwen2.5
Exercise 1 — Your first tool call
Let's build the simplest possible tool — a calculator:
import ollama
tools = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Perform arithmetic or math calculations ONLY. "
"Do NOT use for general knowledge questions. "
"Only call this when the input is a mathematical expression.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression using numbers and "
"operators only. e.g. '(4 * 7) + 3'. NOT for text or names."
}
},
"required": ["expression"]
}
}
}
]
def calculate(expression: str) -> str:
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
messages = [{"role": "user", "content": "What is 1337 * 42 + 100?"}]
response = ollama.chat(model="qwen2.5", messages=messages, tools=tools)
print("Tool calls:", response.message.tool_calls)
Run this and look at response.message.tool_calls. Instead of a text answer, the model returned structured JSON — it's telling you to run the calculator. That's the tool call.
Exercise 2 — Completing the loop
Now let's handle the tool result and get the final answer:
import ollama
tools = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Perform arithmetic or math calculations ONLY. "
"Do NOT use for general knowledge questions.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "A valid Python math expression using numbers and operators only."
}
},
"required": ["expression"]
}
}
}
]
def calculate(expression: str) -> str:
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
TOOLS = {"calculate": calculate}
def run_with_tools(user_message: str):
messages = [{"role": "user", "content": user_message}]
response = ollama.chat(model="qwen2.5", messages=messages, tools=tools)
if response.message.tool_calls:
messages.append(response.message)
for tool_call in response.message.tool_calls:
name = tool_call.function.name
args = tool_call.function.arguments
print(f" → Tool called: {name}({args})")
result = TOOLS[name](**args)
print(f" → Result: {result}")
# role must be "tool" — this is what feeds the result back to the LLM
messages.append({"role": "tool", "content": result})
final = ollama.chat(model="qwen2.5", messages=messages)
return final.message.content
return response.message.content
print(run_with_tools("What is 1337 * 42 + 100?"))
print(run_with_tools("What is the square root of 144?"))
print(run_with_tools("Who wrote Hamlet?")) # watch what happens here
The last question is interesting — because the description says "math only", the model should answer it directly without calling the tool at all.
Exercise 3 — Multiple tools
Now let's give the model three tools and let it pick the right one:
import ollama
import datetime
tools = [
{
"type": "function",
"function": {
"name": "calculate",
"description": "Perform arithmetic or math calculations ONLY. "
"Do NOT use for general knowledge, geography, or time questions.",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "A Python math expression"}
},
"required": ["expression"]
}
}
},
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Get the current date and time on this machine. "
"ONLY use when the user explicitly asks what time or date it is right now. "
"Do NOT use for geography, timezone facts, or general knowledge.",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read the contents of a local text file. "
"Use ONLY when the user asks to read or summarise a specific file.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path"}
},
"required": ["path"]
}
}
}
]
def calculate(expression: str) -> str:
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
def get_current_time() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def read_file(path: str) -> str:
try:
with open(path, "r") as f:
return f.read()[:2000]
except Exception as e:
return f"Error: {e}"
TOOLS = {"calculate": calculate, "get_current_time": get_current_time, "read_file": read_file}
def run(user_message: str):
print(f"\nUser: {user_message}")
messages = [{"role": "user", "content": user_message}]
response = ollama.chat(model="qwen2.5", messages=messages, tools=tools)
if response.message.tool_calls:
messages.append(response.message)
for tool_call in response.message.tool_calls:
name = tool_call.function.name
args = tool_call.function.arguments
print(f" → {name}({args})")
result = TOOLS[name](**args)
print(f" ← {str(result)[:100]}")
messages.append({"role": "tool", "content": str(result)})
final = ollama.chat(model="qwen2.5", messages=messages)
print(f"AI: {final.message.content}")
else:
print(f"AI: {response.message.content}")
run("What is 999 * 888?")
run("What time is it right now?")
run("Read the file README.md and summarise it in one sentence")
run("What is the capital of Japan?") # should answer directly — no tool needed
Watch which tool gets selected for each question. The model is reading your descriptions and matching them to what the user is asking. That selection logic is just prompting under the hood.
Exercise 4 — Description quality matters
This one makes the point directly. Same tool, same question, two different descriptions:
import ollama
vague_tools = [{
"type": "function",
"function": {
"name": "process",
"description": "Processes input.",
"parameters": {
"type": "object",
"properties": {"input": {"type": "string"}},
"required": ["input"]
}
}
}]
specific_tools = [{
"type": "function",
"function": {
"name": "process",
"description": "Converts any text to UPPERCASE. "
"Use ONLY when the user wants text transformed to capitals. "
"Do NOT use for general questions.",
"parameters": {
"type": "object",
"properties": {
"input": {"type": "string", "description": "The text to convert to uppercase"}
},
"required": ["input"]
}
}
}]
question = "Can you make this uppercase: hello world"
r1 = ollama.chat(model="qwen2.5", messages=[{"role": "user", "content": question}], tools=vague_tools)
r2 = ollama.chat(model="qwen2.5", messages=[{"role": "user", "content": question}], tools=specific_tools)
print("Vague description — tool called:", bool(r1.message.tool_calls))
print("Specific description — tool called:", bool(r2.message.tool_calls))
The vague description often results in no tool call. The specific one triggers reliably. This is the single most important thing to take away from this post — your tool description is a prompt, and it deserves the same care.
Wrapping up
Tools are what move LLMs from "text generators" to "actors". The mechanism is straightforward — you describe functions as JSON, the model decides when to call them, your code does the actual execution, and the result goes back into the conversation.
The one thing that trips people up most is the description. Write it like a prompt: be specific about when to use the tool, and explicit about when not to.
In Post #3, we look at RAG — where instead of calling functions, the LLM retrieves relevant knowledge from a document store before answering. See you there. 🚀
Top comments (0)