Tool Calling¶
In the previous recipe we registered one of Memory’s built-in tools (locate) on an LLM and let the LLM decide when to call it. That’s the most direct path when the tool you want is already a @component_action on a managed component. In this recipe we generalise the pattern: we write our own Python function and register it as a tool on the LLM. The function can do anything Python can do – read a sensor, hit a web service, toggle a piece of hardware, run a calculation – and the LLM picks it up as a callable tool with the schema we declare.
The key idea: when the LLM is configured with one or more registered tools, it produces structured tool calls instead of raw text. A tool call has a name and JSON-serialisable arguments that match the tool’s declared schema. The framework dispatches the call into our Python function, and we choose what happens to the return value – either:
send_tool_response_to_model=True— the function’s return is fed back to the LLM as a tool result, and the LLM produces a follow-up generation that uses it. The right mode when you want the LLM to react to the tool’s answer.send_tool_response_to_model=False— the function’s return value is the LLM component’s output, and gets published on the output topic directly. The right mode when you just want the LLM to extract structured arguments and you handle the rest. This was what we used in the GoTo Navigation recipe.
Tool calling works with any client that supports it – the OllamaClient and the GenericHTTPClient both do, as long as the underlying model is tool-trained.
What we’re building¶
A tiny voice-of-the-world agent: an LLM component that can answer questions about the current weather anywhere on Earth by calling a custom function that hits the public Open-Meteo API. Weather is the canonical case for tool calling – the LLM categorically can’t know it from training data, so the tool earns its place by giving the model live information it would otherwise have to invent.
Component |
Role |
|---|---|
LLM |
Receives a free-form question, decides whether to call our |
|
A regular Python function that geocodes a city name, queries Open-Meteo, and returns the current conditions. |
This shows the full tool-calling loop: tool result back to LLM → LLM phrases an answer in natural language. Anywhere we’d like the LLM to use live data instead of hallucinating it – a sensor reading, a database row, a web fetch, a sub-process – follows the same shape.
Step 1: The LLM¶
A plain LLM component using a tool-capable Ollama model.
from agents.clients import OllamaClient
from agents.components import LLM
from agents.config import LLMConfig
from agents.models import OllamaModel
from agents.ros import Launcher, Topic
qwen = OllamaModel(name="qwen", checkpoint="qwen3.5:latest")
qwen_client = OllamaClient(qwen)
question = Topic(name="question", msg_type="String")
answer = Topic(name="answer", msg_type="String")
assistant = LLM(
inputs=[question],
outputs=[answer],
model_client=qwen_client,
trigger=question,
config=LLMConfig(),
component_name="assistant",
)
assistant.set_component_prompt(
template=(
"You are a friendly assistant. If the user asks about the current "
"weather, temperature, or wind in a location, call the "
"``get_weather`` tool with the city name and answer using the data "
"it returns. The user said: {{question}}"
)
)
The prompt nudges the model toward the tool when the question is weather-related. For unrelated questions the LLM will just answer directly without calling anything.
Step 2: Define the tool¶
A regular Python function that takes a city name, geocodes it, and queries the Open-Meteo current-conditions endpoint. No API key required.
from typing import Dict, Union
import httpx
def get_weather(city: str) -> Dict[str, Union[str, float]]:
"""Look up the current weather for *city* via the Open-Meteo public API.
Returns a dict with the city's resolved name, temperature, and wind
speed -- or ``{"error": "..."}`` if the city couldn't be resolved.
"""
# Geocoding: city name -> (lat, lon)
geo = httpx.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10.0,
).json()
if not geo.get("results"):
return {"error": f"could not find location '{city}'"}
place = geo["results"][0]
lat, lon = place["latitude"], place["longitude"]
# Current weather at those coordinates
weather = httpx.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,wind_speed_10m",
},
timeout=10.0,
).json()["current"]
return {
"city": place.get("name", city),
"country": place.get("country", ""),
"temperature_c": weather["temperature_2m"],
"wind_speed_kmh": weather["wind_speed_10m"],
}
httpx is already a dependency of EMOS, so no extra install. The function:
takes one string argument (
city),does its own work (two HTTP calls – geocoding, then weather),
returns a small dict the LLM can phrase prose around.
Step 3: Declare the tool’s schema¶
The LLM needs an OpenAI-format description so it knows the tool’s name, what it does, and what arguments to provide.
get_weather_description = {
"type": "function",
"function": {
"name": "get_weather",
"description": (
"Look up the current weather (temperature in Celsius and wind "
"speed in km/h) for a given city. Call this whenever the user "
"asks about live weather conditions anywhere in the world."
),
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": (
"City name, optionally with country (e.g. 'Berlin', "
"'Tokyo', 'Paris, France')."
),
},
},
"required": ["city"],
},
},
}
Spend the description budget on telling the model when to call the tool and what the arguments mean. The LLM doesn’t see the function body; it only sees the description.
Step 4: Register the tool¶
assistant.register_tool(
tool=get_weather,
tool_description=get_weather_description,
send_tool_response_to_model=True,
)
send_tool_response_to_model=True is what makes this recipe a conversational loop:
User asks “what’s the weather in Berlin right now?”.
LLM emits a structured
get_weather(city="Berlin")tool call.Framework dispatches the call, the function geocodes Berlin and hits Open-Meteo.
The result –
{"city": "Berlin", "country": "Germany", "temperature_c": 18.3, "wind_speed_kmh": 11.5}– is fed back into the LLM as a tool message.The LLM produces the final user-facing reply: “It’s currently 18°C in Berlin with a light breeze around 12 km/h.”
That reply gets published on
answer.
If the user asks something off-topic (“what’s the capital of Peru?”), the LLM never calls the tool and answers from its own knowledge.
Step 5: Launch¶
launcher = Launcher()
launcher.add_pkg(components=[assistant])
launcher.bringup()
Full recipe code¶
1from typing import Dict, Union
2
3import httpx
4
5from agents.clients import OllamaClient
6from agents.components import LLM
7from agents.config import LLMConfig
8from agents.models import OllamaModel
9from agents.ros import Launcher, Topic
10
11
12# -- LLM --
13qwen = OllamaModel(name="qwen", checkpoint="qwen3.5:latest")
14qwen_client = OllamaClient(qwen)
15
16question = Topic(name="question", msg_type="String")
17answer = Topic(name="answer", msg_type="String")
18
19assistant = LLM(
20 inputs=[question],
21 outputs=[answer],
22 model_client=qwen_client,
23 trigger=question,
24 config=LLMConfig(),
25 component_name="assistant",
26)
27
28assistant.set_component_prompt(
29 template=(
30 "You are a friendly assistant. If the user asks about the current "
31 "weather, temperature, or wind in a location, call the "
32 "``get_weather`` tool with the city name and answer using the data "
33 "it returns. The user said: {{question}}"
34 )
35)
36
37
38# -- Custom tool: live weather via Open-Meteo --
39def get_weather(city: str) -> Dict[str, Union[str, float]]:
40 """Look up the current weather for *city* via the Open-Meteo public API."""
41 geo = httpx.get(
42 "https://geocoding-api.open-meteo.com/v1/search",
43 params={"name": city, "count": 1},
44 timeout=10.0,
45 ).json()
46 if not geo.get("results"):
47 return {"error": f"could not find location '{city}'"}
48
49 place = geo["results"][0]
50 lat, lon = place["latitude"], place["longitude"]
51
52 weather = httpx.get(
53 "https://api.open-meteo.com/v1/forecast",
54 params={
55 "latitude": lat,
56 "longitude": lon,
57 "current": "temperature_2m,wind_speed_10m",
58 },
59 timeout=10.0,
60 ).json()["current"]
61
62 return {
63 "city": place.get("name", city),
64 "country": place.get("country", ""),
65 "temperature_c": weather["temperature_2m"],
66 "wind_speed_kmh": weather["wind_speed_10m"],
67 }
68
69
70get_weather_description = {
71 "type": "function",
72 "function": {
73 "name": "get_weather",
74 "description": (
75 "Look up the current weather (temperature in Celsius and wind "
76 "speed in km/h) for a given city. Call this whenever the user "
77 "asks about live weather conditions anywhere in the world."
78 ),
79 "parameters": {
80 "type": "object",
81 "properties": {
82 "city": {
83 "type": "string",
84 "description": (
85 "City name, optionally with country (e.g. 'Berlin', "
86 "'Tokyo', 'Paris, France')."
87 ),
88 },
89 },
90 "required": ["city"],
91 },
92 },
93}
94
95assistant.register_tool(
96 tool=get_weather,
97 tool_description=get_weather_description,
98 send_tool_response_to_model=True,
99)
100
101
102# -- Launch --
103launcher = Launcher()
104launcher.add_pkg(components=[assistant])
105launcher.bringup()
When to use which pattern¶
Need |
Pattern |
|---|---|
Use a tool that’s already a |
|
Custom Python function as the tool, with a schema you define. |
|
Multiple capabilities orchestrated by an LLM that decides what to do. |
A Cortex component, which auto-discovers every |
And within the custom-function pattern itself:
|
When |
|---|---|
|
The LLM should react to the tool’s output – compose a sentence, decide what to do next, ask a follow-up. Conversational and reasoning agents. |
|
The tool’s return value is what should be published. You’re using the LLM to extract structured arguments, and the function does the real work. |
Tip
Promote this recipe to production. While you’re shaping it, the script runs straight with python recipe.py. Once it’s solid, drop it at ~/emos/recipes/<your_name>/recipe.py and run emos run <your_name> – you’ll get sensor pre-flight checks, persistent logs, and a card on the dashboard so an operator can launch it from a browser. See Running Recipes for the full development-vs-production comparison and install-mode pitfalls (especially in Container mode).