Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/OpenAI SDK

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
  1. 1. The loop
  2. 2. Describing a tool
  3. 3. A complete, safe loop
  4. 4. Rules for tools in production
  5. 5. Controlling tool use
  6. Recap

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

① your code → model user question + tool schemas ② model → function_call get_order(order_id="1042") ③ your code runs it validate args → call → result ④ function_call_output sent back with the call_id repeat until the model answers with a message (always cap the number of rounds)

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

RuleWhy
Allow-list functions; never eval or getattr arbitrary namesthe 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'sthe tool should only see what the signed-in user may see
Confirm side effects (refunds, emails, deletes) with the usera misread instruction should not move money
Return errors as outputsthe model can recover ("that id doesn't exist, which order did you mean?")
Cap rounds, time and tokensloops can run away; each round costs a full request
Keep outputs smalltool 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.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries