Function calling: letting the model use tools
Let a model look things up and take actions by calling your Python functions. Build a Brew & Bean assistant with automatic and manual function calling, and learn how to keep tool use safe.
What you will build: a Brew & Bean assistant that can look up menu prices and the status of an order by calling your Python functions. You will do it the easy way (the SDK runs the functions for you) and the manual way (so you understand the loop that every AI agent is built on).
Prerequisites: *Your first LLM API call (free Gemini API)* and *Structured output with JSON schemas*. You need the google-genai package and your key in .env.
The problem tools solve
A model only knows its training data and your prompt. It cannot check today's order status, read your database, send an email or do exact arithmetic. Function calling (also called tool use) closes that gap. You tell the model which functions exist; when it needs one, it replies with *a request to call it*, with arguments. Your code runs the function and sends the result back; the model then writes the final answer.
The key idea that confuses many beginners: the model never runs your code. It only writes a structured request like "call get_order_status with order_id = 42". Your program decides whether and how to run it. That makes you the gatekeeper, which matters for safety.
The tool-calling loop
| Step | Who | What happens |
|---|---|---|
| 1 | You | Send the question plus a list of tool descriptions |
| 2 | Model | Replies with a function call (name and arguments), not text |
| 3 | Your code | Runs the real function and gets a result |
| 4 | You | Send the result back to the model |
| 5 | Model | Writes the final answer using the result |
Step 1: Write the tools
A tool is an ordinary function. Keep them small, with clear names, type hints and a docstring. The docstring is what the model reads to decide when to use the tool, so write it carefully.
MENU = {
"espresso": ("Espresso", 120),
"cappuccino": ("Cappuccino", 180),
"cold-brew": ("Cold Brew", 220),
"filter-coffee": ("Filter Coffee", 80),
"masala-chai": ("Masala Chai", 90),
"croissant": ("Butter Croissant", 150),
"banana-bread": ("Banana Bread", 130),
"veg-sandwich": ("Veg Grilled Sandwich", 160),
}
# A pretend orders table. In a real app this is a database query.
ORDERS = {
42: {"status": "preparing", "pickup_time": "18:30"},
43: {"status": "ready", "pickup_time": "18:45"},
}
def get_menu_price(item_id: str) -> dict:
"""Get the name and price in rupees of one menu item.
Args:
item_id: Menu item ID such as 'cappuccino', 'cold-brew' or 'masala-chai'.
"""
if item_id not in MENU:
return {"error": f"Unknown item. Valid IDs: {', '.join(MENU)}"}
name, price = MENU[item_id]
return {"item_id": item_id, "name": name, "price_rupees": price}
def get_order_status(order_id: int) -> dict:
"""Look up the status and pickup time of an existing order.
Args:
order_id: The numeric order ID given to the customer.
"""
order = ORDERS.get(order_id)
if order is None:
return {"error": f"No order found with id {order_id}"}
return {"order_id": order_id, **order}Two read-only tools. Errors are returned as data so the model can explain them to the user.
Step 2: The easy way, automatic function calling
The Python SDK can read your function signatures and docstrings, send them as tool descriptions, run the function when the model asks and feed the result back, all inside one generate_content call.
from dotenv import load_dotenv
from google import genai
from google.genai import types
from tools import get_menu_price, get_order_status
load_dotenv()
MODEL = "gemini-flash-latest" # use a current model ID
client = genai.Client()
response = client.models.generate_content(
model=MODEL,
contents="How much is a cold brew, and is order 42 ready yet?",
config=types.GenerateContentConfig(
system_instruction="You are the Brew & Bean assistant. Use the tools for prices and order status. Never guess.",
tools=[get_menu_price, get_order_status],
),
)
print(response.text)Pass the functions themselves in tools. The SDK handles the loop.
A Cold Brew is Rs 220. Order 42 is still being prepared, and the pickup time is 18:30.Notice the question needed two tool calls. The model asked for both, the SDK ran them, and the model combined the results. You saw none of it, which is convenient but hides the mechanism. Next, do it by hand.
Step 3: The manual loop
In production you often need control: logging each call, asking the user to confirm, limiting tools per user or running tools in another service. For that you disable the automatic behaviour and run the loop yourself.
import json
from dotenv import load_dotenv
from google import genai
from google.genai import types
from tools import get_menu_price, get_order_status
load_dotenv()
MODEL = "gemini-flash-latest" # use a current model ID
client = genai.Client()
# Only functions in this dictionary can ever be run.
TOOLS = {"get_menu_price": get_menu_price, "get_order_status": get_order_status}
declarations = [
{
"name": "get_menu_price",
"description": "Get the name and price in rupees of one menu item.",
"parameters": {
"type": "object",
"properties": {
"item_id": {
"type": "string",
"description": "Menu item ID such as 'cappuccino' or 'cold-brew'.",
}
},
"required": ["item_id"],
},
},
{
"name": "get_order_status",
"description": "Look up the status and pickup time of an existing order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "integer", "description": "The numeric order ID."}
},
"required": ["order_id"],
},
},
]
config = types.GenerateContentConfig(
system_instruction="You are the Brew & Bean assistant. Use the tools for prices and order status. Never guess.",
tools=[types.Tool(function_declarations=declarations)],
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
contents = [
types.Content(
role="user",
parts=[types.Part(text="How much is a cold brew, and is order 42 ready yet?")],
)
]
MAX_STEPS = 5 # never loop forever
for step in range(MAX_STEPS):
response = client.models.generate_content(model=MODEL, contents=contents, config=config)
if not response.function_calls: # no tool requested: this is the final answer
print(response.text)
break
# Keep the model's own turn in the history (it holds the call requests)
contents.append(response.candidates[0].content)
result_parts = []
for call in response.function_calls:
print(f"[tool] {call.name}({json.dumps(dict(call.args))})")
fn = TOOLS.get(call.name)
if fn is None:
result = {"error": "Unknown tool"}
else:
try:
result = fn(**call.args)
except Exception as e:
result = {"error": str(e)}
result_parts.append(
types.Part.from_function_response(name=call.name, response={"result": result})
)
# Send all results back in ONE user turn
contents.append(types.Content(role="user", parts=result_parts))
else:
print("Stopped: too many tool steps.")The whole agent pattern in about 40 lines: ask, run the requested tools, send results back, repeat until the model answers in text.
[tool] get_menu_price({"item_id": "cold-brew"})
[tool] get_order_status({"order_id": 42})
A Cold Brew is Rs 220. Order 42 is being prepared and will be ready for pickup at 18:30.How the loop works
- 1Define the tools as JSON-Schema declarations (name, description, parameters).
- 2Send the question with the declarations and turn off automatic calling.
- 3If
response.function_callsis empty, the model is done: printresponse.text. - 4Otherwise, append the model's turn to the history, run each requested function from your allow-list and collect the results.
- 5Append the results as a user turn with
Part.from_function_responseand call the model again. - 6Stop after
MAX_STEPSso a confused model cannot loop forever.
Keep the model's turn intact
Always append response.candidates[0].content to the history as it is, rather than rebuilding it from text. Newer Gemini models attach internal data to tool-call turns that must be sent back unchanged, otherwise the next call can fail.
Writing tools the model uses well
A good tool definition
- Names are verbs that say what they do:
get_order_status, notorders - The description says when to use the tool, and when not to
- Each parameter has a type and a description with an example value
- Use enums for fixed choices, and keep the number of parameters small
- Return small, clean results (not a whole database row dump)
- Return errors as data with a helpful message so the model can recover
- Keep the tool list short; ten sharp tools beat fifty vague ones
Safety: you are the gatekeeper
Treat tool arguments as untrusted input
Arguments come from a model that may be confused or tricked by text in a document or a user message (prompt injection). Never pass them straight into SQL strings or shell commands. Validate every argument, check that the logged-in user is allowed to see that order, and use an allow-list of tools like TOOLS above.
Match control to risk
| Tool type | Examples | Rule |
|---|---|---|
| Read-only | Get price, search docs | Safe to run automatically after validation |
| Reversible write | Add to cart, save draft | Allow, but log it |
| Irreversible or costly | Place order, send email, refund, delete | Ask the user to confirm first, in your UI |
| Dangerous | Run shell code, arbitrary SQL | Avoid, or sandbox heavily |
A related idea from the interview angle: tools are how an LLM "takes actions". A multi-step loop with tools is the core of an agent, covered in *AI agents: tools, loops and planning*. A standard way to share tools between applications is covered in *Model Context Protocol (MCP)*.
How this comes up in interviews
Expect: "Does the model execute the function?" (No: it requests, your code runs.) "How do you prevent a tool from being abused?" (Validate arguments, authorise the user, allow-list, confirm risky actions, cap steps and cost.) "What if the tool fails?" (Return an error message as the tool result; let the model explain or retry once.)
Here is a Python function from my app:
[paste function]
Rewrite it as a tool for an LLM: give it a clear name, a docstring that says when to use it and what each argument means, strict type hints, and input validation. Also list 3 ways a model or a malicious user could misuse it and how to prevent each.What to expect: Check that the docstring would let a stranger know when to use the tool, and that the misuse list includes access control (can this user see this data?).
Try it: Add a cancel_order tool safely
Add cancel_order(order_id: int) to tools.py. It should only cancel orders whose status is received or preparing. In manual_tools.py, before running it, print the planned action and ask Confirm? (y/n) in the terminal. If the user says no, return {"error": "User declined"} as the result.
Key takeaways
- Function calling lets a model request actions or data; your code runs the functions and returns the results.
- The model never executes anything: it outputs a function name and arguments.
- The SDK can run the loop for you; the manual loop gives you logging, confirmation and control.
- Tool descriptions are prompts: clear names, docstrings and typed, described parameters make the model choose well.
- Validate arguments, authorise users, allow-list tools, cap loop steps, and confirm irreversible actions.
- Return errors as data so the model can recover or explain.
Check yourself
Finishing the quiz marks the lesson as done.
1. Who actually runs the function in function calling?
2. Why does the manual loop have a MAX_STEPS limit?
3. Which tool should require explicit user confirmation before it runs?
4. What is the best way to report a failed tool call (for example, order not found) to the model?
5. What makes the model choose the right tool?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.