Home » Hands-On with Agents SDK: Multi-Agent Collaboration

Hands-On with Agents SDK: Multi-Agent Collaboration

this second installment of the Hands-On with Agents SDK series, we’ll explore the basics of multi-agent systems and how agents can collaborate using the OpenAI Agents SDK framework.

If you haven’t read the first article, I highly recommend checking it out here: Your First API‑Calling Agent. In that post, we started by building a simple agent and enhanced it into a tool‑using agent capable of retrieving real‑time weather data. We also wrapped the agent in a minimal Streamlit interface for user interaction.

Now, we take the next step. Instead of relying on a single weather specialist agent, we’ll introduce another agent and learn how to make them work together to understand and fulfill user queries more effectively.

Intro to Multi-Agent Systems

Let’s start with a fundamental question: why do we need multiple agents when a single agent—like the one we built in the previous article—already seems powerful enough to handle the task?

There are several practical and theoretical reasons for adopting a multi-agent system [1]. Each agent can specialize in a specific domain and utilize its own tools, which can lead to better performance and higher-quality results. In real-world applications, business processes are often complex. A multi-agent setup is typically more modular, manageable, and maintainable. For example, if a specific function needs to be updated, we can modify just one agent instead of altering the entire system.

The OpenAI Agents SDK provides two core patterns for enabling agent collaboration: handoff and agent-as-tools. In this article, we’ll explore both of these patterns, discuss when to use them, and show how you can customize them to achieve better results.

Handoff

Handoff is one of the key features of the Agents SDK framework. With handoff, an agent can delegate a task to another agent [2]. To make this concept clearer, imagine a typical process in a hospital.

Let’s say you’re experiencing a health issue and go to the hospital for a check-up. The first person you usually meet is not the doctor, but a triage nurse. The triage nurse collects relevant information from you and then directs you to the appropriate department or specialist.

This analogy closely mirrors how handoff works in the Agents SDK. In our case, we’ll have a triage agent that “examines” the user’s query. After evaluating it, the triage agent routes the query to a more suitable specialist agent. Just like a triage nurse, it hands off the full context (like your collected health data) to that specialist agent.

This pattern is commonly used in real-world applications such as customer service workflows, where a general agent receives an initial request and then routes it to a domain-specific expert agent for resolution. In our weather assistant example, we’ll implement a similar setup.

Instead of having only one weather specialist agent, we will introduce another agent: an air quality specialist. As the name suggests, this agent will focus on answering air quality-related queries for a given location and will be equipped with a tool to fetch real-time air quality data.

Visualization of Handoff pattern using GraphViz.

Basic Handoffs

Let’s dive into the code by creating a new file named 04-basic-handoff-app.py. First, we’ll import the required packages, just like we did in the previous article.

from agents import Agent, Runner, function_tool
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

Next, we define the function tool and the weather specialist agent using the same structure as in the previous article:

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    ...

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    ...
)

In this script, as discussed above, we will define a new Air Quality Specialist agent with a tool named get_current_air_quality. This tool uses a different API endpoint and metrics from Open-Meteo, but it takes the same parameters—latitude and longitude.

The agent itself is defined similarly to the previous Weather Specialist agent, with the key difference being in the instructions—to ensure relevance to air quality queries. Below is the code snippet for the Air Quality Specialist agent.

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    """Fetch current air quality data for the given latitude and longitude."""

    url = "https://air-quality-api.open-meteo.com/v1/air-quality"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "european_aqi,us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

air_quality_specialist_agent = Agent(
    name="Air Quality Specialist Agent",
    instructions="""
    You are an air quality specialist agent.
    Your role is to interpret current air quality data and communicate it clearly to users.

    For each query, provide:
    1. A concise summary of the air quality conditions in plain language, including key pollutants and their levels.
    2. Practical, actionable advice or precautions for outdoor activities, travel, and health, tailored to the air quality data.
    3. If poor or hazardous air quality is detected (e.g., high pollution, allergens), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Air Quality Summary:
    - Summarize the air quality conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the air quality.
    """,
    tools=[get_current_air_quality],
    tool_use_behavior="run_llm_again"
)

Now that we have two agents—the Weather Specialist and the Air Quality Specialist—the next step is to define the Triage Agent, which will evaluate the user’s query and decide which agent to hand off the task to.

The Triage Agent can be defined simply as follows:

triage_agent = Agent(
    name="Triage Agent",
    instructions="""
    You are a triage agent.
    Your task is to determine which specialist agent (Weather Specialist or Air Quality Specialist) is best suited to handle the user's query based on the content of the question.

    For each query, analyze the input and decide:
    - If the query is about weather conditions, route it to the Weather Specialist Agent.
    - If the query is about air quality, route it to the Air Quality Specialist Agent.
    - If the query is ambiguous or does not fit either category, provide a clarification request.
    """,
    handoffs=[weather_specialist_agent, air_quality_specialist_agent]
)

In the instructions argument, we provide a clear directive for this agent to determine which specialist agent is best suited to handle the user’s query.

The most important parameter here is handoffs, where we pass a list of agents that the task may be delegated to. Since we currently have only two agents, we include both in the list.

Finally, we define our run_agent and main functions to integrate with the Streamlit components. (Note: For a detailed explanation of these functions, please refer to the first article.)

async def run_agent(user_input: str):
    result = await Runner.run(triage_agent, user_input)
    return result.final_output

def main():
    st.title("Weather and Air Quality Assistant")
    user_input = st.text_input("Enter your query about weather or air quality:")

    if st.button("Get Update"):
        with st.spinner("Thinking..."):
            if user_input:
                agent_response = asyncio.run(run_agent(user_input))
                st.write(agent_response)
            else:
                st.write("Please enter a question about the weather or air quality.")

if __name__ == "__main__":
    main()
Full script for our handoff script can be seen here.
from agents import Agent, Runner, function_tool
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    """Fetch current weather data for the given latitude and longitude."""
    
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation,weathercode,windspeed_10m,winddirection_10m",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    instructions="""
    You are a weather specialist agent.
    Your task is to analyze current weather data, including temperature, humidity, wind speed and direction, precipitation, and weather codes.

    For each query, provide:
    1. A clear, concise summary of the current weather conditions in plain language.
    2. Practical, actionable suggestions or precautions for outdoor activities, travel, health, or clothing, tailored to the weather data.
    3. If severe weather is detected (e.g., heavy rain, thunderstorms, extreme heat), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Weather Summary:
    - Summarize the weather conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the weather.
    """,
    tools=[get_current_weather],
    tool_use_behavior="run_llm_again"
)

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    """Fetch current air quality data for the given latitude and longitude."""

    url = "https://air-quality-api.open-meteo.com/v1/air-quality"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "european_aqi,us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

air_quality_specialist_agent = Agent(
    name="Air Quality Specialist Agent",
    instructions="""
    You are an air quality specialist agent.
    Your role is to interpret current air quality data and communicate it clearly to users.

    For each query, provide:
    1. A concise summary of the air quality conditions in plain language, including key pollutants and their levels.
    2. Practical, actionable advice or precautions for outdoor activities, travel, and health, tailored to the air quality data.
    3. If poor or hazardous air quality is detected (e.g., high pollution, allergens), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Air Quality Summary:
    - Summarize the air quality conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the air quality.
    """,
    tools=[get_current_air_quality],
    tool_use_behavior="run_llm_again"
)

triage_agent = Agent(
    name="Triage Agent",
    instructions="""
    You are a triage agent.
    Your task is to determine which specialist agent (Weather Specialist or Air Quality Specialist) is best suited to handle the user's query based on the content of the question.

    For each query, analyze the input and decide:
    - If the query is about weather conditions, route it to the Weather Specialist Agent.
    - If the query is about air quality, route it to the Air Quality Specialist Agent.
    - If the query is ambiguous or does not fit either category, provide a clarification request.
    """,
    handoffs=[weather_specialist_agent, air_quality_specialist_agent]
)

async def run_agent(user_input: str):
    result = await Runner.run(triage_agent, user_input)
    return result.final_output

def main():
    st.title("Weather and Air Quality Assistant")
    user_input = st.text_input("Enter your query about weather or air quality:")

    if st.button("Get Update"):
        with st.spinner("Thinking..."):
            if user_input:
                agent_response = asyncio.run(run_agent(user_input))
                st.write(agent_response)
            else:
                st.write("Please enter a question about the weather or air quality.")

if __name__ == "__main__":
    main()

Run the script in your terminal using the following command:

streamlit run 04-basic-handoff-app.py

Now that we have a brand new air quality specialist agent, let’s ask about the air quality in Jakarta.

Screenshot of assistant response about air quality in Jakarta.

Unfortunately, at the time of writing this article (and quite often, actually), the report shows an unhealthy air quality level, along with some suggestions for dealing with the condition.

Checking Trace Dashboard

Recall that in the first article, I briefly shared about the built-in tracing feature in the Agents SDK. In multi-agent collaboration, this feature becomes even more useful compared to when we were still working with a simple, single agent.

Let’s take a look at the trace dashboard for the query we just ran.

Screenshot of trace dashboard for handoff pattern implementation.

We can see that the process involved two agents: the triage agent and the air quality specialist agent. The triage agent took a total of 1,690 ms, while the air quality agent took 7,182 ms to process and return the result.

If we click on the triage agent’s response section, we can view detailed LLM properties, as shown below. Notice that for the triage agent, the LLM views the handoff options as functions: transfer_to_weather_specialist_agent() and transfer_to_air_quality_specialist_agent(). This is how the handoff works under the hood—the LLM decides which function best suits the user’s query.

Screenshot of the detail of triage agent’s response in trace dashboard.

Since the example asked about air quality, the function triggered by the triage agent was transfer_to_air_quality_specialist_agent(), which seamlessly transferred control to the Air Quality Specialist agent.

Screenshot of trace dashboard when triage agent decided to handoff the task to air quality specialist agent.

You can try asking about the weather instead of air quality and inspect the trace dashboard to see the difference.

Customized Handoffs

We understand already that under the hood handoffs are visible by LLM as function, this means also that we can customize handoffs for some aspects.

To customize a handoff, we can create a handoff object using handoff() function and specify part where we want to customize in the arguments, including; customizing tool name and description, running extra logic immediately on handoff, and passing a structured input to the specialist agent.

Let’s see how it works on our use-case below. For a cleaner reference, let’s duplicate previous script of handoff and name the new file as 05-customized-handoff-app.py.

Since we will create a handoff object using handoff() function, we need to add handoff and RunContextWrapper function from agents package as below:

from agents import Agent, Runner, function_tool, handoff, RunContextWrapper
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

After we imported the required package, next is to define function tools and agents as latest script:

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    ...

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    ...
)

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    ...

air_quality_specialist_agent = Agent(
    ...
)

Now let’s add two handoff objects. We will add customization step-by-step from the simplest one.

Tool Name and Description Override

weather_handoff = handoff(
    agent=weather_specialist_agent,
    tool_name_override="handoff_to_weather_specialist",
    tool_description_override="Handle queries related to weather conditions"
)

air_quality_handoff = handoff(
    agent=air_quality_specialist_agent,
    tool_name_override="handoff_to_air_quality_specialist",
    tool_description_override="Handle queries related to air quality conditions"
)

Above code shows the first customization that we apply for both of agents where we change the tool name and description for LLM visibility. Generally this change not affecting how the LLM response the query but only provide us a way to have a clear and more specific tool name and description rather then the default transfer_to_.

Add Callback Function

One of the most use-case for callback function with handoff is to log the handoff event or showing it in the UI. Let’s say on the app you want to let the user know whenever the triage agent handoffs the query to one of the specialist agent.

First let’s define the callback function in order to call Streamlit’s info component that tell handoff event then add this function in on_handoff properties on both of the handoff objects.

def on_handoff_callback(ctx):
    st.info(f"Handing off to specialist agent for further processing...")

weather_handoff = handoff(
    agent=weather_specialist_agent,
    tool_name_override="get_current_weather",
    tool_description_override="Handle queries related to weather conditions",
    on_handoff=on_handoff_callback
)

air_quality_handoff = handoff(
    agent=air_quality_specialist_agent,
    tool_name_override="get_current_air_quality",
    tool_description_override="Handle queries related to air quality conditions",
    on_handoff=on_handoff_callback
)

Let’s test out this, but before running the script, we need to change the handoffs list in triage agent using handoff objects that we just defined.

triage_agent = Agent(
    name="Triage Agent",
    instructions="""
    ...
    """,
    handoffs=[weather_handoff, air_quality_handoff]
)
Full script including Streamlit main function can be seen here.
from agents import Agent, Runner, function_tool, handoff, RunContextWrapper
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    """Fetch current weather data for the given latitude and longitude."""
    
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation,weathercode,windspeed_10m,winddirection_10m",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    instructions="""
    You are a weather specialist agent.
    Your task is to analyze current weather data, including temperature, humidity, wind speed and direction, precipitation, and weather codes.

    For each query, provide:
    1. A clear, concise summary of the current weather conditions in plain language.
    2. Practical, actionable suggestions or precautions for outdoor activities, travel, health, or clothing, tailored to the weather data.
    3. If severe weather is detected (e.g., heavy rain, thunderstorms, extreme heat), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Weather Summary:
    - Summarize the weather conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the weather.
    """,
    tools=[get_current_weather],
    tool_use_behavior="run_llm_again"
)

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    """Fetch current air quality data for the given latitude and longitude."""

    url = "https://air-quality-api.open-meteo.com/v1/air-quality"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "european_aqi,us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

air_quality_specialist_agent = Agent(
    name="Air Quality Specialist Agent",
    instructions="""
    You are an air quality specialist agent.
    Your role is to interpret current air quality data and communicate it clearly to users.

    For each query, provide:
    1. A concise summary of the air quality conditions in plain language, including key pollutants and their levels.
    2. Practical, actionable advice or precautions for outdoor activities, travel, and health, tailored to the air quality data.
    3. If poor or hazardous air quality is detected (e.g., high pollution, allergens), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Air Quality Summary:
    - Summarize the air quality conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the air quality.
    """,
    tools=[get_current_air_quality],
    tool_use_behavior="run_llm_again"
)

def on_handoff_callback(ctx):
    st.info(f"Handing off to specialist agent for further processing...")

weather_handoff = handoff(
    agent=weather_specialist_agent,
    tool_name_override="handoff_to_weather_specialist",
    tool_description_override="Handle queries related to weather conditions",
    on_handoff=on_handoff_callback
)

air_quality_handoff = handoff(
    agent=air_quality_specialist_agent,
    tool_name_override="handoff_to_air_quality_specialist",
    tool_description_override="Handle queries related to air quality conditions",
    on_handoff=on_handoff_callback
)

triage_agent = Agent(
    name="Triage Agent",
    instructions="""
    You are a triage agent.
    Your task is to determine which specialist agent (Weather Specialist or Air Quality Specialist) is best suited to handle the user's query based on the content of the question.

    For each query, analyze the input and decide:
    - If the query is about weather conditions, route it to the Weather Specialist Agent.
    - If the query is about air quality, route it to the Air Quality Specialist Agent.
    - If the query is ambiguous or does not fit either category, provide a clarification request.
    """,
    handoffs=[weather_handoff, air_quality_handoff]
)

async def run_agent(user_input: str):
    result = await Runner.run(triage_agent, user_input)
    return result.final_output

def main():
    st.title("Weather and Air Quality Assistant")
    user_input = st.text_input("Enter your query about weather or air quality:")

    if st.button("Get Update"):
        with st.spinner("Thinking..."):
            if user_input:
                agent_response = asyncio.run(run_agent(user_input))
                st.write(agent_response)
            else:
                st.write("Please enter a question about the weather or air quality.")

if __name__ == "__main__":
    main()

Run the app from the terminal and ask a question about the weather or air quality. (In the example below, I intentionally asked about the air quality in Melbourne to provide a contrast with the air quality in Jakarta.)

streamlit run 05-customized-handoff-app.py
Screen recording to show how on-handoff works.

I’ve included a screen recording here to demonstrate the purpose of the on_handoff property we defined. As shown above, right after the triage agent initiates a handoff to a specialist agent, an info section appears before the final response is returned in the app. This behavior can be further customized — for example, by enriching the information displayed or adding additional logic to execute during the handoff.

Specify Input Type

The previous example of a callback function didn’t provide much meaningful information—it only indicated that a handoff had occurred.

To pass more useful data to the callback function, we can use the input_type parameter in the handoff object to describe the expected structure of the input.

The first step is to define the input type. Typically, we use a Pydantic model class[3] to specify the structure of the data we want to pass.

Suppose we want the triage agent to provide the following information: the reason for the handoff (to understand the logic behind the decision) and the latitude and longitude of the location mentioned in the user query. To define this input type, we can use the following code:

from pydantic import BaseModel, Field

class HandoffRequest(BaseModel):
    specialist_agent: str = Field(..., description="Name of the specialist agent to hand off to")
    handoff_reason: str = Field(..., description="Reason for the handoff")
    latitude: float = Field(..., description="Latitude of the location")
    longitude: float = Field(..., description="Longitude of the location")

First, we import the necessary classes from the Pydantic library. BaseModel is the base class that provides data validation capabilities, while Field allows us to add metadata to each model field.

Next, we define a class called HandoffRequest, which includes the structure and validation rules for the data we want. The specialist_agent field stores the name of the receiving agent in this handoff. The handoff_reason field is a string explaining why the handoff is happening. The latitude and longitude fields are defined as floats, representing the geographic coordinates.

Once the structured input is defined, the next step is to modify the callback function to accommodate this information.

async def on_handoff_callback(ctx: RunContextWrapper, user_input: HandoffRequest):
    st.info(f"""
            Handing off to {user_input.specialist_agent} for further processing...n
            Handoff reason: {user_input.handoff_reason} n
            Location : {user_input.latitude}, {user_input.longitude} n
            """)

Lastly, let’s add this parameter to both of our handoff objects

weather_handoff = handoff(
    agent=weather_specialist_agent,
    tool_name_override="handoff_to_weather_specialist",
    tool_description_override="Handle queries related to weather conditions",
    on_handoff=on_handoff_callback,
    input_type=HandoffRequest
)

air_quality_handoff = handoff(
    agent=air_quality_specialist_agent,
    tool_name_override="handoff_to_air_quality_specialist",
    tool_description_override="Handle queries related to air quality conditions",
    on_handoff=on_handoff_callback,
    input_type=HandoffRequest
)

The rest of the script remains unchanged. Now, let’s try running it using the following command:

streamlit run 05-customized-handoff-app.py
Screenshot of the input_type implementation on handoff object.

In this example, I didn’t use the word “weather” directly—instead, I asked about “temperature.” From the blue info section, we can see that a handoff occurred to the Weather Specialist agent. We also get the reason why the triage agent made this decision: the query was about the current temperature, which is considered a weather-related topic. Additionally, the geographical location (Tokyo) is provided for further reference.

The full script of customized handoffs can be found here:
from agents import Agent, Runner, function_tool, handoff, RunContextWrapper
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    """Fetch current weather data for the given latitude and longitude."""
    
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation,weathercode,windspeed_10m,winddirection_10m",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    instructions="""
    You are a weather specialist agent.
    Your task is to analyze current weather data, including temperature, humidity, wind speed and direction, precipitation, and weather codes.

    For each query, provide:
    1. A clear, concise summary of the current weather conditions in plain language.
    2. Practical, actionable suggestions or precautions for outdoor activities, travel, health, or clothing, tailored to the weather data.
    3. If severe weather is detected (e.g., heavy rain, thunderstorms, extreme heat), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Weather Summary:
    - Summarize the weather conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the weather.
    """,
    tools=[get_current_weather],
    tool_use_behavior="run_llm_again"
)

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    """Fetch current air quality data for the given latitude and longitude."""

    url = "https://air-quality-api.open-meteo.com/v1/air-quality"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "european_aqi,us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

air_quality_specialist_agent = Agent(
    name="Air Quality Specialist Agent",
    instructions="""
    You are an air quality specialist agent.
    Your role is to interpret current air quality data and communicate it clearly to users.

    For each query, provide:
    1. A concise summary of the air quality conditions in plain language, including key pollutants and their levels.
    2. Practical, actionable advice or precautions for outdoor activities, travel, and health, tailored to the air quality data.
    3. If poor or hazardous air quality is detected (e.g., high pollution, allergens), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Air Quality Summary:
    - Summarize the air quality conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the air quality.
    """,
    tools=[get_current_air_quality],
    tool_use_behavior="run_llm_again"
)

from pydantic import BaseModel, Field

class HandoffRequest(BaseModel):
    specialist_agent: str = Field(..., description="Name of the specialist agent to hand off to")
    handoff_reason: str = Field(..., description="Reason for the handoff")
    latitude: float = Field(..., description="Latitude of the location")
    longitude: float = Field(..., description="Longitude of the location")

async def on_handoff_callback(ctx: RunContextWrapper, user_input: HandoffRequest):
    st.info(f"""
            Handing off to {user_input.specialist_agent} for further processing...n
            Handoff reason: {user_input.handoff_reason} n
            Location : {user_input.latitude}, {user_input.longitude} n
            """)

weather_handoff = handoff(
    agent=weather_specialist_agent,
    tool_name_override="handoff_to_weather_specialist",
    tool_description_override="Handle queries related to weather conditions",
    on_handoff=on_handoff_callback,
    input_type=HandoffRequest
)

air_quality_handoff = handoff(
    agent=air_quality_specialist_agent,
    tool_name_override="handoff_to_air_quality_specialist",
    tool_description_override="Handle queries related to air quality conditions",
    on_handoff=on_handoff_callback,
    input_type=HandoffRequest
)

triage_agent = Agent(
    name="Triage Agent",
    instructions="""
    You are a triage agent.
    Your task is to determine which specialist agent (Weather Specialist or Air Quality Specialist) is best suited to handle the user's query based on the content of the question.

    For each query, analyze the input and decide:
    - If the query is about weather conditions, route it to the Weather Specialist Agent.
    - If the query is about air quality, route it to the Air Quality Specialist Agent.
    - If the query is ambiguous or does not fit either category, provide a clarification request.
    """,
    handoffs=[weather_handoff, air_quality_handoff]
)

async def run_agent(user_input: str):
    result = await Runner.run(triage_agent, user_input)
    return result.final_output

def main():
    st.title("Weather and Air Quality Assistant")
    user_input = st.text_input("Enter your query about weather or air quality:")

    if st.button("Get Update"):
        with st.spinner("Thinking..."):
            if user_input:
                agent_response = asyncio.run(run_agent(user_input))
                st.write(agent_response)
            else:
                st.write("Please enter a question about the weather or air quality.")

if __name__ == "__main__":
    main()

Agents-as-Tools

We’ve already explored the handoff method and how to customize it. When a handoff is invoked, the task is fully transferred to the next agent.

With the Agents-as-Tools pattern, instead of transferring full conversational control to another agent, the main agent—called the orchestrator agent—retains control of the conversation. It can consult other specialist agents as callable tools. In this scenario, the orchestrator agent can combine responses from different agents to construct a complete answer.

Visualization of the Agents-as-Tools pattern using GraphViz.

Turn an Agent into a Tool with a Simple Method

Now, let’s get into the code. We can turn an agent into a callable tool simply by using the agent.as_tool() function. This function requires two parameters: tool_name and tool_description.

We can apply this method directly within the tools list of our new orchestrator_agent, as shown below:

orchestrator_agent = Agent(
    name="Orchestrator Agent",
    instructions="""
    You are an orchestrator agent.
    Your task is to manage the interaction between the Weather Specialist Agent and the Air Quality Specialist Agent.
    You will receive a query from the user and will decide which agent to invoke based on the content of the query.
    If both weather and air quality information is requested, you will invoke both agents and combine their responses into one clear answer.
    """,
    tools=[
        weather_specialist_agent.as_tool(
            tool_name="get_weather_update",
            tool_description="Get current weather information and suggestion including temperature, humidity, wind speed and direction, precipitation, and weather codes."
        ),
        air_quality_specialist_agent.as_tool(
            tool_name="get_air_quality_update",
            tool_description="Get current air quality information and suggestion including pollutants and their levels."
        )
    ]
)

In the instruction, we guide the agent to manage interactions between two specialist agents. It can invoke either one or both agents depending on the query. If both specialist agents are invoked, the orchestrator agent must combine their responses.

Here is the full script to demonstrate the agents-as-tools pattern.
from agents import Agent, Runner, function_tool
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    """Fetch current weather data for the given latitude and longitude."""
    
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation,weathercode,windspeed_10m,winddirection_10m",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    instructions="""
    You are a weather specialist agent.
    Your task is to analyze current weather data, including temperature, humidity, wind speed and direction, precipitation, and weather codes.

    For each query, provide:
    1. A clear, concise summary of the current weather conditions in plain language.
    2. Practical, actionable suggestions or precautions for outdoor activities, travel, health, or clothing, tailored to the weather data.
    3. If severe weather is detected (e.g., heavy rain, thunderstorms, extreme heat), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Weather Summary:
    - Summarize the weather conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the weather.
    """,
    tools=[get_current_weather],
    tool_use_behavior="run_llm_again"
)

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    """Fetch current air quality data for the given latitude and longitude."""

    url = "https://air-quality-api.open-meteo.com/v1/air-quality"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "european_aqi,us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

air_quality_specialist_agent = Agent(
    name="Air Quality Specialist Agent",
    instructions="""
    You are an air quality specialist agent.
    Your role is to interpret current air quality data and communicate it clearly to users.

    For each query, provide:
    1. A concise summary of the air quality conditions in plain language, including key pollutants and their levels.
    2. Practical, actionable advice or precautions for outdoor activities, travel, and health, tailored to the air quality data.
    3. If poor or hazardous air quality is detected (e.g., high pollution, allergens), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Air Quality Summary:
    - Summarize the air quality conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the air quality.
    """,
    tools=[get_current_air_quality],
    tool_use_behavior="run_llm_again"
)

orchestrator_agent = Agent(
    name="Orchestrator Agent",
    instructions="""
    You are an orchestrator agent.
    Your task is to manage the interaction between the Weather Specialist Agent and the Air Quality Specialist Agent.
    You will receive a query from the user and will decide which agent to invoke based on the content of the query.
    If both weather and air quality information is requested, you will invoke both agents and combine their responses into one clear answer.
    """,
    tools=[
        weather_specialist_agent.as_tool(
            tool_name="get_weather_update",
            tool_description="Get current weather information and suggestion including temperature, humidity, wind speed and direction, precipitation, and weather codes."
        ),
        air_quality_specialist_agent.as_tool(
            tool_name="get_air_quality_update",
            tool_description="Get current air quality information and suggestion including pollutants and their levels."
        )
    ],
    tool_use_behavior="run_llm_again"
)

async def run_agent(user_input: str):
    result = await Runner.run(orchestrator_agent, user_input)
    return result.final_output

def main():
    st.title("Weather and Air Quality Assistant")
    user_input = st.text_input("Enter your query about weather or air quality:")

    if st.button("Get Update"):
        with st.spinner("Thinking..."):
            if user_input:
                agent_response = asyncio.run(run_agent(user_input))
                st.write(agent_response)
            else:
                st.write("Please enter a question about the weather or air quality.")

if __name__ == "__main__":
    main()

Save this file as 06-agents-as-tools-app.py and run it in the terminal using the command below:

streamlit run 06-agents-as-tools-app.py

How the Orchestrator Uses Agents

Let’s begin with a query that triggers only one agent. In this example, I asked about the weather in Jakarta.

Screenshot of the agent’s response with agents-as-tools pattern for a specific task.

The result is similar to what we get using the handoff pattern. The weather specialist agent responds with the current temperature and provides suggestions based on the API data.

As mentioned earlier, one of the key advantages of using the agents-as-tools pattern is that it allows the orchestrator agent to consult more than one agent for a single query. The orchestrator intelligently plans based on the user’s intent.

For example, let’s ask about both the weather and air quality in Jakarta. The result looks like this:

Screenshot of the agent’s response with agents-as-tools pattern for multiple task.

The orchestrator agent first returns separate summaries from the weather and air quality specialist agents, along with their individual suggestions. Finally, it combines the insights and provides an overall conclusion—for example, recommending caution when spending time outdoors due to poor air quality.

Exploring the Trace Dashboard

Here’s an overview of the trace structure. We can see that the main branch only involves the Orchestrator Agent — unlike in the handoff pattern, where the Triage Agent transfers control to the next specialist agent.

In the first LLM response, the Orchestrator Agent calls two functions: get_weather_update() and get_air_quality_update(). These functions were originally agents that we transformed into tools. Both functions receive the same input: “Jakarta”.

After receiving the outputs from both tools, the Orchestrator Agent calls the LLM again to combine the responses and generate a final summary.

Customizing Agents-as-Tools

The method we used earlier is a quick way to turn agents into tools. However, as we saw in the trace dashboard, it doesn’t give us control over the input of each function.

In our example, the Orchestrator Agent called the functions with “Jakarta” as the input. This means the task of converting that into precise geographical coordinates is left to the LLMs of the specialist agents. This approach is not always reliable—I encountered cases where the specialist agents called the API using different latitude and longitude values.

This issue can be addressed by customizing agents-as-tools with structured inputs. As suggested in the documentation, we can use Runner.run() within the tool implementation.

@function_tool
async def get_weather_update(latitude: float, longitude: float) -> str:
    result = await Runner.run(
        weather_specialist_agent,
        input="Get the current weather condition and suggestion for this location (latitude: {}, longitude: {})".format(latitude, longitude)
    )
    return result.final_output

@function_tool
async def get_air_quality_update(latitude: float, longitude: float) -> str:
    result = await Runner.run(
        air_quality_specialist_agent,
        input="Get the current air quality condition and suggestion for this location (latitude: {}, longitude: {})".format(latitude, longitude)
    )
    return result.final_output

These two functions are decorated with @function_tool, similar to how we defined our API-call tools—turning both agents into tools that can be invoked by the Orchestrator Agent.

Each function now takes latitude and longitude as arguments, unlike the previous method where we couldn’t control the input values.

Since we’re running the agent inside these functions, we also need to define the input explicitly as a formatted string that includes latitude and longitude.

This method provides a more reliable approach: the Orchestrator Agent determines the exact coordinates and passes them to the tools, rather than relying on each specialist agent to resolve the location—eliminating inconsistencies in API calls.

A full script of this implementation can be found here.
from agents import Agent, Runner, function_tool
import asyncio
import streamlit as st
from dotenv import load_dotenv
import requests

load_dotenv()

@function_tool
def get_current_weather(latitude: float, longitude: float) -> dict:
    """Fetch current weather data for the given latitude and longitude."""
    
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation,weathercode,windspeed_10m,winddirection_10m",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

weather_specialist_agent = Agent(
    name="Weather Specialist Agent",
    instructions="""
    You are a weather specialist agent.
    Your task is to analyze current weather data, including temperature, humidity, wind speed and direction, precipitation, and weather codes.

    For each query, provide:
    1. A clear, concise summary of the current weather conditions in plain language.
    2. Practical, actionable suggestions or precautions for outdoor activities, travel, health, or clothing, tailored to the weather data.
    3. If severe weather is detected (e.g., heavy rain, thunderstorms, extreme heat), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Weather Summary:
    - Summarize the weather conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the weather.
    """,
    tools=[get_current_weather],
    tool_use_behavior="run_llm_again"
)

@function_tool
def get_current_air_quality(latitude: float, longitude: float) -> dict:
    """Fetch current air quality data for the given latitude and longitude."""

    url = "https://air-quality-api.open-meteo.com/v1/air-quality"
    params = {
        "latitude": latitude,
        "longitude": longitude,
        "current": "european_aqi,us_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

air_quality_specialist_agent = Agent(
    name="Air Quality Specialist Agent",
    instructions="""
    You are an air quality specialist agent.
    Your role is to interpret current air quality data and communicate it clearly to users.

    For each query, provide:
    1. A concise summary of the air quality conditions in plain language, including key pollutants and their levels.
    2. Practical, actionable advice or precautions for outdoor activities, travel, and health, tailored to the air quality data.
    3. If poor or hazardous air quality is detected (e.g., high pollution, allergens), clearly highlight recommended safety measures.

    Structure your response in two sections:
    Air Quality Summary:
    - Summarize the air quality conditions in simple terms.

    Suggestions:
    - List relevant advice or precautions based on the air quality.
    """,
    tools=[get_current_air_quality],
    tool_use_behavior="run_llm_again"
)

@function_tool
async def get_weather_update(latitude: float, longitude: float) -> str:
    result = await Runner.run(
        weather_specialist_agent,
        input="Get the current weather condition and suggestion for this location (latitude: {}, longitude: {})".format(latitude, longitude)
        )
    return result.final_output

@function_tool
async def get_air_quality_update(latitude: float, longitude: float) -> str:
    result = await Runner.run(
        air_quality_specialist_agent,
        input="Get the current air quality condition and suggestion for this location (latitude: {}, longitude: {})".format(latitude, longitude)
    )
    return result.final_output

orchestrator_agent = Agent(
    name="Orchestrator Agent",
    instructions="""
    You are an orchestrator agent with two tools: `get_weather_update` and `get_air_quality_update`.
    Analyze the user's query and invoke:
      - `get_weather_update` for weather-related requests (temperature, humidity, wind, precipitation).
      - `get_air_quality_update` for air quality-related requests (pollutants, AQI).
    If the query requires both, call both tools and merge their outputs.
    Return a single, clear response that addresses the user's question with concise summaries and actionable advice.
    """,
    tools=[get_weather_update, get_air_quality_update],
    tool_use_behavior="run_llm_again"
)

async def run_agent(user_input: str):
    result = await Runner.run(orchestrator_agent, user_input)
    return result.final_output

def main():
    st.title("Weather and Air Quality Assistant")
    user_input = st.text_input("Enter your query about weather or air quality:")

    if st.button("Get Update"):
        with st.spinner("Thinking..."):
            if user_input:
                agent_response = asyncio.run(run_agent(user_input))
                st.write(agent_response)
            else:
                st.write("Please enter a question about the weather or air quality.")

if __name__ == "__main__":
    main()

Conclusion

We have demonstrated two fundamental patterns of multi-agent collaboration: the handoff pattern and the agents-as-tools pattern.

A handoff is suitable for use cases where the main agent can delegate the entire conversation to a specialist agent. On the other hand, the agents-as-tools pattern is powerful when the main agent needs to maintain control over the task.

Although the use cases in this article are relatively simple, they illustrate how agents can work and collaborate effectively. From here after knowing when to use handoff or agents-as-tools, you can continue your exploration on building specialize agent with more challanging task and tools.

We will continue the journey on my next installment of Hands-On with Agents SDK soon.

Reference

[1] Bornet, P., Wirtz, J., Davenport, T. H., De Cremer, D., Evergreen, B., Fersht, P., Gohel, R., Khiyara, S., Sund, P., & Mullakara, N. (2025). Agentic Artificial Intelligence: Harnessing AI Agents to Reinvent Business, Work, and Life. World Scientific Publishing Co.

[2] OpenAI. (2025). OpenAI Agents SDK documentation. Retrieved August 1, 2025, from https://openai.github.io/openai-agents-python/handoffs/

[3] Pydantic. (n.d.). Models. Pydantic Documentation. Retrieved August 1, 2025, from https://docs.pydantic.dev/latest/concepts/models/


Read the first article here:

You can find the complete source code used in this article in the following repository: agentic-ai-weather | GitHub Repository. Feel free to explore, clone, or fork the project to follow along or build your own version.

If you’d like to see the app in action, I’ve also deployed it here: Weather Assistant Streamlit

Lastly, let’s connect on LinkedIn!

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *