,

Your first tool-using agent: function calling done right

LEARN · RAG & AGENTIC DEVELOPMENT

What you are actually building

A tool-using agent is not a language model that somehow reaches into your Python process and executes functions by itself.

The division of responsibility is much cleaner:

  • The model decides whether a tool would help.

  • The model produces structured arguments for that tool.

  • Your application receives the requested call.

  • Your application decides whether the call is allowed.

  • Your application validates and executes ordinary code.

  • Your application sends the result back to the model.

  • The model uses that result to continue the workflow or answer the user.

With OpenAI’s current Responses API, calls to developer-defined functions appear as function_call output items. Your application executes the requested operation and returns a corresponding function_call_output using the original call_id. The Responses API represents conversations as typed input and output items rather than treating everything as plain chat messages.

That gives us the most important architectural rule in this entire lesson:

The model can propose an operation. Your application remains the authority that decides what actually happens.

We are going to build a small shipping-support agent that can answer a question such as:

Where is order ORD-1002, and when should it arrive?

The answer requires two dependent operations:

  1. Look up the order.

  2. Use the carrier and tracking number from that lookup to retrieve shipping information.

The order and shipment data will be synthetic. You can therefore run the whole example without a database, logistics provider, customer account, or private dataset.

More importantly, you will see the complete function-calling loop rather than hiding it behind an orchestration framework.

Why use the Responses API directly?

OpenAI currently supports developer-defined function calling through the Responses API, and current model documentation lists gpt-5.6 as the alias for GPT-5.6 Sol. GPT-5.6 Sol supports function calling and structured outputs.

For this lesson, using the Responses API directly is useful because every important boundary stays visible.

You will explicitly see:

  • the tool definitions sent to the model,

  • the function_call items returned by the model,

  • argument parsing,

  • application-side dispatch,

  • business validation,

  • function_call_output,

  • and the next model turn.

Once that mechanism is clear, higher-level agent frameworks become much easier to evaluate because you know what loop they are automating.

A Responses API function definition includes fields such as:

  • type

  • name

  • description

  • parameters

  • strict

The parameters field uses JSON Schema syntax, but there is an important qualification: strict function calling does not support every feature of the full JSON Schema standard. OpenAI documents strict mode as using structured outputs and notes that some JSON Schema features are unsupported.

So it is more precise to say:

Function arguments are described with a supported subset of JSON Schema.

That distinction matters when you graduate from simple objects and enums to more complicated schemas.

Set up the project

The shell commands below use a POSIX-style shell, so they work directly on macOS, Linux, and environments such as WSL.

Create a project and virtual environment:

mkdir first-tool-agent
cd first-tool-agent
python -m venv .venv
source .venv/bin/activate

Install the current OpenAI Python SDK:

python -m pip install --upgrade openai

OpenAI’s current Python quickstart uses the openai package and the OpenAI client with client.responses.create(...). The SDK also reads OPENAI_API_KEY from the environment.

Set the key in your shell:

export OPENAI_API_KEY="your-api-key"

Do not hard-code a real API key into app.py, commit it to Git, paste it into logs, or embed it in a frontend bundle.

For a reproducible project, it is also useful to record the SDK version that you actually tested rather than trusting a tutorial indefinitely. After installation, capture the installed version:

python -m pip freeze | grep '^openai=='

Commit that resolved version to your dependency lock file or requirements file for the application you deploy. When you intentionally upgrade later, run your agent tests again before updating the lock.

Our project will remain deliberately small:

first-tool-agent/
├── .venv/
└── app.py

Start with ordinary application functions

Before introducing a model, write the capabilities as ordinary application code.

This habit prevents a common design failure: mixing authorization, data access, prompting, model behavior, and business rules into one enormous “agent function.”

Our sample data contains two orders and one shipment:

ORDERS = {
    "ORD-1001": {
        "status": "processing",
        "carrier": None,
        "tracking_number": None,
    },
    "ORD-1002": {
        "status": "shipped",
        "carrier": "DHL",
        "tracking_number": "JD0146000062812345",
    },
}

SHIPMENTS = {
    "JD0146000062812345": {
        "carrier": "DHL",
        "location": "Leipzig distribution center",
        "estimated_delivery": "2026-08-13",
    },
}

The first application function looks up an order:

from typing import Any


def lookup_order(order_id: str) -> dict[str, Any]:
    order = ORDERS.get(order_id)

    if order is None:
        return {
            "ok": False,
            "error": {
                "code": "order_not_found",
                "message": "No order exists with that ID.",
            },
        }

    return {
        "ok": True,
        "order": {
            "order_id": order_id,
            **order,
        },
    }

The second function looks up shipping information:

from typing import Any


def get_shipping_eta(
    carrier: str,
    tracking_number: str,
) -> dict[str, Any]:
    shipment = SHIPMENTS.get(tracking_number)

    if shipment is None or shipment["carrier"] != carrier:
        return {
            "ok": False,
            "error": {
                "code": "shipment_not_found",
                "message": "No matching shipment was found.",
            },
        }

    return {
        "ok": True,
        "shipment": {
            "tracking_number": tracking_number,
            **shipment,
        },
    }

These functions behave sensibly without a model.

That is intentional.

The model is not responsible for deciding whether an order really exists. The lookup system is.

The model is not responsible for deciding whether a tracking number corresponds to DHL. The shipping system is.

Once tools can issue refunds, modify infrastructure, send messages, change permissions, or delete data, this separation becomes a security boundary rather than merely good code organization.

Describe the tools to the model

We now tell the model which functions it is allowed to request.

The order lookup tool is intentionally narrow:

LOOKUP_ORDER_TOOL = {
    "type": "function",
    "name": "lookup_order",
    "description": (
        "Look up an order by its exact order ID. "
        "Use this before making claims about order status or shipping."
    ),
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {
                "type": "string",
                "description": "Exact order ID, for example ORD-1002.",
            },
        },
        "required": ["order_id"],
        "additionalProperties": False,
    },
}

The shipping tool requires the values returned by the first operation:

GET_SHIPPING_ETA_TOOL = {
    "type": "function",
    "name": "get_shipping_eta",
    "description": (
        "Get the current location and estimated delivery date "
        "for a shipment that already has a known carrier and tracking number."
    ),
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {
            "carrier": {
                "type": "string",
                "enum": ["DHL", "UPS", "FedEx"],
                "description": "Shipment carrier returned by the order system.",
            },
            "tracking_number": {
                "type": "string",
                "description": "Tracking number returned by the order system.",
            },
        },
        "required": ["carrier", "tracking_number"],
        "additionalProperties": False,
    },
}

There are several design decisions hidden in these small definitions.

Use strict schemas

OpenAI recommends strict mode for function calls. With strict set to true, generated arguments are constrained to the declared function schema rather than handled as best-effort schema matching. Current strict-mode requirements include setting additionalProperties to false on parameter objects and marking every declared property as required. Optional values can be represented by permitting null while keeping the field itself required.

For example, a required field whose value may be absent can use a nullable type:

{
  "type": ["string", "null"]
}

Strict mode is enormously useful because it can prevent a large class of malformed calls.

For example, if your schema says a carrier must be one of three enum values, the model cannot successfully produce a schema-conforming value such as "SomeRandomCarrier".

If your object prohibits additional properties, the model cannot add a surprise field such as admin_override.

If the schema requires an order ID, the generated call must structurally include that field.

But strict mode solves structure, not truth.

We will return to that distinction because it produces one of the most surprising failure modes in real tool design.

Describe the provenance of arguments

Compare these two parameter descriptions:

Tracking number.

and:

Tracking number returned by the order system.

The second one is better.

It does more than explain what the argument represents. It tells the model where the value is supposed to come from.

That gives the model a stronger signal that the value should be copied from authoritative tool output rather than guessed from the conversation.

Useful descriptions often encode provenance explicitly:

  • customer ID returned by the authenticated account lookup,

  • invoice ID returned by the billing system,

  • file ID returned by the upload operation,

  • project ID selected by the user,

  • tracking number returned by the order lookup.

Descriptions are not security controls. Your server still validates everything.

They are nevertheless useful behavioral guidance.

Keep tools narrow

Avoid starting with a giant function such as this conceptual design:

def manage_customer_account(action: str, payload: dict) -> dict:
    ...

The function may look flexible, but most of its real contract is now hidden inside action and payload.

The model has to infer:

  • which actions exist,

  • which payload fields belong to each action,

  • which fields are mutually exclusive,

  • which values are safe,

  • which operations require authorization,

  • and which state transitions are legal.

Prefer focused operations with explicit semantics.

Narrow tools are easier to:

  • authorize,

  • validate,

  • log,

  • test,

  • rate-limit,

  • evaluate,

  • monitor,

  • retry,

  • and remove later.

They also make failures easier to understand.

Build the actual function-calling loop

Now we can combine the application functions, schemas, dispatcher, and model loop.

Here is the complete app.py:

from __future__ import annotations

import json
import os
from typing import Any, Callable

from openai import OpenAI


if not os.getenv("OPENAI_API_KEY"):
    raise RuntimeError("Set OPENAI_API_KEY before running this program.")

MODEL = os.getenv("OPENAI_MODEL", "gpt-5.6")
client = OpenAI()


ORDERS = {
    "ORD-1001": {
        "status": "processing",
        "carrier": None,
        "tracking_number": None,
    },
    "ORD-1002": {
        "status": "shipped",
        "carrier": "DHL",
        "tracking_number": "JD0146000062812345",
    },
}


SHIPMENTS = {
    "JD0146000062812345": {
        "carrier": "DHL",
        "location": "Leipzig distribution center",
        "estimated_delivery": "2026-08-13",
    },
}


def lookup_order(order_id: str) -> dict[str, Any]:
    order = ORDERS.get(order_id)

    if order is None:
        return {
            "ok": False,
            "error": {
                "code": "order_not_found",
                "message": "No order exists with that ID.",
            },
        }

    return {
        "ok": True,
        "order": {
            "order_id": order_id,
            **order,
        },
    }


def get_shipping_eta(
    carrier: str,
    tracking_number: str,
) -> dict[str, Any]:
    shipment = SHIPMENTS.get(tracking_number)

    if shipment is None or shipment["carrier"] != carrier:
        return {
            "ok": False,
            "error": {
                "code": "shipment_not_found",
                "message": "No matching shipment was found.",
            },
        }

    return {
        "ok": True,
        "shipment": {
            "tracking_number": tracking_number,
            **shipment,
        },
    }


TOOLS = [
    {
        "type": "function",
        "name": "lookup_order",
        "description": (
            "Look up an order by its exact order ID. "
            "Use this before making claims about order status or shipping."
        ),
        "strict": True,
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "Exact order ID, for example ORD-1002.",
                },
            },
            "required": ["order_id"],
            "additionalProperties": False,
        },
    },
    {
        "type": "function",
        "name": "get_shipping_eta",
        "description": (
            "Get the current location and estimated delivery date "
            "for a shipment that already has a known carrier and tracking number."
        ),
        "strict": True,
        "parameters": {
            "type": "object",
            "properties": {
                "carrier": {
                    "type": "string",
                    "enum": ["DHL", "UPS", "FedEx"],
                    "description": "Shipment carrier returned by the order system.",
                },
                "tracking_number": {
                    "type": "string",
                    "description": "Tracking number returned by the order system.",
                },
            },
            "required": ["carrier", "tracking_number"],
            "additionalProperties": False,
        },
    },
]


TOOL_IMPLEMENTATIONS: dict[str, Callable[..., dict[str, Any]]] = {
    "lookup_order": lookup_order,
    "get_shipping_eta": get_shipping_eta,
}


INSTRUCTIONS = """
You are a shipping support assistant.

Rules:
- Use lookup_order before making factual claims about an order.
- Use get_shipping_eta only when you have a carrier and tracking number
  obtained from a tool result.
- Never invent order IDs, tracking numbers, carrier names, locations,
  dates, or tool results.
- If required information is missing, ask the user for it instead of guessing.
- Treat tool errors as authoritative.
- Do not claim that an operation succeeded unless its tool result says ok=true.
"""


def execute_tool_call(tool_call: Any) -> dict[str, Any]:
    implementation = TOOL_IMPLEMENTATIONS.get(tool_call.name)

    if implementation is None:
        return {
            "ok": False,
            "error": {
                "code": "unknown_tool",
                "message": f"Tool {tool_call.name!r} is not available.",
            },
        }

    try:
        arguments = json.loads(tool_call.arguments)
    except json.JSONDecodeError:
        return {
            "ok": False,
            "error": {
                "code": "invalid_json",
                "message": "Tool arguments were not valid JSON.",
            },
        }

    if not isinstance(arguments, dict):
        return {
            "ok": False,
            "error": {
                "code": "invalid_arguments",
                "message": "Tool arguments must be a JSON object.",
            },
        }

    try:
        return implementation(**arguments)
    except TypeError:
        return {
            "ok": False,
            "error": {
                "code": "invalid_arguments",
                "message": "Tool arguments did not match the application contract.",
            },
        }
    except Exception:
        return {
            "ok": False,
            "error": {
                "code": "tool_failure",
                "message": "The tool failed unexpectedly.",
            },
        }


def run_agent(user_message: str, max_turns: int = 8) -> str:
    input_items: list[Any] = [
        {
            "role": "user",
            "content": user_message,
        }
    ]

    for _ in range(max_turns):
        response = client.responses.create(
            model=MODEL,
            instructions=INSTRUCTIONS,
            tools=TOOLS,
            input=input_items,
            parallel_tool_calls=False,
        )

        input_items += response.output

        tool_calls = [
            item
            for item in response.output
            if item.type == "function_call"
        ]

        if not tool_calls:
            final_text = response.output_text.strip()

            if not final_text:
                raise RuntimeError("The model returned no final text.")

            return final_text

        for tool_call in tool_calls:
            result = execute_tool_call(tool_call)

            print(
                f"[tool] {tool_call.name}"
                f"({tool_call.arguments})"
                f" -> {json.dumps(result)}"
            )

            input_items.append(
                {
                    "type": "function_call_output",
                    "call_id": tool_call.call_id,
                    "output": json.dumps(
                        result,
                        separators=(",", ":"),
                    ),
                }
            )

    raise RuntimeError("Agent exceeded the maximum number of turns.")


if __name__ == "__main__":
    question = input("You: ")
    answer = run_agent(question)
    print(f"\nAssistant: {answer}")

There is intentionally no agent framework here.

The interesting behavior fits inside a loop because function calling is fundamentally a protocol between the model and your application.

Run the agent

Start the application:

python app.py

Ask:

You: Where is ORD-1002 and when should it arrive?

A successful run should follow this conceptual sequence:

User
  ↓
lookup_order(order_id="ORD-1002")
  ↓
order status + carrier + tracking number
  ↓
get_shipping_eta(
    carrier="DHL",
    tracking_number="JD0146000062812345"
)
  ↓
location + estimated delivery
  ↓
final natural-language answer

Because the data is synthetic, the final response can truthfully report that the shipment is at the Leipzig distribution center and has an estimated delivery date of August 13, 2026.

The important detail is not the final wording.

The important detail is that the model did not begin the conversation knowing those application facts.

They entered the interaction through tool results produced by your code.

Walk through one model turn

This request gives the model its instructions, available tools, and current conversation state:

response = client.responses.create(
    model=MODEL,
    instructions=INSTRUCTIONS,
    tools=TOOLS,
    input=input_items,
    parallel_tool_calls=False,
)

Instead of answering immediately, the model can return a function call conceptually resembling this:

{
  "type": "function_call",
  "call_id": "call_example123",
  "name": "lookup_order",
  "arguments": "{\"order_id\":\"ORD-1002\"}"
}

Generated IDs will differ from run to run.

Notice that arguments is represented as JSON text. Current OpenAI function-calling examples parse that text before invoking application code.

Our dispatcher therefore parses it:

arguments = json.loads(tool_call.arguments)

Then it resolves the function through an explicit whitelist:

implementation = TOOL_IMPLEMENTATIONS.get(tool_call.name)

The model does not get to name an arbitrary Python function and have the runtime execute it.

Only functions present in TOOL_IMPLEMENTATIONS can run.

After execution, the application sends the result back using the original call ID:

input_items.append(
    {
        "type": "function_call_output",
        "call_id": tool_call.call_id,
        "output": json.dumps(result),
    }
)

OpenAI currently documents function_call_output.output as typically being a string. JSON serialized into that string is a convenient choice when your tools return structured application data.

The next Responses API request now contains the model’s original call plus the application’s result.

At that point the model can either:

  • request another tool, or

  • produce the final user-facing answer.

That repeated cycle is your agent loop.

Why append the complete response.output?

This line deserves special attention:

input_items += response.output

It preserves the model’s output items before you append the tool result.

OpenAI’s current function-calling examples do the same when manually maintaining Responses API state. The documentation also notes that reasoning items returned alongside tool calls need to be passed back when continuing such a workflow.

Conceptually, you want the continuation to preserve the actual sequence:

user request
→ model output
→ function call
→ application result
→ model continuation

You should not casually throw away the model’s function-call item and send only an isolated tool result.

The call and its result are two sides of the same interaction.

Why disable parallel function calls here?

Our request contains:

parallel_tool_calls=False

That choice is deliberate.

The shipping operation depends on values produced by the order lookup.

The safe dependency graph is:

lookup order
    ↓
obtain carrier and tracking number
    ↓
look up shipment

The shipping lookup should not race ahead and guess the inputs it hopes the first call will produce.

OpenAI currently documents that a model may otherwise request multiple functions in one turn, while setting parallel_tool_calls to false restricts the turn to zero or one tool call.

Parallel calls make sense when operations are independent.

For example:

get_open_orders
get_account_balance
get_recent_support_tickets

If none of those functions depends on another’s result, parallel execution may reduce latency.

But parallelism should be an optimization applied after you understand the dependency graph.

Sequential correctness comes first.

The cherry on the cake: strict JSON can still contain invented facts

Here is a failure mode that surprises developers the first time they encounter it.

This is an illustrative scenario, not a claim about a specific production incident.

Suppose you give the model a refund tool:

DANGEROUS_TOOL = {
    "type": "function",
    "name": "refund_order",
    "description": "Refund a customer's order.",
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {
                "type": "string",
            },
            "amount_cents": {
                "type": "integer",
            },
            "customer_id": {
                "type": "string",
            },
            "reason": {
                "type": "string",
            },
        },
        "required": [
            "order_id",
            "amount_cents",
            "customer_id",
            "reason",
        ],
        "additionalProperties": False,
    },
}

Now the user says:

Refund my order because it arrived late.

The conversation contains no:

  • order ID,

  • customer ID,

  • refund amount.

Yet the schema demands all three.

A model that attempts to complete the requested task might produce a call resembling this:

{
  "order_id": "ORD-4821",
  "amount_cents": 4999,
  "customer_id": "CUST-1042",
  "reason": "Order arrived late"
}

From a schema perspective, that object looks excellent.

  • Every required field is present.

  • amount_cents is an integer.

  • The other fields are strings.

  • There are no extra properties.

  • The JSON is valid.

From a business perspective, it is unacceptable.

Nothing established that:

ORD-4821

is the user’s order.

Nothing established that:

CUST-1042

is the authenticated customer.

Nothing established that:

4999

is the correct refundable amount.

This is the distinction to remember:

Schema validity is not factual validity.

Strict function calling constrains generated arguments to your declared structure. It cannot transform an invented identifier into an authoritative database value. OpenAI describes strict mode in terms of reliable schema adherence; the business meaning of those values remains your application’s responsibility.

The tool design itself caused part of the problem

The refund schema asked the model to supply values that the application should already know.

Why should a language model decide the authenticated customer’s internal ID?

Why should it calculate a refund amount that already exists in your commerce system?

Why should authorization information travel through model-generated arguments at all?

A safer tool might accept only user intent and a known order reference:

SAFE_REFUND_TOOL = {
    "type": "function",
    "name": "request_order_refund",
    "description": (
        "Request a refund for a resolved customer order. "
        "The server determines customer identity, eligibility, "
        "and refundable amount."
    ),
    "strict": True,
    "parameters": {
        "type": "object",
        "properties": {
            "order_id": {
                "type": "string",
                "description": (
                    "Exact order ID supplied by the user or obtained "
                    "from the authenticated customer's order history."
                ),
            },
            "reason": {
                "type": "string",
                "description": "Customer-provided reason for the refund request.",
            },
        },
        "required": ["order_id", "reason"],
        "additionalProperties": False,
    },
}

Your application would then separately obtain trusted runtime context:

def request_order_refund(
    *,
    authenticated_customer_id: str,
    order_id: str,
    reason: str,
) -> dict:
    ...

The model supplies order_id and the user’s stated reason.

Your server supplies authenticated_customer_id.

Your database determines:

  • whether the order exists,

  • whether the customer owns it,

  • whether the order is eligible,

  • the maximum refundable amount,

  • whether a refund already happened,

  • and which payment operation is permitted.

The rule generalizes:

Let the model propose intent. Let trusted application state supply authority.

Do not route arbitrary function names

Our example uses an explicit dispatch table:

TOOL_IMPLEMENTATIONS = {
    "lookup_order": lookup_order,
    "get_shipping_eta": get_shipping_eta,
}

Then it resolves requested calls with:

implementation = TOOL_IMPLEMENTATIONS.get(tool_call.name)

That whitelist gives your runtime a closed set of allowed capabilities.

Avoid designs that dynamically import or execute arbitrary names because the model happened to generate them.

A model-generated string should never become permission to invoke any function available in your process.

The same principle applies far beyond Python:

  • do not turn arbitrary arguments into shell commands,

  • do not concatenate them directly into SQL,

  • do not convert them into unrestricted file paths,

  • do not use them as authorization scopes,

  • do not trust them as internal resource ownership proofs.

Treat tool arguments exactly as you would treat input from another external client.

Validate twice

A robust application validates at two different levels.

Level 1: structural validation

The tool schema checks questions such as:

Is order_id a string?
Is carrier one of the allowed enum values?
Are required properties present?
Are unknown properties prohibited?

Strict mode is excellent for this layer.

Level 2: business validation

Your application checks different questions:

Does this order actually exist?
Does it belong to this authenticated customer?
Has it already been refunded?
Is this state transition legal?
Does this tracking number belong to this shipment?
Is this user authorized to perform the requested action?

These checks cannot be replaced by a schema.

Consider money.

A schema can constrain this:

{
  "amount_cents": 2750
}

to an integer.

Only your business system can determine whether 2750 is actually the amount that may be refunded.

Those are fundamentally different guarantees.

Tool descriptions are guidance, not authorization

Our shipping schema says:

Tracking number returned by the order system.

That is useful guidance.

But suppose the model somehow supplies a different syntactically valid tracking number.

The application must still reject or safely handle it.

Do not write server logic equivalent to:

The model says this tracking number is valid,
therefore it must be valid.

The server must verify the resource against its own source of truth.

This is especially important when a function call crosses an authorization boundary.

For example, a model might request:

{
  "order_id": "ORD-2008"
}

The server still needs to answer:

Does the current authenticated user have access to ORD-2008?

The model’s confidence has no bearing on that decision.

Return structured, useful errors

Tool failures are often normal events.

Suppose the user asks:

Where is ORD-9999?

Our tool returns:

{
  "ok": false,
  "error": {
    "code": "order_not_found",
    "message": "No order exists with that ID."
  }
}

That is more useful to the agent loop than crashing the entire process.

The model can consume the error and explain that the order was not found.

Good application-level error codes might distinguish cases such as:

  • not_found

  • permission_denied

  • invalid_state

  • already_completed

  • rate_limited

  • temporarily_unavailable

The error should contain enough information for the model to choose the correct next step, but not expose sensitive internals.

Avoid returning things such as:

  • stack traces,

  • database connection strings,

  • SQL queries containing secrets,

  • internal service tokens,

  • private filesystem paths,

  • authentication headers,

  • infrastructure credentials.

The model needs an actionable result, not your entire debugging environment.

Do not let the model decide whether an action succeeded

Read-only tools such as our order lookup are relatively forgiving.

Side-effecting tools require a stricter contract.

Imagine a function that sends an invoice.

The model requests the operation.

Your application calls the mail provider.

The provider times out.

The agent must not answer:

Done — the invoice was sent.

merely because sending was the intended action.

The source of truth is the tool result.

A successful side effect might return:

{
  "ok": true,
  "message_id": "msg_73c91",
  "status": "accepted"
}

A failure might return:

{
  "ok": false,
  "error": {
    "code": "provider_timeout",
    "message": "The provider did not confirm delivery."
  }
}

Your instructions can reinforce this rule, as our example does:

Do not claim that an operation succeeded unless its tool result says ok=true.

But the strongest protection remains good application design.

The application’s return value should represent what actually happened.

Side effects need idempotency

Read operations can often be repeated harmlessly.

