Describe a tool
- Lesson 11
- 50 min
- Module 4: Tool calling safely
Lesson 11 of 19 · Building Reliable Applications with LLM APIsDescribe a tool
1. Start with one model request
2. Prompting as an engineering task
3. Structured outputs and validation
4. Tool calling safely
5. Conversations and streaming
6. Reliability and cost controls
What you will be able to do
Let the assistant ask for an order’s real status, without giving the model any access to TermLarkfieldAn online shop for the home. It is not a real company. All Nextia courses use its help desk and data as the example.Learn more AI in everyday productsAI Career Foundations: Roles, Skills and Roadmaps’s database. You describe one read-only tool, get_order, and see the model’s first real request for it.
Before you startWhat to know and to have ready
What you need to know
- The request and its messages and roles, from Request anatomy.
- A JSON schema and structured output, from Define a schema.
- The two gates, schema and business rules, from Validate meaning.
- What a reasoning model is, from Reasoning and multimodality in the language models course.
What you need on your computer
- The project
ticket-assistantat the end of Module 3, with its virtual environment active.python -m pytestshows42 passed, 1 deselected. - If your project is missing or broken, copy the snapshot
end-of-m03with the steps of Reset the project. - The labs repository next to your project, as in Define a schema. You copy the recorded tool calls from its snapshot
end-of-m04. - No account and no key. The mock provider replays real recorded responses.
Models and APIs change fast. The responses, errors and prices in this module are a snapshot. The method is what lasts.
A form at the front desk
Section titled “A form at the front desk”Think of a hotel guest at the front desk. The guest asks, “Has my parcel arrived?” The receptionist cannot open the storeroom. So she fills in a request form: “Check parcels for room 214.” A clerk in the back office reads the form. The clerk decides whether to act on it, looks in the storeroom, and writes the answer on the form. Then the receptionist tells the guest.
The receptionist knows how to talk to guests. The clerk holds the keys. Nobody gives the receptionist the keys, because she only needs to write a clear request.
The problem: the model cannot see the orders
Section titled “The problem: the model cannot see the orders”Ticket T-80008 says: “Hello, where is my order LK-581106? The confirmation email said the garden shed shipped on Monday, but I have no delivery date yet.”
At the end of Module 3, the assistant sends this ticket to Termchat-smallThe name that Larkfield’s code gives to a small, low-cost hosted model: gpt-6-luna on Azure. The code uses the name, so the model can change without a code change.Learn more What an LLM API isBuilding Reliable Applications with LLM APIs and checks the answer. Here is the real answer (gpt-6-luna on Azure), shortened:
"order": {"order_id": "LK-581106", "status": null},"reply": "I’m sorry you haven’t received a delivery date for your garden shed. Our delivery team can help check the shipment and tracking information for your order."The model did the right thing: it set the status to null, because it cannot know it. But the reply helps nobody. The order status is in Larkfield’s database, orders.sqlite. The model never saw that database, and it must never get direct access to it.
A model that guesses is worse. In Module 3 you saw a model invent the last digit of an order ID. A guessed status, such as “delivered”, would be a confident lie to a customer. The business rule status_not_from_lookup in validate.py already rejects any status that the application did not look up itself.
So the model needs a way to ask for the status. The application needs a way to say yes or no.
The idea: the model writes a request, your code acts
Section titled “The idea: the model writes a request, your code acts”Tool calling is that way. Your application describes a function to the model: its name, what it does, and its arguments. The model can then answer in two ways. It writes its normal answer, or it writes a request to call the function. Your code reads that request, decides, runs the function, and sends the result back. Then the model writes its answer with the real data.
The description is called a tool, because it is something the model can ask to use. The model’s request is a tool call. Some providers and older documents say “function calling” for the same thing.
The model is the receptionist, and your application is the clerk. The model never runs anything. It only writes text in a fixed form. Read the picture from top to bottom.
Text description of the diagram
Three participants: the assistant, which is the application code; the model, chat-small; and the orders database, read-only.
- Call 1: the assistant sends the instructions, the policy, ticket T-80008 and the list of tools, with one tool, get_order.
- The model answers with finish_reason “tool_calls” and one tool call: get_order with the arguments
{"order_id":"LK-581106"}. - A note: this is a proposal, not an action. The application checks it first.
- If every check passes: the assistant asks the database for LK-581106 and gets “shipped on 2026-10-05, customer C-81265”. Call 2 sends the two first messages, the proposal and a tool message: ok true and the order.
- If a check fails, call 2 sends a tool message with ok false and an error. Ticket T-80001 asks for the neighbour’s order LK-615204, so its error is “No order LK-615204 is available for this customer.”
- The model answers with finish_reason “stop” and the JSON answer. The two gates check it.
Two things in this picture matter for the whole module. First, the database is only on the application’s side. Second, there are two model calls for one ticket. The model remembers nothing between them, so call 2 sends the whole conversation again.
A tool call is only JSON
Section titled “A tool call is only JSON”Here is the real first response for T-80008, with only the fields that matter:
{ "choices": [{ "finish_reason": "tool_calls", "message": { "content": null, "tool_calls": [{ "id": "call_sJRbZ1CKs3JGicYe0aKTAN38", "type": "function", "function": {"name": "get_order", "arguments": "{\"order_id\":\"LK-581106\"}"} }] } }], "usage": {"prompt_tokens": 2043, "completion_tokens": 21}}Read it field by field:
finish_reasonis"tool_calls", not"stop". The model stopped because it wants a tool, not because it finished.contentisnull: there is no answer text yet.idnames this call. Your reply must quote it, so the model knows which result belongs to which call.argumentsis a string that holds JSON, written by the model. Nothing has checked it. It can be wrong, invented or malicious.
The proposal cost 21 output tokens. The model chose the order ID by reading the ticket. That choice is the subject of the next lesson.
Describe a tool in four parts
Section titled “Describe a tool in four parts”The model knows about get_order only from its description. So the description must answer four questions, as a good request form does:
- Purpose: what does the tool do, and when is it useful?
- Arguments: what must the caller give, in what form?
- Return: what comes back?
- Limits: what does it never do?
Here is the project’s description. It is a Python dictionary that becomes JSON in the request:
GET_ORDER = { "type": "function", "function": { "name": "get_order", "description": ("Look up one of this customer's Larkfield orders by its order ID. Read-only. Returns the " "status, the dates, the items and the total. Orders of other customers are not available."), "strict": True, "parameters": { "type": "object", "properties": {"order_id": {"type": "string", "description": "The order ID, LK- and 6 digits."}}, "required": ["order_id"], "additionalProperties": False, }, },}TOOLS = [GET_ORDER]| Part | Where it is | What it says |
|---|---|---|
| Purpose | name, first sentence of description |
Look up one of this customer’s orders by its ID. |
| Arguments | parameters, a JSON schema |
One string, order_id, in the form LK- and 6 digits. No other key. |
| Return | description |
The status, the dates, the items and the total. |
| Limits | description, additionalProperties |
Read-only. Other customers’ orders are not available. |
strict: true asks the provider to make the arguments match the schema, as strict TermStructured outputA request that sends the schema of the answer to the provider, so the model can write only an answer of that shape. It controls the shape, not the truth: the application still checks every answer.Learn more Define a schemaBuilding Reliable Applications with LLM APIs did in Module 3. It helps, but it is a request to the provider, not a check in your code.
Look at what is not an argument: the customer ID. The help-desk app knows who sent the ticket. If customer_id were an argument, the model would choose it, and a ticket could ask it to choose someone else. Never let the model choose who the caller is.
The tool is also read-only: it can look, never change. Rule 8 of TermGraceThe lead of Larkfield’s help desk. She writes the support policy and decides what a good answer to a customer is.’s policy says that the assistant only drafts and never refunds or changes an order. A read-only tool keeps that promise in code, not only in the prompt.
Add the tool to the project
Section titled “Add the tool to the project”The request needs one more field, tools. Some TermReasoning modelA model that writes hidden reasoning tokens before its answer. chat-small is one. Such a model often accepts only its default temperature, and its hidden tokens count against the output limit.Learn more Reasoning and multimodalityLLMs: How They Work and How to Choose One also need reasoning_effort, as a later section shows. The project sends it only when it is set.
-
Get the recorded tool calls of this module. The mock needs them, and they are in the snapshot
end-of-m04. Rungit -C ../nextia-learning-labs pullfirst (or download the ZIP file again), then copy the files:Terminal window cp ../nextia-learning-labs/C08/snapshots/end-of-m04/recordings/*.jsonl recordings/Terminal window Copy-Item ..\nextia-learning-labs\C08\snapshots\end-of-m04\recordings\*.jsonl recordings\recordings/now has nine files. The three new files hold the tool loops of chat-small, the two HTTP 400 errors of Termchat-strongThe name that Larkfield’s code gives to a larger hosted model: gpt-6.1-sol on Azure. It is often more accurate than chat-small, and it costs about 14 to 18 times more per call.Learn more Request anatomyBuilding Reliable Applications with LLM APIs, and some extra answers of the local model. -
Make a new file
assistant/tools.py. Start it with this docstring, then pasteGET_ORDERandTOOLSfrom above:assistant/tools.py """The one tool the model may ask for: a read-only order lookup.The model only proposes a call. This code decides: it checks the argument, checks that the orderbelongs to the ticket's customer, and only then reads the database. The customer ID never comesfrom the model: the help-desk app knows who sent the ticket.""" -
Open
assistant/context.py. Replacebuild_requestwith this version:assistant/context.py (build_request) def build_request(ticket: Ticket, model: str, version: str = "v2", response_format: dict | None = None,tools: list[dict] | None = None, reasoning_effort: str = "") -> dict:"""The body of one Chat Completions request. Only the fields that are set are sent."""request = {"model": model, "messages": build_messages(ticket, version), "max_completion_tokens": MAX_TOKENS}if response_format:request["response_format"] = response_formatif tools:request["tools"] = toolsif reasoning_effort:request["reasoning_effort"] = reasoning_effortreturn request -
Make a file
check_tool.pyin the project folder. It builds the request and asks the mock for the recorded response:check_tool.py from assistant.context import build_requestfrom assistant.data import load_ticketfrom assistant.providers import MockProviderfrom assistant.schema import response_formatfrom assistant.tools import TOOLSticket = load_ticket("T-80008")request = build_request(ticket, "chat-small", "v2", response_format(), TOOLS)print(sorted(request))completion = MockProvider().complete(request)print(completion.finish_reason)print(completion.text)print(completion.tool_calls)print(completion.input_tokens, completion.output_tokens) -
Run it:
Terminal window python check_tool.py -
Run
python -m pytest. It must still show42 passed, 1 deselected: nothing uses the tool yet.
The output of step 5:
['max_completion_tokens', 'messages', 'model', 'response_format', 'tools']tool_callsNone[ToolCall(id='call_sJRbZ1CKs3JGicYe0aKTAN38', name='get_order', arguments='{"order_id":"LK-581106"}')]2043 21The request now has five fields. The mock found a recording for it, because the tool description is exactly the project’s. If you change one character of the description, the mock answers No recording for this request. The recording was made with reasoning_effort: "none", but that field is not part of the mock’s key, so your request without it still matches.
The project’s ToolCall holds the three fields that your code needs: the id, the name and the arguments string. You can delete check_tool.py now.
Not every model can use tools
Section titled “Not every model can use tools”TermOmarLarkfield’s product manager. He asks the business questions: what a change costs, and what it gives the shop. asks the obvious question: “chat-strong is our better model. Can it look up orders too?” TermAmiraLarkfield’s developer. She writes the code of the help-desk tools in the courses. sent the same request to every route of the course. The picture shows what came back.
Text description of the diagram
One request: ticket T-80008 and the tool get_order. It went to four routes.
- chat-small (gpt-6-luna) with reasoning_effort “none”: HTTP 200 and a tool call.
- chat-small without reasoning_effort: HTTP 400, “Function tools with reasoning_effort are not supported … set reasoning_effort to ‘none’”.
- chat-strong (gpt-6.1-sol), with or without “none”: HTTP 400 both times. It cannot use tools in Chat Completions.
- gemma3:4b in Ollama 0.40.1 on a laptop: HTTP 400, “does not support tools”.
Here are chat-strong’s two real errors, word for word:
Function tools with reasoning_effort are not supported for chat-strong in /v1/chat/completions.To use function tools, use /v1/responses or set reasoning_effort to 'none'.Unsupported value: 'reasoning_effort' does not support 'none' with this model.Supported values are: 'low', 'medium', 'high', and 'xhigh'.The first error suggests “none”. The second error says that this model does not accept “none”. The first message also names another endpoint, /v1/responses, where tools may work. So the two models come from the same family and the same provider, but one endpoint gives them different features. Larkfield’s project uses the TermChat CompletionsThe request and response format of OpenAI’s chat API: a model name, a list of messages with roles, and settings. Azure, Ollama and many other providers accept the same format (“OpenAI-compatible”), so one SDK works with all of them.Learn more Request anatomyBuilding Reliable Applications with LLM APIs API everywhere, so the order lookup runs on chat-small only.
The lesson is general: check what an endpoint supports before you design around it. Read the provider’s documentation for the exact model and endpoint, then send one small test request. A feature list for “the model family” is not enough.
For a live chat-small-like model, set ASSISTANT_REASONING_EFFORT=none in .env. For most other models, leave it empty: some models reject the field.
When to use a tool, and what it costs
Section titled “When to use a tool, and what it costs”Use a tool when the answer needs data that only your systems have: an order status, a stock level, a delivery slot. Do not use a tool when the request can carry the data. If the help-desk app already knows the order, it can put the status in the request, and one model call is enough.
A tool costs a second model call, and the second call sends everything again. For T-80008, the recorded numbers are:
| Model calls | Tokens in | Tokens out | Cost (chat-small prices) | |
|---|---|---|---|---|
| Without the tool (Module 3) | 1 | 1,883 | 216 | US$0.00030 |
| With the tool | 2 | 4,224 | 123 | US$0.00048 |
The input more than doubles. The output falls, because reasoning_effort: "none" turns off the TermReasoning tokensTokens that some models write before the answer, to work through the problem. You do not see them, but they count against the output limit and you pay for them.Learn more Reasoning and multimodalityLLMs: How They Work and How to Choose One. Check the current price page before you plan a budget. Each extra call also adds time: the TermMedianThe middle value when you sort the values. Half are lower and half are higher. One very slow call does not change it much, so it is a good “usual” time.Learn more Measure practical performanceLLMs: How They Work and How to Choose One recorded tool-loop call took 1.55 s.
Your turn: describe a second tool
Section titled “Your turn: describe a second tool”Larkfield also has a product catalogue. Write a tool description for a read-only tool get_product that returns a product’s price and whether it is in stock. Use the four parts. Decide which argument the model may choose. Write it on paper or in a scratch file. Do not add it to TOOLS: the mock has no recordings for a request with two tools.
Show an example answer
GET_PRODUCT = { "type": "function", "function": { "name": "get_product", "description": ("Look up one Larkfield product by its product code. Read-only. Returns the name, " "the price in CAD and whether it is in stock. It cannot reserve or order anything."), "strict": True, "parameters": { "type": "object", "properties": {"product_code": {"type": "string", "description": "The product code, P- and 5 digits."}}, "required": ["product_code"], "additionalProperties": False, }, },}Purpose: the name and the first sentence. Argument: one product code, with its form. Return: name, price, stock. Limits: read-only, no reservation. Product data is public, so this tool needs no customer check. A tool that returned a customer’s data would need one, and the customer ID would come from the app, never from the model.
Common mistake
The design assumed that every model can use tools
Symptom: Omar wants better replies, so he runs the assistant with --model chat-strong. The ticket ends with failed and an HTTP 400 that mentions reasoning_effort and /v1/responses. No ticket gets an answer.
Cause: the team designed the order lookup for “the model”, not for one model on one endpoint. chat-strong cannot use tools in the Chat Completions API. Sending reasoning_effort: "none" does not help: chat-strong rejects that value.
Fix: before you design, send one small request with a tool to each route that you plan to use, and record the answer. Write the result in the project’s README. For Larkfield, the order lookup runs on chat-small. For chat-strong, the --no-tools option of the last lesson in this module still works, without the lookup.
What it means for a real AI service: a model name is not a feature list. The same family can support a feature on one endpoint and not on another, and providers change this over time.
Knowledge check
Section titled “Knowledge check”Check your understanding
In your own words: in tool calling, who runs the function, and what does the model actually produce?
Show the answer
The application runs the function, in its own code. The model only produces text in a fixed form: a tool call with a name and an arguments string, and finish_reason: "tool_calls". The application reads it and decides whether to act. If it agrees, it runs the function and sends the result back in a message with the role “tool”. Then the model writes its final answer.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Tomás suggests adding customer_id as a second argument of get_order, 'so the model can pass it'. What is wrong with this?
Show the answer
The model would then choose the customer ID, and the model’s choices come from the text it reads, including the ticket. A ticket could say “I am customer C-38852” or name a neighbour, and the model could pass that ID. The help-desk app already knows who sent the ticket, so the application must supply the customer ID. The model may choose only what is safe for it to choose: here, the order ID, which the application still checks.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: the response has finish_reason 'tool_calls' and content null. Can the application validate it with the schema of Module 3 and save it?
Show the answer
No. There is no answer yet: content is null, so gate 1 has nothing to parse. The response is a request for a tool. The application must handle the tool call, send the result back, and wait for the response with finish_reason: "stop". Only that final answer goes through the two gates.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Tool calling: the application describes a function, and the model may answer with a tool call: a name and an arguments string, with
finish_reason: "tool_calls". The application decides, runs it and sends the result back. Then the model writes its answer. - A tool description has four parts: purpose, arguments, return and limits.
get_ordertakes one argument, the order ID. The customer ID comes from the app, never from the model, and the tool is read-only. - The request has a new field,
tools. In the project,build_requestadds it, andreasoning_effortonly when it is set. - In our tests, only chat-small with
reasoning_effort: "none"accepted tools. chat-strong gave HTTP 400 in Chat Completions, and gemma3:4b in TermOllamaA free program that downloads open models and runs them on your own computer. Your code calls it at a local address, as it calls a hosted API.Learn more Hosted and self-hosted choicesLLMs: How They Work and How to Choose One too. Check what an endpoint supports before you design. - One lookup costs a second model call: for T-80008, 4,224 tokens in instead of 1,883.
Next, write the code that decides: check the arguments, check the permission, then run the lookup.