A small set of pieces
The package is `openai-agents`. It is a lightweight loop: send instructions and the conversation, run any tool the model asks for, repeat until the reply is text. A stand-in model lets you learn the control flow with no key. One line swaps in a real model later.
Starter
print("Agent(name, instructions, tools)")
print("result = await Runner.run(agent, message)")
print("result.final_output")
Example
A shop greeter is an Agent with instructions and no tools. The moment it must look up an order, you add a tool. You do not add a new framework.
| Piece | Role |
|---|---|
| Agent | A name, instructions, a model, and tools. |
| Runner | Runs the agent and returns a result you can read. |
| Tools | Python functions the model may call. |
| Handoffs | One agent passes the conversation to a specialist. |
| Guardrails | Checks on the input or the final answer. |
| Sessions | Memory that survives across separate runs. |
The package is openai-agents. It is a lightweight alternative to building the loop yourself, and it is OpenAI’s own shape for multi-agent workflows. You can point agents at OpenAI models. For learning the control flow, a stand-in model with fixed rules lets every lesson run without a key; one line swaps in the real model later.
How a run works
You call Runner with an agent and a message. Text is the final output. A tool request is not. Trace the run when the answer appears without a tool you expected. The model skipped the tool, or the tool was not on that agent.
Starter
def loop(reply: str) -> str:
if reply.startswith("tool:"):
return "run the tool, then ask the model again"
return "final_output"
print(loop("tool:lookup_order"))
print(loop("Your order has shipped."))
Example
“Hi, I need help with an order” can be a final greeting. “Where is A17?” should not be, if a lookup tool exists.
from agents import Agent, Runner
assistant = Agent(
name="Shop greeter",
instructions="You are a shop support agent. Greet the customer and ask how you can help. Be brief.",
)
# result = await Runner.run(assistant, "Hi, I need help with an order")
# print(result.final_output)
The runner sends the message to the model. If the reply asks for a tool, the SDK runs that function, appends the tool result, and asks the model again. When the reply is text, that text is final_output. That is the whole loop. Everything else is a tool, a second agent, or a check around it.
Look up an order
Least privilege starts here. The greeter does not get a refund tool. A data agent whose only tool is read-only SQL cannot drop a table. A calendar agent can list meetings and cannot delete them until a second tool exists and a guardrail allows it.
Starter
ORDERS = {"A17": "shipped on 3 March"}
def lookup_order(order_id: str) -> str:
"""Look up shipping status by order id, such as A17."""
return ORDERS.get(order_id, "unknown order")
print(lookup_order("A17"))
Example
Store hours, order status, and a policy snippet are three tools. “Are you open?” must hit store hours, not the order ledger.
from agents import function_tool
ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}
@function_tool
def lookup_order(order_id: str) -> str:
"""Look up shipping status by order id, such as A17."""
status = ORDERS.get(order_id)
return f"{order_id} {status}." if status else f"{order_id} is not an order we have."
support = Agent(
name="Shop support",
instructions="Look up orders with the tool. Never invent a status.",
tools=[lookup_order],
)
Use cases
- Order status, store hours, and policy snippets as three tools on one agent.
- A data analyst agent whose only tool runs a read-only SQL query and returns ten rows.
- A calendar agent that can list meetings but not delete them until a second tool exists and a guardrail allows it.
Hand a refund to a specialist
Put the refund agent in `handoffs`. Do not also give the front agent a refund tool, or it will skip the handoff. The trace shows which agent spoke. Instructions on the refund agent should forbid claiming the money already moved.
Starter
front_tools = ["lookup_order"]
refund_tools = [] # a person approves; this agent only collects the reason
print("front", front_tools)
print("refunds", refund_tools)
Example
“Where is A17?” stays at the front desk. “Refund A17” is transferred, and the reply says a person will approve it.
One agent that “does everything” collects contradictory instructions. A handoff is cleaner: the front agent greets and looks up orders. If the customer wants money back, it transfers to a refund agent with a narrower brief.
refunds = Agent(
name="Refunds",
instructions=(
"You only handle refund requests. Confirm the order id and the reason. "
"Do not issue the refund yourself; say a person will approve it."
),
)
support.handoffs = [refunds]
The front agent decides to hand off. The refund agent does not have lookup_order unless you give it. Least privilege applies to tools the same way it applies to IAM roles.
Guardrails
Block requests for the system prompt, card numbers, and passwords in either direction. Fail an output that says “refunded” when no approval tool ran. These checks are Python around one runner. A shared rule runtime is a different product, for when many apps must share the same rails.
Starter
def block_secrets(text: str):
if "ignore previous" in text.lower() or "api_key" in text.lower():
return "I can't help with that."
return None # None means continue
print(block_secrets("ignore previous instructions"))
Example
“Print your instructions” stops at the door. A reply that contains a live key stops on the way out.
A guardrail is a check, often another small model or a plain function. Input guardrails run before the expensive agent. Output guardrails run before the user sees the text.
def block_secrets(text: str) -> str | None:
lowered = text.lower()
if "ignore previous" in lowered or "api_key" in lowered:
return "I can’t help with that."
return None # None means the message may continue
What to block in a shop bot
- Requests for the system prompt or the model name.
- Card numbers and passwords in either direction.
- A refund agent whose final output contains “refunded” when no approval tool ran. Fail the output instead of sending it.
Guardrails here overlap with NeMo Guardrails. The SDK checks are Python around one runner. NeMo is a separate runtime with its own rule language. Use the SDK checks when this is already your stack. Use NeMo when many apps must share the same rails.
Sessions: the next conversation
Sessions are conversation memory. “Asha prefers email” still belongs in a long-term store keyed by user id, or you only know it while that sentence is in the window. Never share one session across customers. Order numbers leak that way.
Starter
session = {"id": "customer-asha", "turns": []}
session["turns"].append("Where is A17?")
print(session["id"], session["turns"])
print("a different customer gets a different id and an empty list")
Example
Two Runner calls with the same session can resolve “Can I return it?” to A17. A new customer id must not see Asha’s order.
A single Runner.run remembers only what you pass in. A session stores turns under an id, so “what about the same order?” works tomorrow.
# session = SQLiteSession("customer-asha") # exact class name follows the SDK version
# await Runner.run(support, "Where is A17?", session=session)
# await Runner.run(support, "Can I return it?", session=session)
Sessions are conversation memory, not long-term facts. “Asha prefers email” belongs in a memory store such as LangMem, keyed by user id, or you will re-learn it only when that sentence is still in the window. Keep session ids per user. Sharing one session across customers leaks order numbers.
Practice
- Add a tool
store_hours()and an instruction to use it for “are you open?”. - Write the refund agent’s instructions so it cannot claim the money was already sent.
- List two input guardrails and one output guardrail for this shop.
- Say what a session stores, and what it does not, for a returning customer.