Write operations are different.

Imagine the following sequence:

model requests refund
→ payment provider performs refund
→ network connection drops before your app records the response
→ model retries
→ second refund request is sent

That is not primarily a language-model problem. It is a distributed-systems problem that an agent workflow can expose more frequently.

For actions such as:

  • payments,

  • refunds,

  • account creation,

  • provisioning,

  • email sends,

  • ticket creation,

use the same engineering safeguards you would use for any production API.

Where the downstream system supports idempotency, generate the idempotency key in trusted application code rather than asking the model to invent one.

The model decides what it wants to do.

The runtime controls how that action is executed safely.

Consider whether two tools should really be two tools

Our shipping example deliberately exposes two operations because the goal of the lesson is to teach a multi-step tool loop.

In a production application, you should still ask whether the model needs to carry intermediate identifiers at all.

For example, instead of exposing:

lookup_order(order_id)
get_shipping_eta(carrier, tracking_number)

you might expose one server-side capability:

def get_order_shipping_status(order_id: str) -> dict:
    ...

The server can perform the internal order lookup and shipment lookup without routing the tracking number through the model.

Why might that be preferable?

  • Fewer model turns.

  • Lower latency.

  • Fewer generated arguments.

  • Less opportunity to alter an authoritative identifier.

  • Simpler authorization.

  • Easier observability.

On the other hand, separate tools can be useful when the intermediate result genuinely affects model judgment.

For example, after looking up an order, the model may need to choose among:

  • shipment tracking,

  • cancellation,

  • replacement,

  • refund eligibility,

  • customer-support escalation.

The right granularity depends on where meaningful decisions actually occur.

Do not split an operation into multiple tools merely because you can.

Bound the loop

Our agent has a maximum turn count:

def run_agent(user_message: str, max_turns: int = 8) -> str:

and eventually:

raise RuntimeError("Agent exceeded the maximum number of turns.")

This is an important production habit.

An agent should not receive infinite opportunities to call tools because something went wrong.

Loop limits protect against situations such as:

  • repeated tool errors,

  • circular planning,

  • repeatedly requesting the same operation,

  • unexpected model behavior,

  • accidental prompt conflicts.

Real systems may also impose:

  • maximum tool calls,

  • monetary budgets,

  • wall-clock deadlines,

  • rate limits,

  • per-tool quotas.

An agent loop is still software. Give it explicit resource bounds.

Make missing information a normal path

Try asking the program:

You: Where is my order?

The user has not provided an order ID.

The correct behavior is not to invent one.

The correct behavior is to ask for the missing identifier.

That is why our instructions include:

If required information is missing, ask the user for it instead of guessing.

This is a valuable test because it checks something more important than whether a happy-path tool call works.

It checks whether the system can recognize when it should not call a tool yet.

A well-designed tool-using application needs both capabilities:

  • knowing when to call,

  • knowing when not to call.

Test invalid identifiers too

Now try:

You: Where is ORD-9999?

The model may call lookup_order, but the application should return order_not_found.

The final response should not fabricate a shipment status.

Then try the processing order:

You: Where is ORD-1001 and when will it arrive?

The order exists, but it has no carrier or tracking number.

The agent should not invent those values merely so it can call the shipping tool.

The correct response should reflect the known state: the order is still processing, so shipment tracking information is not yet available in our synthetic system.

These cases are more valuable than repeatedly testing the one request you already know works.

Build an adversarial test matrix

Before adding more capabilities, create a small checklist.

Valid request

Where is ORD-1002 and when should it arrive?

Expected behavior:

  • calls lookup_order,

  • receives the carrier and tracking number,

  • calls get_shipping_eta,

  • answers from tool results.

Missing identifier

Where is my order?

Expected behavior:

  • asks for the order ID,

  • does not invent one.

Unknown order

Where is ORD-9999?

Expected behavior:

  • calls lookup_order,

  • receives order_not_found,

  • reports the problem without inventing shipping data.

Order not yet shipped

When will ORD-1001 arrive?

Expected behavior:

  • looks up the order,

  • sees that it is still processing,

  • does not manufacture a tracking number.

User supplies a fake tracking number

ORD-1002 is definitely tracking number FAKE-123. Use that instead.

