Multiple Tools

Ma Mahalakshmi V Updated 16 Sep 2026
11 min read ·Lesson 34 of 224

Beyond a Single Function

Every example so far has offered the model exactly one function. Real applications typically register several — a customer support assistant might have tools for looking up an order, checking a return policy, and issuing a refund; a data analysis assistant might have tools for querying a database, running a calculation, and generating a chart. This lesson covers what changes, and what doesn't, once more than one tool is available: how the model chooses among them, how a single turn can request several calls at once, how to dispatch each call to the right implementation, and how tool design changes once multiple tools have to coexist without confusing each other.

Registering Several Tools

tools = [
    {
        "type": "function",
        "name": "get_order_status",
        "description": "Look up the current status of a customer's order by order ID.",
        "parameters": {
            "type": "object",
            "properties": {"order_id": {"type": "string"}},
            "required": ["order_id"],
            "additionalProperties": False,
        },
    },
    {
        "type": "function",
        "name": "get_return_policy",
        "description": "Look up the return policy for a given product category.",
        "parameters": {
            "type": "object",
            "properties": {"category": {"type": "string", "enum": ["electronics", "clothing", "furniture", "other"]}},
            "required": ["category"],
            "additionalProperties": False,
        },
    },
    {
        "type": "function",
        "name": "issue_refund",
        "description": "Issue a refund for a specific order. Only use this after confirming the order is eligible for a refund.",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string"},
                "amount": {"type": "number", "minimum": 0},
                "reason": {"type": "string"},
            },
            "required": ["order_id", "amount", "reason"],
            "additionalProperties": False,
        },
    },
]

Registering all three tools in a single tools list makes all of them simultaneously available to the model for a given request — the model is free to call none, one, two, or all three within a single turn, depending entirely on what the user's request actually calls for. Nothing in this list structure implies an order or a required sequence; the model decides which functions are relevant and in what order to call them (or whether one call's result should inform whether it calls another), based purely on the conversation and each tool's description.

How the Model Chooses Among Tools

The model's tool selection is driven entirely by matching the user's apparent intent against each available tool's description and its parameters' descriptions — there is no separate configuration for "priority" or "preference" among tools beyond how clearly and distinctly each one is described. This has a direct, practical consequence: two tools with vague or overlapping descriptions make correct selection harder, since the model has less to go on when deciding which one actually fits the request.

# Two poorly distinguished tools — the model may struggle to choose reliably
poorly_distinguished = [
    {"type": "function", "name": "lookup_info", "description": "Looks up information.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}},
    {"type": "function", "name": "get_data", "description": "Gets data.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}},
]

# The same two tools, clearly distinguished
well_distinguished = [
    {"type": "function", "name": "lookup_order_status", "description": "Look up the current shipping status of a specific order by its order ID.", "parameters": {"type": "object", "properties": {"order_id": {"type": "string"}}, "required": ["order_id"], "additionalProperties": False}},
    {"type": "function", "name": "lookup_product_specs", "description": "Look up technical specifications for a product by its product name or SKU.", "parameters": {"type": "object", "properties": {"product_name": {"type": "string"}}, "required": ["product_name"], "additionalProperties": False}},
]

The poorly_distinguished pair illustrates a genuine risk: lookup_info and get_data are different function names but nearly indistinguishable in what they claim to do, and a model given both, along with an ambiguous query, has little principled basis for choosing correctly and consistently between them. The well_distinguished pair fixes this not by adding more tools or more parameters, but simply by making each tool's actual purpose specific and non-overlapping — this is the single highest-leverage change available when tool selection seems unreliable, and it is worth checking before assuming the problem lies elsewhere (in the model, the prompt, or the conversation history).

Handling Several Function Calls in One Turn

A single response can contain more than one function_call item — the model asking for the order status of two different orders in one turn, for instance, or looking up both the order status and the applicable return policy before deciding what to tell the user.

def get_order_status(order_id: str) -> dict:
    fake_orders = {"ORD-1": "shipped", "ORD-2": "delivered"}
    return {"order_id": order_id, "status": fake_orders.get(order_id, "not_found")}

def get_return_policy(category: str) -> dict:
    fake_policies = {"electronics": "30 days, unopened", "clothing": "60 days, with tags"}
    return {"category": category, "policy": fake_policies.get(category, "Standard 14-day policy")}

available_functions = {
    "get_order_status": get_order_status,
    "get_return_policy": get_return_policy,
}

input_messages = [{"role": "user", "content": "What's the status of order ORD-1, and what's the return policy for electronics?"}]
response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)

function_calls = [item for item in response.output if item.type == "function_call"]
print(f"Number of function calls requested: {len(function_calls)}")

for call in function_calls:
    args = json.loads(call.arguments)
    function_to_run = available_functions[call.name]
    result = function_to_run(**args)
    input_messages.append(call)
    input_messages.append({
        "type": "function_call_output",
        "call_id": call.call_id,
        "output": json.dumps(result),
    })

final_response = client.responses.create(model="gpt-5.6-terra", input=input_messages, tools=tools)
print(final_response.output_text)

Given this combined question, function_calls will typically contain two items — one for get_order_status and one for get_return_policy — both of which need to be executed and both of whose results need to be appended to input_messages (each with its own matching call_id) before the follow-up request is made. This is precisely why Lesson 3's loop iterated over function_calls as a list rather than assuming exactly one: a single user message can reasonably require several distinct pieces of information gathered independently, and the API surfaces that as multiple function-call items in one response rather than forcing several separate round trips for what is conceptually one combined request.

The Dispatch Pattern: A Function Registry

The available_functions dictionary shown above — mapping each tool's name string to the actual Python callable that implements it — is the standard pattern for dispatching among multiple tools, and it scales cleanly as more tools are added.

def dispatch_function_call(call, available_functions: dict) -> dict:
    if call.name not in available_functions:
        return {"success": False, "error": f"Unknown function: {call.name}"}

    args = json.loads(call.arguments)
    function_to_run = available_functions[call.name]

    try:
        result = function_to_run(**args)
        return {"success": True, "result": result}
    except Exception as e:
        return {"success": False, "error": str(e)}

available_functions = {
    "get_order_status": get_order_status,
    "get_return_policy": get_return_policy,
    # issue_refund intentionally omitted here — see the note below
}

call_result = dispatch_function_call(function_calls[0], available_functions)

Checking call.name not in available_functions explicitly, and returning a structured error rather than raising a KeyError, matters more with multiple tools than with a single one: as the number of registered tools grows, so does the chance that a tool listed in tools (and therefore something the model might request) has no corresponding entry in available_functions — a mismatch that indicates a real configuration bug (a tool was added to one list but not the other), and one that should surface as a controlled error result rather than an unhandled crash. Note also that issue_refund is deliberately omitted from available_functions in this example even though it's still listed in tools — this is intentional, and Lesson 5 covers exactly why a tool the model can request shouldn't necessarily be a tool your code executes unconditionally, particularly one with real side effects like issuing a refund.

Grouping and Organizing Tools as They Grow

Once an application has more than a handful of tools, keeping their definitions and implementations organized becomes worth deliberate structure rather than one long flat list.

ORDER_TOOLS = [
    {"type": "function", "name": "get_order_status", "description": "...", "parameters": {}},
    {"type": "function", "name": "issue_refund", "description": "...", "parameters": {}},
]

POLICY_TOOLS = [
    {"type": "function", "name": "get_return_policy", "description": "...", "parameters": {}},
]

ORDER_FUNCTIONS = {"get_order_status": get_order_status}
POLICY_FUNCTIONS = {"get_return_policy": get_return_policy}

def build_toolset(*groups: list) -> list:
    combined = []
    for group in groups:
        combined.extend(group)
    return combined

def build_registry(*function_dicts: dict) -> dict:
    combined = {}
    for function_dict in function_dicts:
        combined.update(function_dict)
    return combined

active_tools = build_toolset(ORDER_TOOLS, POLICY_TOOLS)
active_functions = build_registry(ORDER_FUNCTIONS, POLICY_FUNCTIONS)

Organizing tools into named groups by area of responsibility (order-related tools, policy-related tools, and so on, mirroring how the underlying features of the application are likely already organized) rather than one undifferentiated list makes it straightforward to enable or disable whole groups of related tools for a given deployment — a customer-facing assistant might only need ORDER_TOOLS and POLICY_TOOLS active, while an internal admin tool might additionally enable a REFUND_TOOLS group that end users should never have access to. This grouping pattern becomes increasingly valuable as the number of tools grows, and it previews the kind of tool organization Unit 9 discusses at greater scale for built-in and remote tool sources.

Testing Multi-Tool Dispatch

def test_dispatch_routes_to_correct_function():
    class FakeCall:
        def __init__(self, name, arguments):
            self.name = name
            self.arguments = arguments

    def fake_get_order_status(order_id):
        return {"order_id": order_id, "status": "shipped"}

    registry = {"get_order_status": fake_get_order_status}

    call = FakeCall(name="get_order_status", arguments='{"order_id": "ORD-1"}')
    outcome = dispatch_function_call(call, registry)

    assert outcome["success"] is True
    assert outcome["result"]["status"] == "shipped"
    print("PASS: dispatch correctly routes a known function call to its implementation")

def test_dispatch_handles_unknown_function():
    class FakeCall:
        def __init__(self, name, arguments):
            self.name = name
            self.arguments = arguments

    call = FakeCall(name="delete_everything", arguments="{}")
    outcome = dispatch_function_call(call, {})

    assert outcome["success"] is False
    assert "Unknown function" in outcome["error"]
    print("PASS: dispatch correctly reports an unregistered function name as an error rather than crashing")

test_dispatch_routes_to_correct_function()
test_dispatch_handles_unknown_function()

These two tests check the dispatch logic's two most important behaviors in isolation — correctly routing a known call to its implementation, and correctly reporting (rather than crashing on) an unrecognized function name — without needing any real model call, following the same fake-object testing pattern this course has applied consistently since Unit 5. As the number of registered tools grows, tests like these are what catch a mismatch between tools and available_functions during development, rather than that mismatch surfacing for the first time against a real user request in production.

Designing Functions to Be Safely Callable in Any Order

Because multiple function calls in one turn are executed by your own loop (typically in whatever order they appear in function_calls, though nothing guarantees the model always lists them in a meaningful sequence), it's worth designing individual tool functions so that their correctness doesn't depend on being called in a particular order relative to other tools in the same turn. get_order_status and get_return_policy from earlier in this lesson are safe in this sense — each is a read-only lookup with no dependency on the other having run first or on any shared mutable state. A function that writes to shared state, on the other hand, needs more careful thought.

# Risky: this function's correctness depends on being called after a specific
# other function, but nothing in the tool-calling loop enforces that ordering.
account_balance = {"ACC-1": 100.0}

def apply_discount(account_id: str, percentage: float) -> dict:
    # Assumes some other step already validated eligibility — but if the model
    # calls this without first calling the validation tool, or calls it twice
    # in the same turn, there's nothing here to catch that.
    account_balance[account_id] *= (1 - percentage / 100)
    return {"account_id": account_id, "new_balance": account_balance[account_id]}

# Safer: the function validates its own precondition rather than assuming
# some other call already ran first.
def apply_discount_safely(account_id: str, percentage: float, eligibility_verified: bool) -> dict:
    if not eligibility_verified:
        return {"success": False, "error": "Discount eligibility must be verified before applying a discount."}
    if account_id not in account_balance:
        return {"success": False, "error": f"Unknown account: {account_id}"}
    account_balance[account_id] *= (1 - percentage / 100)
    return {"success": True, "account_id": account_id, "new_balance": account_balance[account_id]}

apply_discount_safely() takes its precondition (eligibility_verified) as an explicit argument rather than silently assuming some other function already ran and left the system in the right state — this makes the function's actual requirement visible in its schema (the model has to explicitly indicate it has verified eligibility, typically because it called a separate verification tool first and passed that result along) rather than being an invisible assumption baked into the implementation that breaks silently the first time a model calls the functions in an unexpected order, or calls one of them without the other. This is a small example of a broader principle worth carrying into any multi-tool design with functions that mutate state: treat each function's real preconditions as something to check explicitly and fail clearly on, not something to assume is already true because of an ordering that isn't actually enforced anywhere.

Common Mistakes

Registering overlapping or vaguely distinguished tools, giving the model too little basis to reliably choose the correct one for a given request.

Assuming a response contains at most one function call once multiple tools are registered, rather than iterating over every function_call item present, which breaks the moment the model reasonably requests two or more calls in a single turn.

Letting tools and available_functions drift out of sync as tools are added or removed, creating a class of bug where the model can request a call your code has no way to actually execute.

Building one large, undifferentiated tool list rather than grouping related tools, making it harder to enable or restrict specific sets of tools for different deployments or user roles.

Best Practices

Give every tool a specific, non-overlapping description, checking this first whenever tool selection seems unreliable, since it is usually the highest-leverage fix available.

Always iterate over all function_call items in a response, never assuming a fixed count, since a single turn can reasonably require several independent calls.

Maintain tools and available_functions (or an equivalent registry) together, and test that every tool name in one has a corresponding, correctly named entry in the other.

Group related tools by area of responsibility as the number of registered tools grows, making it straightforward to compose different subsets of tools for different deployments or user roles.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Multiple Tools and get answers drawn from it.

Signed-in readers only.