Lesson 07 OpenAI Agents SDK Intro
Theme:

Lab 07 — First agent with the OpenAI Agents SDK

Lesson 07 · Building Your Own Agents · ~30 min hands-on

Goal: at the end of this lab you will have (a) verified that openai-agents is importable from the agents namespace, (b) written a 10-line hello-world agent that runs successfully, (c) added a single handoff and observed result.last_agent change, and (d) added a one-line input guardrail and triggered the tripwire deliberately. This lab keeps the surface area tiny on purpose — Lesson 08 scales the same primitives up into a realistic triage-and-handoff agent.

Prereqs

Step 1 — Confirm the SDK is importable

Before writing any code, sanity-check the install. The OpenAI Agents SDK ships as the openai-agents PyPI package but imports from the top-level agents namespace — a common stumble:

python -c "from agents import Agent, Runner, function_tool; print('ok')"

Expected: ok. If you get ModuleNotFoundError: No module named 'agents', you haven’t installed openai-agents yet — re-run pip install -r agents/requirements.txt and confirm the installed version matches the row in agents/SDK-VERSIONS.md.

✓ Self-check: the import line prints ok with no traceback.

Step 2 — The 10-line hello world

Create a file called hello_openai_agent.py anywhere convenient (your scratch dir is fine):

from agents import Agent, Runner, function_tool

@function_tool
def get_weather(city: str) -> str:
    """Get current weather for a city as 'temp°C, conditions'."""
    return f"{city}: 4°C, light rain"

agent = Agent(
    name="WeatherBot",
    instructions="You answer weather questions using the get_weather tool.",
    model="gpt-4o-mini",
    tools=[get_weather],
)
result = Runner.run_sync(agent, "What's the weather in Kyiv?")
print(result.final_output)
print(f"[last agent: {result.last_agent.name}]")

Run it:

python hello_openai_agent.py

Expected: a one-sentence weather answer (something like “It’s currently 4°C with light rain in Kyiv.”) followed by [last agent: WeatherBot].

✓ Self-check: you saw a natural-language weather answer and the last_agent line. No traceback.

Why run_sync?

The SDK’s native entry point is Runner.run(agent, input) — an async coroutine. Runner.run_sync(...) is a thin wrapper that spins up an event loop for non-async callers; we use it in this lab to keep the snippets short. The moment you need streaming (Step 4) we switch back to the async form.

Step 3 — Add a second agent and a handoff

Now feel the SDK’s headline primitive. Define a JokeBot agent and let WeatherBot hand off to it when the user asks for a joke. Edit hello_openai_agent.py:

joke = Agent(
    name="JokeBot",
    instructions="You tell exactly one short, clean joke and stop.",
    model="gpt-4o-mini",
)

agent = Agent(
    name="WeatherBot",
    instructions=(
        "You answer weather questions using the get_weather tool. "
        "If the user asks for a joke, hand off to JokeBot."
    ),
    model="gpt-4o-mini",
    tools=[get_weather],
    handoffs=[joke],
)

Re-run with a joke prompt:

result = Runner.run_sync(agent, "Forget the weather. Tell me a joke.")
print(result.final_output)
print(f"[last agent: {result.last_agent.name}]")

Expected: the final output is a joke, and the line prints [last agent: JokeBot] — the handoff transferred control. Re-run with the original weather question and confirm last_agent goes back to WeatherBot.

✓ Self-check: the joke prompt routes to JokeBot; the weather prompt stays with WeatherBot.

Step 4 — Confirm the handoff appears as a synthetic tool

Internally the SDK implements a handoff as a tool call the LLM emits. To prove it, replace Runner.run_sync(...) with Runner.run_streamed(...) and iterate the events:

import asyncio
from agents.stream_events import RunItemStreamEvent

async def main():
    stream = Runner.run_streamed(agent, "Tell me a joke.")
    async for event in stream.stream_events():
        if isinstance(event, RunItemStreamEvent) and event.name in (
            "tool_called", "handoff_requested", "handoff_occured"
        ):
            print(event.name, getattr(event.item, "raw_item", event.item))
    print("FINAL:", stream.final_output)

asyncio.run(main())

Expected: you see a handoff_requested event for transfer_to_jokebot (the SDK’s synthetic tool name), then a handoff_occured event, then the joke text. This confirms what the slides claimed: a handoff is just a tool call with a fancy name — the LLM picks it the same way it picks any other tool.

✓ Self-check: at least one handoff_requested / handoff_occured pair printed before the final joke.

Step 5 — Add a one-line input guardrail

Guardrails are the SDK’s built-in pre/post-condition primitive. Add this near the top of the file:

from agents import input_guardrail, GuardrailFunctionOutput

@input_guardrail
async def block_long_input(ctx, agent, user_input):
    too_long = len(user_input) > 200
    return GuardrailFunctionOutput(
        output_info={"length": len(user_input), "blocked": too_long},
        tripwire_triggered=too_long,
    )

Then wire it into the WeatherBot constructor by adding input_guardrails=[block_long_input] to the keyword arguments:

agent = Agent(
    name="WeatherBot",
    instructions=(
        "You answer weather questions using the get_weather tool. "
        "If the user asks for a joke, hand off to JokeBot."
    ),
    model="gpt-4o-mini",
    tools=[get_weather],
    handoffs=[joke],
    input_guardrails=[block_long_input],
)

Now run with a deliberately over-long question:

long_q = "What is the weather in Kyiv? " * 10
try:
    Runner.run_sync(agent, long_q)
except Exception as e:
    print(type(e).__name__, e)

Expected: InputGuardrailTripwireTriggered is raised before any model call hits the wire. Note the typed exception name vs. the generic RuntimeError you’d hand-write in a from-scratch loop — the SDK gives you a dedicated class you can catch precisely. Restore the original behaviour by deleting the input_guardrails=[...] argument or raising the threshold.

✓ Self-check: the print line shows InputGuardrailTripwireTriggered (not RuntimeError, not a model reply).

Step 6 — Inspect the trace

Re-run the original (no guardrail) handoff scenario and print result.new_items to see what the SDK actually ships to its tracing backend:

result = Runner.run_sync(agent, "Tell me a joke.")
for item in result.new_items:
    print(type(item).__name__, getattr(item, "raw_item", item))

Expected: a sequence including HandoffCallItem, HandoffOutputItem, and MessageOutputItem. These typed items are the structured record of what happened — you’ll use exactly this shape in Lesson 08 to verify which specialist the triage agent picked per request.

✓ Self-check: at least one HandoffCallItem and one HandoffOutputItem appear in the output.

What you should leave with

Troubleshooting

Common stumbles