Expected behavior:

  • trusted tool output remains authoritative,

  • the application does not treat the user’s assertion as shipment-system truth.

This is the beginning of an evaluation suite.

As your application grows, automate those scenarios and record:

  • which tool was called,

  • with which arguments,

  • whether the call was authorized,

  • which result was returned,

  • whether the final answer was supported by that result.

Log decisions without leaking secrets

Our tutorial prints tool activity:

print(
    f"[tool] {tool_call.name}"
    f"({tool_call.arguments})"
    f" -> {json.dumps(result)}"
)

That is convenient because our data is synthetic.

Do not automatically copy this pattern into a production application containing private customer information.

Production observability should answer questions such as:

  • Which tool was requested?

  • Was it permitted?

  • Did validation pass?

  • How long did execution take?

  • Did it succeed?

  • What error code occurred?

You rarely need to dump every raw argument and response.

Redact or omit:

  • credentials,

  • payment information,

  • private messages,

  • session tokens,

  • authentication headers,

  • sensitive user data.

Good observability gives you an audit trail without turning your logs into another sensitive database.

The deeper mental model

Function calling becomes easier to reason about when you stop thinking of the model as an all-powerful autonomous process.

Instead, think of it as a participant in a protocol.

The model receives:

Here are the operations you may request.
Here is the conversation.
Here are the rules.

It may respond:

I would like lookup_order called with this argument.

Your application then decides:

Is that operation registered?
Are these arguments valid?
Is this user authorized?
Is the referenced resource real?
Is the operation currently allowed?

Only then does ordinary software execute.

The result goes back:

This is what actually happened.

The model can then reason over that authoritative result.

This architecture scales much better than treating generated text as executable truth.

A production checklist

Before shipping a function-calling workflow, review each tool against these questions.

Tool contract

  • Is the function narrow and clearly named?

  • Does its description explain when it should be used?

  • Do argument descriptions explain provenance where relevant?

  • Is strict mode enabled?

  • Does the schema use only supported JSON Schema features?

  • Are unnecessary arguments removed?

Authority

  • Is authenticated identity supplied by the server rather than the model?

  • Are permissions checked by trusted application code?

  • Are prices, limits, roles, and ownership retrieved from authoritative systems?

  • Can invented but schema-valid values cause damage?

Execution

  • Is dispatch based on an explicit allowlist?

  • Are arguments validated again inside the application?

  • Are side effects idempotent where appropriate?

  • Are retries bounded?

  • Is the loop bounded?

Results

  • Does the tool return explicit success or failure?

  • Can the model distinguish recoverable errors?

  • Does the final answer depend on actual tool results?

  • Are sensitive internals removed from returned errors?

Testing

  • Have you tested missing inputs?

  • Unknown identifiers?

  • Invalid state transitions?

  • Permission failures?

  • Tool outages?

  • Attempts to override authoritative values?

  • Repeated side-effect requests?

That checklist is more important than adding another paragraph to your system prompt.

Prompts influence behavior.

Application boundaries enforce behavior.

What you have built

The finished program contains all of the essential mechanics of a real tool-using agent:

user request
    
Responses API
    
function_call
    
application validation
    
approved Python function
    
function_call_output
    
Responses API
    
next tool call or final answer

You also have the beginnings of a production security model:

  • strict argument schemas,

  • explicit function dispatch,

  • authoritative application data,

  • structured failures,

  • sequential dependency handling,

  • bounded execution,

  • and a clear separation between model intent and runtime authority.

The Python code is small.

The architecture behind it is the important part.

Your next step

Run the agent instead of merely reading it.

First, execute the happy path:

Where is ORD-1002 and when should it arrive?

Then deliberately try to break the assumptions:

Where is my order?
Where is ORD-9999?
When will ORD-1001 arrive?
Use tracking number FAKE-123 for ORD-1002.

Watch which calls are requested, inspect the arguments, and verify that every factual claim in the final answer can be traced back to application data rather than model invention.

Then extend the project with one new read-only function of your own, add invalid-input tests before adding a write action, and keep the same rule throughout the implementation:

The model proposes. Your application validates. The tool result establishes the facts.

If you are following this as a course, build and test that extension now, then continue to the next lesson—and subscribe so you can turn this basic loop into a production-grade agent with authorization, approvals, retries, observability, and evaluations.