AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

Tool calling

On its own, a language model can only produce text. Tool calling (also called function calling) lets it produce a structured request — “call get_order_status with order_id A-1001” — which your code executes and feeds back. The model never runs anything itself; your code stays in control. This lesson covers the full cycle, a reusable tool registry, validation and error handling, and how to design tools a model uses well.

Last updated: 01 Nov, 2026

What tool calling is

Definition · Tool (function) definition

A description you send with the request: the tool's name, a natural-language description of what it does and when to use it, and a JSON Schema for its parameters.

Definition · Tool call

Instead of (or as well as) text, the model returns a structured request containing a call id, the tool name, and a JSON string of arguments. Your code runs the tool and sends the result back in a tool message with the same call id.

The cycle has four steps:

  1. You send messages plus tool definitions.
  2. The model replies with one or more tool calls (or a normal answer if no tool is needed).
  3. Your code validates and executes each call.
  4. You send the results back; the model uses them to answer — or calls more tools.

Defining a tool

Tool definition
tools = [{
    "type": "function",
    "function": {
        "name": "get_order_status",
        "description": "Look up the shipping status of a customer order. Use when the user asks where their order is.",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string", "description": "Order id, like 'A-1001'"},
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
    },
}]

The description is a prompt: the model decides whether and how to call the tool almost entirely from the name, description, and parameter descriptions.

A tool registry built from type hints

Writing schemas by hand gets repetitive and drifts out of sync with the code. A decorator can build the schema from the function's signature and docstring.

Python
import inspect
import json
from typing import get_type_hints

JSON_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean"}
TOOLS: dict[str, dict] = {}


def tool(fn):
    """Register fn as a tool and build its JSON Schema from type hints."""
    hints = get_type_hints(fn)
    params = inspect.signature(fn).parameters
    properties = {name: {"type": JSON_TYPES[hints[name]]} for name in params}
    required = [name for name, p in params.items() if p.default is inspect.Parameter.empty]
    TOOLS[fn.__name__] = {
        "fn": fn,
        "schema": {
            "type": "function",
            "function": {
                "name": fn.__name__,
                "description": inspect.getdoc(fn),
                "parameters": {"type": "object", "properties": properties, "required": required},
            },
        },
    }
    return fn


ORDERS = {"A-1001": "shipped", "A-1002": "processing"}
RATES = {"EUR": 0.92, "INR": 83.1}


@tool
def get_order_status(order_id: str) -> dict:
    """Look up the shipping status of a customer order by id, e.g. 'A-1001'."""
    if order_id not in ORDERS:
        raise KeyError(f"no order with id {order_id}")
    return {"order_id": order_id, "status": ORDERS[order_id]}


@tool
def convert_currency(amount: float, to: str = "EUR") -> dict:
    """Convert an amount in USD to another currency (EUR or INR)."""
    return {"amount": round(amount * RATES[to], 2), "currency": to}


print(json.dumps(TOOLS["convert_currency"]["schema"], indent=2))
{ "type": "function", "function": { "name": "convert_currency", "description": "Convert an amount in USD to another currency (EUR or INR).", "parameters": { "type": "object", "properties": { "amount": { "type": "number" }, "to": { "type": "string" } }, "required": [ "amount" ] } } }

Executing calls safely

Tool arguments come from the model, which means they ultimately come from user input. Treat them like any untrusted input: parse, validate, and catch errors. Return errors to the model as the tool result — it can often fix its own mistake on the next step.

Python
def run_tool_call(name: str, arguments: str) -> str:
    """Execute one tool call and always return a JSON string for the model."""
    if name not in TOOLS:
        return json.dumps({"error": f"unknown tool '{name}'"})
    try:
        args = json.loads(arguments)
    except json.JSONDecodeError:
        return json.dumps({"error": "arguments were not valid JSON"})
    try:
        return json.dumps(TOOLS[name]["fn"](**args))
    except TypeError:
        return json.dumps({"error": f"wrong arguments for {name}; expected {list(inspect.signature(TOOLS[name]['fn']).parameters)}"})
    except Exception as error:
        return json.dumps({"error": f"{type(error).__name__}: {error}"})


print(run_tool_call("get_order_status", '{"order_id": "A-1001"}'))
print(run_tool_call("get_order_status", '{"id": "A-1001"}'))
print(run_tool_call("get_order_status", '{"order_id": "Z-9"}'))
print(run_tool_call("delete_everything", "{}"))
{"order_id": "A-1001", "status": "shipped"} {"error": "wrong arguments for get_order_status; expected ['order_id']"} {"error": "KeyError: 'no order with id Z-9'"} {"error": "unknown tool 'delete_everything'"}

The full round trip

Here is the complete conversation with a simulated model that behaves like the real API: first it requests a tool, then it answers using the result.

Python
def fake_model(messages: list[dict]) -> dict:
    """Returns an assistant message, possibly containing tool calls (same shape as the API)."""
    last = messages[-1]
    if last["role"] == "user":
        return {"role": "assistant", "content": None, "tool_calls": [
            {"id": "call_1", "type": "function",
             "function": {"name": "get_order_status", "arguments": '{"order_id": "A-1001"}'}},
        ]}
    status = json.loads(last["content"])["status"]
    return {"role": "assistant", "content": f"Good news - order A-1001 has {status}."}


messages = [{"role": "user", "content": "Where is my order A-1001?"}]
reply = fake_model(messages)
messages.append(reply)
for call in reply.get("tool_calls") or []:
    result = run_tool_call(call["function"]["name"], call["function"]["arguments"])
    messages.append({"role": "tool", "tool_call_id": call["id"], "content": result})
final = fake_model(messages)
messages.append(final)

for m in messages:
    detail = m["content"] or f"tool_calls={[c['function']['name'] for c in m['tool_calls']]}"
    print(f"{m['role']:>9}: {detail}")
user: Where is my order A-1001? assistant: tool_calls=['get_order_status'] tool: {"order_id": "A-1001", "status": "shipped"} assistant: Good news - order A-1001 has shipped.

The same cycle against the real API — note that the assistant message containing the tool calls must be appended before the tool results:

Python
schemas = [t["schema"] for t in TOOLS.values()]
messages = [{"role": "user", "content": "Where is my order A-1001, and what is 50 USD in INR?"}]

response = client.chat.completions.create(model="gpt-4.1-mini", messages=messages, tools=schemas)
assistant = response.choices[0].message
messages.append(assistant)                              # keep the tool_calls in history

for call in assistant.tool_calls or []:
    result = run_tool_call(call.function.name, call.function.arguments)
    messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

final = client.chat.completions.create(model="gpt-4.1-mini", messages=messages, tools=schemas)
print(final.choices[0].message.content)
Your order A-1001 has shipped. 50 USD is 4,155 INR.
Definition · Parallel tool calls

The model may return several tool calls in one reply when they do not depend on each other — here, the order lookup and the currency conversion. Run them all (concurrently if they are slow) and send one tool message per call id before calling the model again.

Controlling tool use

tool_choiceBehaviour
"auto" (default)The model decides whether to call tools.
"none"The model must answer in text.
"required"The model must call at least one tool.
{"type": "function", "function": {"name": "..."}}The model must call this specific tool — useful for structured extraction.

Adding "strict": true to a function definition (with additionalProperties: false and every property listed in required) makes the API guarantee the arguments match your schema.

Designing good tools

GuidelineWhy
Clear verb–noun names: search_orders, send_emailThe name is the model's first clue about purpose.
Descriptions that say when to use the tool, not just what it doesPrevents the model from picking a similar-looking tool.
Few parameters, with enums for fixed choicesFewer ways to get arguments wrong.
Return small, relevant resultsHuge tool outputs waste context and bury the answer.
Return helpful error messagesThe model can correct a bad argument if the error says what was expected.
Keep the tool set small and distinctAccuracy drops when many tools overlap.
Separate read tools from write toolsLets you auto-approve reads and gate writes (Human in the loop).
Watch out

Never expose tools like run_sql(query) or run_shell(command) to a model that reads untrusted text. A prompt injection hidden in an email or web page could make it run anything. Expose narrow tools such as get_order_status(order_id) and enforce permissions in code.

Try it
  • Add a calculator(expression: str) tool that only accepts digits, spaces, and +-*/()., and test it with a malicious expression.
  • Extend tool() to read parameter descriptions from the docstring.
  • Make convert_currency return a helpful error listing supported currencies when to is unknown.

Key takeaways

Key takeaways
  • The model only requests tool calls; your code executes them and stays in control.
  • Tool names, descriptions, and parameter schemas are prompts — write them carefully.
  • Validate arguments and return errors as tool results so the model can recover.
  • Append the assistant's tool-call message, then one tool message per call id, before calling the model again.
  • Prefer small, narrow, well-described tools, and never expose arbitrary code or SQL execution.