OpenAI SDK: Function calling: the tool loop
A model cannot query your database or call your API. What it can do is ask you to . You describe functions (name, purpose, JSON Schema for the arguments).
In this post
What this post covers
A model cannot query your database or call your API. What it can do is ask you to. You
describe functions (name, purpose, JSON Schema for the arguments). When the model decides one is needed,
it returns a function_call item with arguments. Your code
runs the function and sends the result back, and the model continues. That loop is the foundation of
every agent. Getting its details right (validation, limits, errors) is what separates a demo from a product.
A head chef and a kitchen porter
The chef (model) knows what the dish needs but never leaves the pass. They shout "two onions, diced!" (a function call with arguments). The porter (your code) checks the request is sensible, fetches and dices, and hands them back (the function output). The chef decides the next step. The porter never lets the chef reach into the safe.
1. The loop
2. Describing a tool
a strict tool definition
tools = [{
"type": "function",
"name": "get_order",
"description": "Look up one order by its id. Use when the user asks about an order's status, "
"items or delivery. Do not guess ids; ask the user if none is given.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order id, digits only, e.g. '1042'"},
},
"required": ["order_id"],
"additionalProperties": False,
},
"strict": True, # arguments are guaranteed to match the schema
}]
The description is a prompt: it tells the model when to use the tool and how. Good names and descriptions matter more than clever system prompts. Keep the tool set small and distinct. Twenty overlapping tools confuse models just as they would confuse a new colleague.
3. A complete, safe loop
import json
from fake_openai import fake_client
ORDERS = {"1042": {"status": "DELIVERED", "delivered_on": "2025-10-12", "amount": 1500.0},
"1043": {"status": "PENDING", "delivered_on": None, "amount": 820.5}}
def get_order(order_id: str) -> dict:
if not order_id.isdigit():
raise ValueError("order_id must be digits")
return ORDERS.get(order_id) or {"error": f"no order {order_id}"}
TOOLS = {"get_order": get_order} # allow-list: the ONLY functions the model can reach
SCHEMAS = [{"type": "function", "name": "get_order", "description": "Look up one order by id.",
"parameters": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"], "additionalProperties": False}, "strict": True}]
def run_tool(call):
try:
func = TOOLS[call.name]
args = json.loads(call.arguments)
result = func(**args)
except Exception as e: # errors go BACK to the model, not up the stack
result = {"error": f"{type(e).__name__}: {e}"}
return {"type": "function_call_output", "call_id": call.call_id, "output": json.dumps(result)}
def ask(client, question, max_rounds=5):
response = client.responses.create(model="gpt-5.6-luna", input=question, tools=SCHEMAS)
for round_no in range(max_rounds):
calls = [item for item in response.output if item.type == "function_call"]
if not calls:
return response.output_text
print(f" round {round_no + 1}: model asked for", [(c.name, c.arguments) for c in calls])
outputs = [run_tool(c) for c in calls]
response = client.responses.create(model="gpt-5.6-luna", tools=SCHEMAS,
previous_response_id=response.id, input=outputs)
raise RuntimeError("too many tool rounds")
client = fake_client(script=[
{"tool_calls": [("get_order", {"order_id": "1042"}), ("get_order", {"order_id": "1043"})]},
"Order 1042 was delivered on 12 Oct 2025; order 1043 is still pending.",
])
print(ask(client, "What is the status of orders 1042 and 1043?"))
print("second request sent:", [o["type"] for o in client.fake.requests[1]["body"]["input"]])
Output
round 1: model asked for [('get_order', '{"order_id": "1042"}'), ('get_order', '{"order_id": "1043"}')]
Order 1042 was delivered on 12 Oct 2025; order 1043 is still pending.
second request sent: ['function_call_output', 'function_call_output']
The model asked for two calls in one turn (parallel tool calls), so both run before replying.
Each output carries the call_id it answers. previous_response_id tells the API
to continue from the earlier response, so you do not resend the history (part 15 shows the stateless
alternative: append response.output and the outputs to your own input list).
4. Rules for tools in production
| Rule | Why |
|---|---|
Allow-list functions; never eval or getattr arbitrary names | the model's output is untrusted text; prompt injection can make it ask for anything |
| Validate arguments (Pydantic, type checks, ranges, ownership) | strict schemas guarantee shape, not that order 1042 belongs to this user |
| Authorise with the user's permissions, not the app's | the tool should only see what the signed-in user may see |
| Confirm side effects (refunds, emails, deletes) with the user | a misread instruction should not move money |
| Return errors as outputs | the model can recover ("that id doesn't exist, which order did you mean?") |
| Cap rounds, time and tokens | loops can run away; each round costs a full request |
| Keep outputs small | tool results are input tokens on the next call. Return the 10 rows needed, not 10,000 |
5. Controlling tool use
client.responses.create(..., tools=SCHEMAS, tool_choice="auto") # default: model decides
client.responses.create(..., tools=SCHEMAS, tool_choice="required") # must call some tool
client.responses.create(..., tools=SCHEMAS,
tool_choice={"type": "function", "name": "get_order"}) # force this one
client.responses.create(..., tools=SCHEMAS, parallel_tool_calls=False) # at most one call per turn
Recap
- The model requests; your code executes. Send outputs back with the matching
call_id. - Descriptions are prompts; use strict schemas for well-formed arguments.
- Allow-list, validate, authorise, confirm side effects; return errors as outputs.
- Handle parallel calls and cap the number of rounds.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.