references/agents.md
# Agents
## Contents
- [Basic Agent Creation](#basic-agent-creation)
- [Other Providers (LiteLLM)](#other-providers-litellm)
- [Azure OpenAI without LiteLLM](#azure-openai-without-litellm-native-client)
- [Dynamic System Prompt](#dynamic-system-prompt)
- [Loading Prompts from Files](#loading-prompts-from-files)
- [Agent Configuration Options](#agent-configuration-options)
## Basic Agent Creation
The minimal `Agent` + `Runner` example lives in SKILL.md (Quick Reference → Basic
Agent) and is not repeated here. Two things that example does not show:
- **Omitting `model=` is a choice, not a safe default.** The SDK ships its own
default model (currently `gpt-5.6-luna` with `reasoning.effort="none"` and
`verbosity="low"`), and that default changes between releases. Set the model
explicitly in production code so an upstream change cannot swap tiers silently.
- **Use explicit model IDs when tier choice matters.** `gpt-5.6` is an alias for
`gpt-5.6-sol`; the explicit `gpt-5.6-sol`, `gpt-5.6-terra` and
`gpt-5.6-luna` IDs make the intended tier clear. Verify current IDs from the
model catalog (`https://developers.openai.com/api/docs/models.md`).
## Other Providers (LiteLLM)
`openai-agents` supports non-OpenAI models through [LiteLLM](https://docs.litellm.ai/), which normalizes 100+ providers (Azure, Anthropic, Bedrock, Vertex AI, Ollama, ...) behind one interface. Install the extra first: `pip install "openai-agents[litellm]"` (or `uv add "openai-agents[litellm]"`). The SDK docs classify LiteLLM (and Any-LLM) as **beta** integrations, recommended only when the built-in integration points are insufficient — for Azure OpenAI specifically, the native path further below needs no extra dependency. Two LiteLLM integration approaches exist:
### Direct model instantiation
Pass a `litellm/<provider>/<model>` string, or instantiate `LitellmModel` directly (shown here with Azure — swap the prefix for other providers):
```python
import os
from typing import Union
from agents import Agent, ModelSettings
from agents.extensions.models.litellm_model import LitellmModel
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "azure") # this project's own convention, not SDK-mandated
MODEL = os.getenv("MODEL", "gpt-5.6-sol") # Azure: the deployment name, not the catalog ID
def get_model() -> Union[str, LitellmModel]:
"""Get model based on provider."""
if LLM_PROVIDER == "azure":
# azure/ prefix tells LiteLLM to use Azure endpoint
# requires AZURE_API_KEY, AZURE_API_BASE, AZURE_API_VERSION —
# see LiteLLM's provider docs below for current names/values, they change over time
return LitellmModel(model=f"azure/{MODEL}")
# Direct OpenAI
return MODEL
agent = Agent(
name="Assistant",
instructions="You are helpful.",
model=get_model(), # Works with both Azure and OpenAI
)
```
### LiteLLM proxy
Run a LiteLLM proxy server and point the SDK at it through a custom `ModelProvider`, authenticating with `LITELLM_API_KEY` (LiteLLM's own key, not the underlying provider's) against `LITELLM_BASE_URL`. Useful for centralized key management/routing across many providers. See LiteLLM's [OpenAI Agents SDK tutorial](https://docs.litellm.ai/docs/tutorials/openai_agents_sdk) for the full setup — it's a different wiring than direct instantiation above, not an alternative env var naming for the same thing.
### References
- **Provider list & model string prefixes:** https://openai.github.io/openai-agents-python/models/
- **Per-provider env vars (Azure, Anthropic, Bedrock, ...):** https://docs.litellm.ai/docs/providers
## Azure OpenAI without LiteLLM (native client)
Azure OpenAI speaks the OpenAI API, so the SDK's own model classes work with an
`AsyncAzureOpenAI` client — no extra dependency:
```python
import os
from openai import AsyncAzureOpenAI
from agents import (
Agent, OpenAIChatCompletionsModel,
set_default_openai_client, set_default_openai_api, set_tracing_disabled,
)
# AsyncAzureOpenAI also auto-reads AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT
# and OPENAI_API_VERSION if you prefer env vars over explicit arguments.
client = AsyncAzureOpenAI(
api_key=os.environ["AZURE_OPENAI_API_KEY"],
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
api_version=os.environ["OPENAI_API_VERSION"],
)
# Option A — one agent, Chat Completions against a named deployment
agent = Agent(
name="Assistant",
instructions="You are helpful.",
model=OpenAIChatCompletionsModel(model="my-gpt-deployment", openai_client=client),
)
# Option B — process-wide default for every agent
set_default_openai_client(client, use_for_tracing=False)
set_default_openai_api("chat_completions") # only if the deployment lacks the Responses API
set_tracing_disabled(True) # or keep OPENAI_API_KEY set for the trace uploader
```
`use_for_tracing=False` matters: trace uploads go to OpenAI's platform and need a
real `OPENAI_API_KEY`; with an Azure-only setup either disable tracing or route
spans elsewhere (see patterns.md → Tracing).
## Dynamic System Prompt
```python
from agents import Agent, Runner, RunContextWrapper
def dynamic_instructions(
ctx: RunContextWrapper[dict], agent: Agent[dict]
) -> str:
user_name = ctx.context.get("user_name", "User")
return f"You are helping {user_name}. Be friendly and helpful."
agent = Agent(
name="DynamicBot",
instructions=dynamic_instructions, # Function instead of string
model="gpt-5.6-sol",
)
result = await Runner.run(
agent,
"Hello!",
context={"user_name": "Alice"},
)
```
## Loading Prompts from Files
```python
from pathlib import Path
PROMPTS_DIR = Path(__file__).parent / "prompts"
def load_prompt(filename: str) -> str:
return (PROMPTS_DIR / filename).read_text(encoding="utf-8")
agent = Agent(
name="Planner",
instructions=load_prompt("planner.md"),
model="gpt-5.6-sol",
)
```
## Agent Configuration Options
| Option | Description |
|--------|-------------|
| `name` | Agent identifier |
| `instructions` | System prompt (string or function) |
| `model` | Model name or LitellmModel instance |
| `tools` | List of tools the agent can use |
| `handoffs` | List of agents to delegate to |
| `output_type` | Pydantic model for structured output |
| `model_settings` | ModelSettings for fine-tuning |
| `input_guardrails` | Input validation functions |
| `output_guardrails` | Output validation functions |
references/guardrails.md
# Guardrails
## Contents
- [Input Guardrails](#input-guardrails)
- [Output Guardrails](#output-guardrails)
- [Guardrail with Context](#guardrail-with-context)
- [Tool Guardrails](#tool-guardrails)
- [Handling Guardrail Errors](#handling-guardrail-errors)
- [GuardrailFunctionOutput Fields](#guardrailfunctionoutput-fields)
## Input Guardrails
Validate and filter input before the agent processes it. Note: input guardrails run in parallel with the agent by default (`run_in_parallel=True`); pass `run_in_parallel=False` for a strict pre-check that blocks before the agent starts.
```python
from agents import Agent, Runner, input_guardrail
from agents import GuardrailFunctionOutput, RunContextWrapper
@input_guardrail
async def check_appropriate(
ctx: RunContextWrapper, agent: Agent, input: str
) -> GuardrailFunctionOutput:
# Check input for inappropriate content
is_inappropriate = "bad_word" in input.lower()
return GuardrailFunctionOutput(
tripwire_triggered=is_inappropriate,
output_info="Inappropriate content detected" if is_inappropriate else None,
)
@input_guardrail
async def check_length(
ctx: RunContextWrapper, agent: Agent, input: str
) -> GuardrailFunctionOutput:
if len(input) > 10000:
return GuardrailFunctionOutput(
tripwire_triggered=True,
output_info="Input too long (max 10000 characters)",
)
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
agent = Agent(
name="SafeAgent",
instructions="Be helpful.",
input_guardrails=[check_appropriate, check_length],
)
```
## Output Guardrails
Validate agent output before returning:
```python
from agents import Agent, output_guardrail
from agents import GuardrailFunctionOutput, RunContextWrapper
@output_guardrail
async def check_no_pii(
ctx: RunContextWrapper, agent: Agent, output: str
) -> GuardrailFunctionOutput:
# Check for potential PII in output
import re
# Simple email pattern check
has_email = bool(re.search(r'\b[\w.-]+@[\w.-]+\.\w+\b', output))
# Simple phone pattern check
has_phone = bool(re.search(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b', output))
if has_email or has_phone:
return GuardrailFunctionOutput(
tripwire_triggered=True,
output_info="Output contains potential PII",
)
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
agent = Agent(
name="PIISafeAgent",
instructions="Help users with their questions.",
output_guardrails=[check_no_pii],
)
```
## Guardrail with Context
```python
from agents import Agent, input_guardrail
from agents import GuardrailFunctionOutput, RunContextWrapper
@input_guardrail
async def check_user_permissions(
ctx: RunContextWrapper[dict], agent: Agent, input: str
) -> GuardrailFunctionOutput:
user_role = ctx.context.get("user_role", "guest")
# Check if user can access admin features
if "admin" in input.lower() and user_role != "admin":
return GuardrailFunctionOutput(
tripwire_triggered=True,
output_info="Admin access not permitted for your role",
)
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
agent = Agent(
name="RoleBasedAgent",
instructions="Help users based on their role.",
input_guardrails=[check_user_permissions],
)
# User without admin access
result = await Runner.run(
agent,
"Show me admin settings",
context={"user_role": "user"},
)
# -> Guardrail triggered
```
## Tool Guardrails
Validate tool inputs before execution with `@tool_input_guardrail` (and tool results with `@tool_output_guardrail`). The function receives a single `data` argument; tool arguments are at `data.context.tool_arguments`. Return `ToolGuardrailFunctionOutput` via its classmethods: `.allow()`, `.reject_content(message=...)`, or `.raise_exception()`.
```python
from agents import function_tool, tool_input_guardrail
from agents import ToolGuardrailFunctionOutput, ToolInputGuardrailData
from typing import Annotated
@tool_input_guardrail
def validate_file_path(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
path = str(data.context.tool_arguments.get("file_path", ""))
# Block access to sensitive directories
forbidden = ["/etc", "/root", "~/.ssh"]
for forbidden_path in forbidden:
if path.startswith(forbidden_path):
return ToolGuardrailFunctionOutput.reject_content(
message=f"Access to {forbidden_path} not allowed",
output_info={"blocked_path": path},
)
return ToolGuardrailFunctionOutput.allow()
@function_tool(tool_input_guardrails=[validate_file_path])
def read_file(file_path: Annotated[str, "Path to file"]) -> str:
"""Read contents of a file."""
with open(file_path) as f:
return f.read()
```
## Handling Guardrail Errors
```python
from agents import Agent, Runner, InputGuardrailTripwireTriggered
agent = Agent(
name="SafeBot",
instructions="Be helpful.",
input_guardrails=[check_appropriate],
)
try:
result = await Runner.run(agent, "Some bad_word input")
except InputGuardrailTripwireTriggered as e:
print(f"Input blocked: {e.guardrail_result.output.output_info}")
```
## GuardrailFunctionOutput Fields
| Field | Description |
|-------|-------------|
| `tripwire_triggered` | True if guardrail should block |
| `output_info` | Human-readable explanation |
references/handoffs.md
# Handoffs
## Contents
- [Basic Handoffs](#basic-handoffs)
- [Multiple Handoffs](#multiple-handoffs)
- [Handoff with Context](#handoff-with-context)
- [Handoff vs Agents as Tools](#handoff-vs-agents-as-tools)
- [Message Filtering](#message-filtering)
## Basic Handoffs
Handoffs allow agents to delegate tasks to specialized agents:
```python
from agents import Agent, handoff
billing_agent = Agent(
name="BillingAgent",
instructions="Handle billing questions. You can help with invoices, payments, and subscriptions.",
)
support_agent = Agent(
name="SupportAgent",
instructions="Handle general support. Handoff billing questions to the billing agent.",
handoffs=[billing_agent],
)
# LLM automatically decides when to delegate to another agent
result = await Runner.run(support_agent, "I have a question about my invoice")
```
## Multiple Handoffs
```python
billing_agent = Agent(
name="BillingAgent",
instructions="Handle billing and payment questions.",
)
technical_agent = Agent(
name="TechnicalAgent",
instructions="Handle technical issues and troubleshooting.",
)
sales_agent = Agent(
name="SalesAgent",
instructions="Handle sales inquiries and pricing.",
)
triage_agent = Agent(
name="TriageAgent",
instructions="""You are a customer service triage agent.
Route customers to the appropriate specialist:
- Billing questions -> BillingAgent
- Technical issues -> TechnicalAgent
- Sales/pricing -> SalesAgent
""",
handoffs=[billing_agent, technical_agent, sales_agent],
)
result = await Runner.run(triage_agent, "My app keeps crashing")
# -> Delegates to TechnicalAgent
```
## Handoff with Context
```python
from agents import Agent, handoff, RunContextWrapper
def escalation_instructions(
ctx: RunContextWrapper[dict], agent: Agent[dict]
) -> str:
priority = ctx.context.get("priority", "normal")
return f"""You are handling an escalated case.
Priority level: {priority}
Be thorough and professional."""
escalation_agent = Agent(
name="EscalationAgent",
instructions=escalation_instructions,
)
support_agent = Agent(
name="SupportAgent",
instructions="Handle support. Escalate complex issues.",
handoffs=[escalation_agent],
)
result = await Runner.run(
support_agent,
"This is urgent, I need help immediately!",
context={"priority": "high"},
)
```
## Handoff vs Agents as Tools
| Feature | Handoffs | Agents as Tools |
|---------|----------|-----------------|
| Control flow | LLM decides when to delegate | Parent agent calls child explicitly |
| Return | Child agent takes over | Returns result to parent |
| Use case | Specialized routing | Orchestration, parallel tasks |
| Conversation | Child continues conversation | Parent continues after tool result |
### Handoff Example
```python
# Child takes over the conversation
support_agent = Agent(
name="Support",
handoffs=[billing_agent], # Billing agent takes over
)
```
### Agent as Tool Example
```python
# Parent stays in control
orchestrator = Agent(
name="Orchestrator",
tools=[
billing_agent.as_tool(
tool_name="check_billing",
tool_description="Get billing info",
),
],
)
```
## Message Filtering
Control what messages are passed during handoff:
An input filter receives and returns `HandoffInputData` (fields include `input_history`, `pre_handoff_items`, `new_items`, `input_items`, `run_context` — the set grows across releases, so never rebuild the dataclass field by field):
```python
import dataclasses
from agents import Agent, handoff
from agents.handoffs import HandoffInputData
def filter_messages(data: HandoffInputData) -> HandoffInputData:
# Only keep last 5 items of the input history
history = data.input_history
if isinstance(history, tuple):
history = history[-5:]
# replace() copies every other field, so fields added by newer SDKs survive
return dataclasses.replace(data, input_history=history)
specialist = Agent(
name="Specialist",
instructions="Handle specialized tasks.",
)
agent = Agent(
name="Router",
instructions="Route to specialist when needed.",
handoffs=[
handoff(
agent=specialist,
input_filter=filter_messages,
),
],
)
```
Ready-made filters are available in `agents.extensions.handoff_filters`, e.g. `handoff_filters.remove_all_tools` to strip tool calls from the handed-off history.
references/patterns.md
# Patterns
## Contents
- [Multi-Agent Workflow Pipeline](#multi-agent-workflow-pipeline)
- [LLM as a Judge](#llm-as-a-judge)
- [Tracing](#tracing)
- [Parallelization](#parallelization)
- [Routing](#routing)
- [Deterministic Workflows](#deterministic-workflows)
## Multi-Agent Workflow Pipeline
Example: 3-stage pipeline (ProductSelector -> SetOptimizer -> PlanGenerator)
```python
from pathlib import Path
from pydantic import BaseModel, Field
from agents import Agent, AgentOutputSchema, ModelSettings, RunConfig, Runner
from openai.types.responses import ResponseTextDeltaEvent
from openai.types.shared.reasoning import Reasoning
from collections.abc import AsyncIterator
# --- Pydantic Output Schemas ---
class ProductLite(BaseModel):
product_id: str
name: str
score: float = Field(ge=0, le=1)
class ProductsOutput(BaseModel):
products: list[ProductLite]
class TravelSet(BaseModel):
set_id: str # "compact", "balanced", "extended"
name: str
product_ids: list[str]
class SetsOutput(BaseModel):
sets: list[TravelSet]
recommended_set_id: str
# --- Prompt Loading ---
PROMPTS_DIR = Path(__file__).parent / "prompts"
def load_prompt(name: str) -> str:
return (PROMPTS_DIR / name).read_text(encoding="utf-8")
# --- Agents ---
# Step 1: Select products (structured output)
product_selector = Agent(
name="ProductSelector",
instructions=load_prompt("product_selector.md"),
model=get_model(),
model_settings=ModelSettings(max_tokens=64000),
output_type=AgentOutputSchema(ProductsOutput, strict_json_schema=True),
)
# Step 2: Optimize sets (structured output)
set_optimizer = Agent(
name="SetOptimizer",
instructions=load_prompt("set_optimizer.md"),
model=get_model(),
model_settings=ModelSettings(
max_tokens=16000,
reasoning=Reasoning(effort="low"),
),
output_type=AgentOutputSchema(SetsOutput, strict_json_schema=True),
)
# Step 3: Generate plan (streaming, no schema)
plan_generator = Agent(
name="PlanGenerator",
instructions=load_prompt("plan_generator.md"),
model=get_model(),
model_settings=ModelSettings(
max_tokens=32000,
reasoning=Reasoning(effort="low"),
),
# No output_type = free text for streaming
)
# --- Runner Functions ---
async def select_products(user_prompt: str, context: str) -> list[ProductLite]:
"""Step 1: Select products."""
result = await Runner.run(
product_selector,
input=f"User: {user_prompt}\n\nProducts:\n{context}",
run_config=RunConfig(
workflow_name="ProductSelector",
trace_metadata={"step": "select"},
),
)
output: ProductsOutput = result.final_output
return output.products
async def optimize_sets(products: list[dict]) -> tuple[list[TravelSet], str]:
"""Step 2: Create optimized sets."""
result = await Runner.run(
set_optimizer,
input=f"Products:\n{products}",
run_config=RunConfig(workflow_name="SetOptimizer"),
)
output: SetsOutput = result.final_output
return output.sets, output.recommended_set_id
async def generate_plan_stream(products: list[dict]) -> AsyncIterator[str]:
"""Step 3: Generate plan with streaming."""
result = Runner.run_streamed(
plan_generator,
input=f"Create travel plan for:\n{products}",
run_config=RunConfig(workflow_name="PlanGenerator"),
)
async for event in result.stream_events():
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
yield event.data.delta
# --- Full Workflow ---
async def travel_workflow(user_prompt: str, products_context: str):
# Step 1
products = await select_products(user_prompt, products_context)
print(f"Selected {len(products)} products")
# Step 2
sets, recommended = await optimize_sets([p.model_dump() for p in products])
print(f"Created {len(sets)} sets, recommended: {recommended}")
# Step 3 - stream
async for chunk in generate_plan_stream([p.model_dump() for p in products]):
print(chunk, end="", flush=True)
```
## LLM as a Judge
Iterative improvement with evaluator agent:
```python
from dataclasses import dataclass
from typing import Literal
from agents import Agent, Runner, TResponseInputItem, trace
@dataclass
class Evaluation:
score: Literal["pass", "needs_improvement", "fail"]
feedback: str
generator = Agent(
name="Generator",
instructions="Generate content based on feedback.",
)
evaluator = Agent(
name="Evaluator",
instructions="Evaluate and provide feedback.",
output_type=Evaluation,
)
async def generate_with_feedback(prompt: str) -> str:
inputs: list[TResponseInputItem] = [{"role": "user", "content": prompt}]
with trace("LLM as a judge"):
while True:
gen_result = await Runner.run(generator, inputs)
inputs = gen_result.to_input_list()
eval_result = await Runner.run(evaluator, inputs)
evaluation: Evaluation = eval_result.final_output
if evaluation.score == "pass":
return gen_result.final_output
inputs.append({"role": "user", "content": f"Feedback: {evaluation.feedback}"})
```
## Tracing
Group related agent runs together:
```python
from agents import Agent, Runner, trace, RunConfig
async def workflow(user_input: str):
with trace("MyWorkflow"):
# All Runner.run() calls inside this block
# appear in the same trace
result1 = await Runner.run(agent1, user_input)
result2 = await Runner.run(agent2, result1.to_input_list())
return result2.final_output
# RunConfig for metadata
result = await Runner.run(
agent,
input=message,
run_config=RunConfig(
workflow_name="ProductSelector",
trace_metadata={"agent": "selector", "locale": "fi"},
),
)
```
More tracing and loop controls:
- `custom_span("name")` — wrap your own work (DB call, external API) so it shows
inside the run's trace.
- `set_tracing_disabled(True)` or env `OPENAI_AGENTS_DISABLE_TRACING=1` — stop
uploads; needed when no `OPENAI_API_KEY` exists (Azure- or LiteLLM-only setups).
- `add_trace_processor(...)` / `set_trace_processors([...])` — send spans to your
own backend (Langfuse, Logfire, OpenTelemetry, …) in addition to, or instead of,
OpenAI's dashboard.
- `Runner.run(..., max_turns=10)` — cap the agent loop (default 10); the run raises
`MaxTurnsExceeded` when the cap is hit.
## Parallelization
Run multiple agents concurrently:
```python
import asyncio
from agents import Agent, Runner
agent1 = Agent(name="Researcher", instructions="Research topics.")
agent2 = Agent(name="Analyzer", instructions="Analyze data.")
agent3 = Agent(name="Writer", instructions="Write content.")
async def parallel_workflow(topic: str):
# Run research and analysis in parallel
research_task = Runner.run(agent1, f"Research: {topic}")
analysis_task = Runner.run(agent2, f"Analyze: {topic}")
research_result, analysis_result = await asyncio.gather(
research_task, analysis_task
)
# Combine results for writer
combined_input = f"""
Research: {research_result.final_output}
Analysis: {analysis_result.final_output}
"""
writer_result = await Runner.run(agent3, combined_input)
return writer_result.final_output
```
## Routing
Route to specialized agents based on input:
```python
from agents import Agent, Runner, function_tool
from typing import Literal
@function_tool
def classify_intent(query: str) -> Literal["billing", "technical", "sales"]:
"""Classify user intent."""
# In real app, could use another LLM or classifier
if "invoice" in query or "payment" in query:
return "billing"
elif "error" in query or "bug" in query:
return "technical"
return "sales"
router = Agent(
name="Router",
instructions="Classify user intent using the classify tool.",
tools=[classify_intent],
)
agents = {
"billing": Agent(name="Billing", instructions="Handle billing."),
"technical": Agent(name="Technical", instructions="Handle tech support."),
"sales": Agent(name="Sales", instructions="Handle sales."),
}
async def route_and_handle(query: str):
# First, classify
router_result = await Runner.run(router, query)
intent = router_result.final_output # "billing", "technical", or "sales"
# Route to specialist
specialist = agents[intent]
result = await Runner.run(specialist, query)
return result.final_output
```
## Deterministic Workflows
Force specific tool execution order:
```python
from agents import Agent, ModelSettings
# Phase 1: Must search
search_agent = Agent(
name="Searcher",
instructions="Search for information.",
tools=[search_tool],
model_settings=ModelSettings(tool_choice="required"),
)
# Phase 2: Must analyze
analyzer = Agent(
name="Analyzer",
instructions="Analyze the search results.",
tools=[analyze_tool],
model_settings=ModelSettings(tool_choice="required"),
)
# Phase 3: Free response
writer = Agent(
name="Writer",
instructions="Write based on analysis.",
# No tool_choice = free text response
)
async def deterministic_workflow(query: str):
# Guaranteed order: search -> analyze -> write
search_result = await Runner.run(search_agent, query)
analysis_result = await Runner.run(analyzer, search_result.to_input_list())
final_result = await Runner.run(writer, analysis_result.to_input_list())
return final_result.final_output
```
references/sandbox.md
# Sandbox Agents
`SandboxAgent` gives an agent a persistent workspace — filesystem tools, shell
access and skills — inside an isolated sandbox, so it can search large document
sets, edit files, run commands, generate artifacts and resume from saved state.
The sandbox hosts the tools; the agent loop itself still runs in your process.
**Beta.** The docs say the API, defaults and supported capabilities may change
before general availability. Fetch the current page before writing code:
`https://openai.github.io/openai-agents-python/sandbox_agents/` (Python 3.10+).
## Building blocks (names verified against the docs page)
| Import | Purpose |
|--------|---------|
| `from agents.sandbox import SandboxAgent, SandboxRunConfig, Manifest` | The agent type, the per-run sandbox config, and the workspace manifest (what gets mounted) |
| `from agents.sandbox.entries import LocalDir` | Manifest entry that mounts a local directory into the workspace |
| `from agents.sandbox.capabilities import Capabilities` | Tool bundle inside the sandbox — start from `Capabilities.default()` and add e.g. `Skills` |
| `from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient` | Runs the sandbox on the local Unix machine |
| `DockerSandboxClient` | Container-backed sandbox; install with `pip install "openai-agents[docker]"` |
| `from agents import Runner, RunConfig` | Run as usual: `RunConfig(sandbox=SandboxRunConfig(client=...))` |
Shape of a run (fill in from the docs' example — do not guess constructor
arguments):
```python
from agents import Runner, RunConfig
from agents.sandbox import SandboxAgent, SandboxRunConfig, Manifest
from agents.sandbox.entries import LocalDir
from agents.sandbox.capabilities import Capabilities
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
agent = SandboxAgent(
name="Workspace agent",
instructions="Work inside the mounted workspace.",
default_manifest=Manifest(entries=[LocalDir(...)]), # see docs for LocalDir args
default_capabilities=Capabilities.default(), # add Skills(...) if the workspace ships skills
)
result = await Runner.run(
agent,
"Summarize the docs folder",
run_config=RunConfig(sandbox=SandboxRunConfig(client=UnixLocalSandboxClient())),
)
```
## Resuming state
`SandboxRunConfig` accepts a `session`, `session_state` or `snapshot` so a later
run picks up the same workspace instead of starting cold. Skills can be loaded
lazily from a directory with `LocalDirLazySkillSource`. Details, snapshot
semantics and the compaction behaviour are only in the live docs — quote them,
do not reconstruct from memory.
## When not to use it
- The task is a plain tool call or API orchestration — `@function_tool` is
enough and has no sandbox overhead.
- You need delegation: there is no separate `Subagent` class; compose with
`agent.as_tool()` or handoffs (see handoffs.md, tools.md).
references/sessions.md
# Sessions
## Contents
- [Conversation History](#conversation-history-with-to_input_list)
- [SQLite Session](#sqlite-session)
- [Advanced SQLite Session](#advanced-sqlite-session)
- [Redis Session](#redis-session)
- [OpenAI Conversations Session](#openai-conversations-session)
- [Compaction Session](#compaction-session)
- [Encrypted Session](#encrypted-session)
- [Session Comparison](#session-comparison)
## Conversation History with to_input_list()
Manual conversation history management:
```python
from agents import Agent, Runner, TResponseInputItem
agent = Agent(name="ChatBot", instructions="Be helpful.")
# First message
result = await Runner.run(agent, "Hello!")
# Continue conversation with history
inputs = result.to_input_list()
inputs.append({"role": "user", "content": "Tell me more"})
result = await Runner.run(agent, inputs)
```
## SQLite Session
Automatic conversation history with SQLite:
```python
from agents import Agent, Runner, SQLiteSession
agent = Agent(name="ChatBot", instructions="Remember our conversation.")
# Session stores and loads history automatically
session = SQLiteSession("conversation_123")
result1 = await Runner.run(agent, "My name is John", session=session)
result2 = await Runner.run(agent, "What's my name?", session=session)
# -> "Your name is John"
```
## Advanced SQLite Session
```python
from agents import Agent, Runner, SQLiteSession
# Custom database path
session = SQLiteSession(
session_id="user_456_chat",
db_path="./data/conversations.db",
)
agent = Agent(
name="MemoryBot",
instructions="Remember user preferences and history.",
)
# Multiple conversations with same agent
await Runner.run(agent, "I prefer dark mode", session=session)
await Runner.run(agent, "Set language to Finnish", session=session)
# Later session retrieval
session2 = SQLiteSession(session_id="user_456_chat", db_path="./data/conversations.db")
result = await Runner.run(agent, "What are my preferences?", session=session2)
# -> Remembers dark mode and Finnish language
```
## Redis Session
For distributed systems:
```python
from agents import Agent, Runner
from agents.extensions.memory import RedisSession
session = RedisSession.from_url(
"user_789",
url="redis://localhost:6379",
ttl=3600, # 1 hour expiry
)
# Or pass an existing client: RedisSession("user_789", redis_client=client)
agent = Agent(name="ScalableBot", instructions="Be helpful.")
result = await Runner.run(agent, "Hello!", session=session)
```
## OpenAI Conversations Session
Using OpenAI's hosted Conversations API as storage:
```python
from agents import Agent, Runner, OpenAIConversationsSession
# Omit conversation_id to start a new conversation,
# or resume an existing one (keyword-only argument)
session = OpenAIConversationsSession(conversation_id="conv_123")
agent = Agent(
name="OpenAIMemoryBot",
instructions="Use your memory to help users.",
)
result = await Runner.run(agent, "Remember I like Python", session=session)
```
## Compaction Session
Automatically compact long conversations using the Responses API:
```python
from agents import Agent, Runner, OpenAIResponsesCompactionSession, SQLiteSession
base_session = SQLiteSession("long_conversation")
# Wraps another session and compacts history server-side when needed
session = OpenAIResponsesCompactionSession(
session_id="long_conversation",
underlying_session=base_session,
)
# Optional keyword-only params: client=, model="gpt-4.1",
# compaction_mode="auto", should_trigger_compaction=callable
agent = Agent(name="LongChatBot", instructions="Have long conversations.")
# After many messages, older ones are compacted automatically
for i in range(30):
await Runner.run(agent, f"Message {i}", session=session)
```
## Encrypted Session
For sensitive conversations:
```python
from agents import Agent, Runner, SQLiteSession
from agents.extensions.memory import EncryptedSession
base_session = SQLiteSession("sensitive_chat")
session = EncryptedSession(
session_id="sensitive_chat",
underlying_session=base_session,
encryption_key="your-32-byte-encryption-key-here",
ttl=600, # Items older than this can no longer be decrypted
)
agent = Agent(name="SecureBot", instructions="Handle sensitive information.")
result = await Runner.run(agent, "My SSN is 123-45-6789", session=session)
# Data stored encrypted in SQLite
```
## Session Comparison
| Session Type | Import | Storage | Use Case |
|--------------|--------|---------|----------|
| Manual (to_input_list) | - | Memory | Simple, single-request |
| SQLiteSession | `agents` | Local file | Single-server apps |
| AsyncSQLiteSession | `agents.extensions.memory` | Local file | Async SQLite access |
| AdvancedSQLiteSession | `agents.extensions.memory` | Local file | Branching, usage analytics |
| SQLAlchemySession | `agents.extensions.memory` | Any SQL DB | Postgres/MySQL etc. |
| RedisSession | `agents.extensions.memory` | Redis | Distributed systems |
| EncryptedSession | `agents.extensions.memory` | Wrapper | Sensitive data |
| OpenAIConversationsSession | `agents` | OpenAI Conversations API | Hosted history |
| OpenAIResponsesCompactionSession | `agents` | Wrapper | Long conversations |
| MongoDBSession | `agents.extensions.memory` | MongoDB | Document store |
| DaprSession | `agents.extensions.memory` | Dapr state store | Dapr-based apps |
references/streaming.md
# Streaming
## Contents
- [Basic Streaming](#basic-streaming)
- [Stream Items](#stream-items)
- [SSE Streaming with FastAPI](#sse-streaming-with-fastapi)
- [Streaming with Tool Calls](#streaming-with-tool-calls)
- [Streaming with Guardrails](#streaming-with-guardrails)
- [Collecting Full Response](#collecting-full-response)
## Basic Streaming
```python
from openai.types.responses import ResponseTextDeltaEvent
from agents import Agent, Runner
agent = Agent(name="Writer", instructions="Write stories.")
result = Runner.run_streamed(agent, input="Write a short story")
async for event in result.stream_events():
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
print(event.data.delta, end="", flush=True)
```
## Stream Items
```python
from agents import Agent, Runner, ItemHelpers
agent = Agent(name="Assistant", instructions="Be helpful.")
result = Runner.run_streamed(agent, input="Tell me about Python")
async for event in result.stream_events():
if event.type == "run_item_stream_event":
print(f"Item type: {event.item.type}")
if event.item.type == "message_output_item":
print(f"Text: {ItemHelpers.text_message_output(event.item)}")
```
## SSE Streaming with FastAPI
```python
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai.types.responses import ResponseTextDeltaEvent
from agents import Agent, Runner
app = FastAPI()
agent = Agent(name="Assistant", instructions="Be helpful.")
def sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
@app.post("/stream")
async def stream_response(prompt: str):
async def generate():
result = Runner.run_streamed(agent, input=prompt)
async for event in result.stream_events():
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
yield sse("delta", {"text": event.data.delta})
yield sse("done", {})
return StreamingResponse(
generate(),
media_type="text/event-stream",
)
```
## Streaming with Tool Calls
```python
from agents import Agent, Runner, function_tool
from openai.types.responses import ResponseTextDeltaEvent, ResponseFunctionCallArgumentsDeltaEvent
@function_tool
def get_data(query: str) -> str:
return f"Data for {query}"
agent = Agent(
name="DataBot",
instructions="Fetch data when asked.",
tools=[get_data],
)
result = Runner.run_streamed(agent, input="Get data about sales")
async for event in result.stream_events():
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
print(f"Text: {event.data.delta}", end="")
elif isinstance(event.data, ResponseFunctionCallArgumentsDeltaEvent):
print(f"Tool args: {event.data.delta}", end="")
```
## Streaming with Guardrails
```python
from agents import Agent, Runner, input_guardrail
from agents import GuardrailFunctionOutput, RunContextWrapper
@input_guardrail
async def check_input(
ctx: RunContextWrapper, agent: Agent, input: str
) -> GuardrailFunctionOutput:
if "bad" in input.lower():
return GuardrailFunctionOutput(
tripwire_triggered=True,
output_info="Inappropriate content",
)
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
agent = Agent(
name="SafeBot",
instructions="Be helpful.",
input_guardrails=[check_input],
)
try:
result = Runner.run_streamed(agent, input="Hello")
async for event in result.stream_events():
# Process events
pass
except Exception as e:
print(f"Guardrail triggered: {e}")
```
## Collecting Full Response
```python
result = Runner.run_streamed(agent, input="Tell me a story")
# Stream first
async for event in result.stream_events():
if event.type == "raw_response_event":
if isinstance(event.data, ResponseTextDeltaEvent):
print(event.data.delta, end="")
# Once the stream is consumed, final_output is populated
print(f"\n\nFull output: {result.final_output}")
```
references/structured-output.md
# Structured Output
## Contents
- [AgentOutputSchema with Pydantic](#agentoutputschema-with-pydantic)
- [Simple Output Type](#simple-output-type)
- [ModelSettings](#modelsettings)
- [ModelSettings Options](#modelsettings-options)
- [Non-Strict Output](#non-strict-output)
## AgentOutputSchema with Pydantic
```python
from pydantic import BaseModel, Field
from agents import Agent, Runner, AgentOutputSchema, ModelSettings
from openai.types.shared.reasoning import Reasoning
# Pydantic model for response structure
class ProductRecommendationLite(BaseModel):
product_id: str = Field(description="Unique product ID")
name: str = Field(description="Product name")
relevance_reason: str = Field(description="Why this product matches")
match_score: float = Field(ge=0, le=1, description="Match score 0-1")
class ProductSelectionOutput(BaseModel):
products: list[ProductRecommendationLite] = Field(description="Selected products")
# Agent with strict JSON schema output
agent = Agent(
name="ProductSelector",
instructions="Select the 10 best products matching user request...",
model=get_model(), # defined in agents.md (LiteLLM/Azure switch)
model_settings=ModelSettings(
max_tokens=64000,
# Reasoning effort: "none", "low", "medium", "high", "xhigh", "max"
reasoning=Reasoning(effort="low"),
),
# strict_json_schema=True forces LLM to return valid JSON
output_type=AgentOutputSchema(ProductSelectionOutput, strict_json_schema=True),
)
result = await Runner.run(agent, "Find products for family hiking trip")
output: ProductSelectionOutput = result.final_output
# Use the result
for product in output.products:
print(f"{product.name}: {product.match_score} - {product.relevance_reason}")
```
## Simple Output Type
```python
from dataclasses import dataclass
from typing import Literal
@dataclass
class EvaluationFeedback:
feedback: str
score: Literal["pass", "needs_improvement", "fail"]
evaluator = Agent[None](
name="Evaluator",
instructions="Evaluate content and provide feedback.",
output_type=EvaluationFeedback,
)
result = await Runner.run(evaluator, "Review this story outline...")
evaluation: EvaluationFeedback = result.final_output
print(f"Score: {evaluation.score}, Feedback: {evaluation.feedback}")
```
## ModelSettings
```python
from agents import Agent, ModelSettings
from openai.types.shared.reasoning import Reasoning
agent = Agent(
name="Assistant",
instructions="Be helpful.",
model="gpt-5.6-sol",
model_settings=ModelSettings(
max_tokens=32000,
temperature=0.7,
tool_choice="required", # Force tool usage
reasoning=Reasoning(effort="medium"), # GPT-5 reasoning
),
)
```
## ModelSettings Options
| Option | Description |
|--------|-------------|
| `max_tokens` | Maximum tokens in response |
| `temperature` | Randomness (0.0-2.0) |
| `top_p` | Nucleus sampling |
| `tool_choice` | "auto", "required", "none" |
| `reasoning` | Reasoning effort for GPT-5 models |
| `presence_penalty` | Penalize repeated topics |
| `frequency_penalty` | Penalize repeated tokens |
## Non-Strict Output
For schemas that don't support strict mode:
```python
from agents import Agent, AgentOutputSchema
class FlexibleOutput(BaseModel):
data: dict # dict type not supported in strict mode
notes: str
agent = Agent(
name="Flexible",
instructions="Return flexible data.",
output_type=AgentOutputSchema(FlexibleOutput, strict_json_schema=False),
)
```
references/tools.md
# Tools
## Contents
- [Function Tools](#function-tools-function_tool)
- [Tool with Multiple Parameters](#tool-with-multiple-parameters)
- [Hosted Tools](#hosted-tools-built-in)
- [Agents as Tools](#agents-as-tools)
- [Tool Guardrails](#tool-guardrails)
- [Forcing Tool Use](#forcing-tool-use)
## Function Tools (@function_tool)
```python
from typing import Annotated
from agents import Agent, Runner, function_tool
@function_tool
def get_weather(city: Annotated[str, "City name"]) -> str:
"""Get weather for a city."""
return f"Weather in {city}: Sunny, 20C"
@function_tool
async def search_database(query: Annotated[str, "Search query"]) -> list[dict]:
"""Search products in database."""
# Async function - can await database calls
return [{"id": "1", "name": "Hiking boots"}]
agent = Agent(
name="Assistant",
instructions="Help users find information.",
tools=[get_weather, search_database],
)
```
## Tool with Multiple Parameters
```python
@function_tool
def book_flight(
origin: Annotated[str, "Departure city"],
destination: Annotated[str, "Arrival city"],
date: Annotated[str, "Travel date (YYYY-MM-DD)"],
passengers: Annotated[int, "Number of passengers"] = 1,
) -> dict:
"""Book a flight between two cities."""
return {
"confirmation": "ABC123",
"route": f"{origin} -> {destination}",
"date": date,
"passengers": passengers,
}
```
## Hosted Tools (Built-in)
```python
from agents import Agent, WebSearchTool, CodeInterpreterTool
agent = Agent(
name="Researcher",
instructions="Search the web and analyze data.",
tools=[
WebSearchTool(user_location={"type": "approximate", "city": "Helsinki"}),
CodeInterpreterTool(tool_config={"type": "code_interpreter", "container": {"type": "auto"}}),
],
)
```
Hosted tools (run on OpenAI's servers):
- `WebSearchTool` - web search
- `FileSearchTool` - retrieval from OpenAI vector stores
- `CodeInterpreterTool` - sandboxed code execution (requires `tool_config`)
- `HostedMCPTool` - remote MCP server tools
- `ImageGenerationTool` - image generation
- `ToolSearchTool` - on-demand tool discovery for large tool sets
- `ProgrammaticToolCallingTool` - the model calls tools from code it writes
- `ShellTool` - shell execution in a hosted container (also has a local mode)
Local runtime tools (execute on your machine):
- `ComputerTool` - computer use / GUI automation
- `ShellTool` (local mode) / `LocalShellTool` - local shell commands
- `ApplyPatchTool` - apply file patches
The list moves between releases — confirm names in the tools reference
(https://openai.github.io/openai-agents-python/tools/) before use.
## Agents as Tools
Use other agents as tools for orchestration:
```python
from agents import Agent, Runner
translator_es = Agent(
name="SpanishTranslator",
instructions="Translate to Spanish.",
)
translator_fr = Agent(
name="FrenchTranslator",
instructions="Translate to French.",
)
orchestrator = Agent(
name="Orchestrator",
instructions="Use translation tools as needed.",
tools=[
translator_es.as_tool(
tool_name="translate_spanish",
tool_description="Translate text to Spanish",
),
translator_fr.as_tool(
tool_name="translate_french",
tool_description="Translate text to French",
),
],
)
result = await Runner.run(orchestrator, "Translate 'hello' to Spanish and French")
```
## Tool Guardrails
Use `@tool_input_guardrail` / `@tool_output_guardrail` and attach via `tool_input_guardrails=` / `tool_output_guardrails=` on `@function_tool`. Return `ToolGuardrailFunctionOutput` via `.allow()`, `.reject_content(message=...)`, or `.raise_exception()`.
```python
from agents import function_tool, tool_input_guardrail
from agents import ToolGuardrailFunctionOutput, ToolInputGuardrailData
@tool_input_guardrail
def validate_query(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
query = str(data.context.tool_arguments.get("query", ""))
if len(query) < 3:
return ToolGuardrailFunctionOutput.reject_content(message="Query too short")
return ToolGuardrailFunctionOutput.allow()
@function_tool(tool_input_guardrails=[validate_query])
def search(query: Annotated[str, "Search query"]) -> list[str]:
"""Search for items."""
return ["result1", "result2"]
```
## Forcing Tool Use
```python
from agents import Agent, ModelSettings
agent = Agent(
name="ToolUser",
instructions="Always use tools to answer.",
tools=[get_weather, search_database],
model_settings=ModelSettings(
tool_choice="required", # Force tool usage
),
)
```
SKILL.md
---
name: openai-agents-sdk
description: OpenAI Agents SDK (Python) development. Use when building AI agents, multi-agent handoffs, function tools, guardrails, sessions, streaming, or tracing with the `openai-agents` / `agents` Python package — including Azure OpenAI via LiteLLM. Triggers on imports from `agents`, uses of `Runner.run_sync`/`Runner.run_streamed`, `@function_tool`, `AgentOutputSchema`, `SQLiteSession`, or questions about the openai-agents-python SDK. Python only — not the TypeScript `@openai/agents` SDK.
---
# OpenAI Agents SDK (Python)
Use this skill when developing AI agents using OpenAI Agents SDK (`openai-agents` package).
## Quick Reference
### Installation
```bash
uv add openai-agents # or `pip install openai-agents` outside a uv project
```
### Environment Variables
```bash
OPENAI_API_KEY=sk-...
```
Using Azure or another provider instead? See [agents.md](references/agents.md#other-providers-litellm) — don't hardcode provider env vars here, they vary and go stale.
### Basic Agent
```python
from agents import Agent, Runner
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
model="gpt-5.6-sol", # or "gpt-5.6-terra" / "gpt-5.6-luna" (cheaper tiers).
# "gpt-5.6" is an alias for gpt-5.6-sol. Verify
# current IDs from the model catalog.
)
# Synchronous
result = Runner.run_sync(agent, "Tell me a joke")
print(result.final_output)
# Asynchronous
result = await Runner.run(agent, "Tell me a joke")
```
Omitting `model=` uses the SDK's built-in default (currently `gpt-5.6-luna` with low-effort reasoning settings) — set it explicitly in production so an upstream default change cannot swap tiers silently.
### Key Patterns
| Pattern | Purpose |
|---------|---------|
| Basic Agent | Simple Q&A with instructions |
| Azure/LiteLLM | Azure OpenAI integration |
| AgentOutputSchema | Strict JSON validation with Pydantic |
| Function Tools | External actions (@function_tool) |
| Streaming | Real-time UI (Runner.run_streamed) |
| Handoffs | Specialized agents, delegation |
| Agents as Tools | Orchestration (agent.as_tool) |
| LLM as Judge | Iterative improvement loop |
| Guardrails | Input/output validation |
| Sessions | Automatic conversation history |
| Multi-Agent Pipeline | Multi-step workflows |
| Sandboxing | `SandboxAgent` — filesystem, shell and skills inside a local/Docker sandbox (beta) |
| Tracing | Built-in spans for runs, tools, handoffs and guardrails; pluggable processors |
The SDK has no separate `Subagent` class: express delegation with handoffs or
`agent.as_tool()`. For model-written tool orchestration, use
`ProgrammaticToolCallingTool` and verify its Responses-only constraints.
## Preferred: Live Docs via MCP
Model names and API details change frequently. When available, consult the **OpenAI Developer Docs MCP server** (`openaiDeveloperDocs`) before relying on the static references below.
Setup (Codex CLI):
```bash
codex mcp add openaiDeveloperDocs --url https://developers.openai.com/mcp
```
Setup (Claude Code):
```bash
claude mcp add --transport http openaiDeveloperDocs https://developers.openai.com/mcp
```
Or config (`~/.codex/config.toml`, VS Code `.vscode/mcp.json`, Cursor `~/.cursor/mcp.json`):
```toml
[mcp_servers.openaiDeveloperDocs]
url = "https://developers.openai.com/mcp"
```
Key tools: `mcp__openaiDeveloperDocs__search_openai_docs`, `fetch_openai_doc`, `list_api_endpoints`, `get_openapi_spec`.
**Rules:** Cite fetched docs. Never speculate on field names, defaults, or current model IDs — fetch first. Keep quotes under 125 chars.
Fallback when MCP is unavailable: `https://developers.openai.com/api/docs/llms.txt` (plain-text index of all API docs; each entry has a `.md` twin at `/api/docs/<slug>.md`).
## Reference Documentation
Offline/quick-lookup snippets. Verify model names and API signatures against the MCP or docs when accuracy matters.
- [agents.md](references/agents.md) - read when choosing or wiring a model: default-model caveat, LiteLLM, native Azure client
- [tools.md](references/tools.md) - read when adding function tools, hosted tools, or agents-as-tools
- [structured-output.md](references/structured-output.md) - read when the output must be a Pydantic/dataclass shape (`AgentOutputSchema`, strict vs non-strict)
- [streaming.md](references/streaming.md) - read when streaming to a UI (event types, SSE with FastAPI)
- [handoffs.md](references/handoffs.md) - read when one agent delegates to another (handoff vs `as_tool`, input filters)
- [guardrails.md](references/guardrails.md) - read when validating input/output or gating tool calls
- [sessions.md](references/sessions.md) - read when conversation history must persist across requests (SQLite, SQLAlchemy, Redis, OpenAI Conversations)
- [patterns.md](references/patterns.md) - read for multi-agent pipelines, LLM-as-judge loops, tracing controls, `max_turns`, parallelization
- [sandbox.md](references/sandbox.md) - read when the agent must edit files or run commands in an isolated workspace (`SandboxAgent`, beta)
## Official Documentation
- **Docs:** https://openai.github.io/openai-agents-python/
- **Examples:** https://github.com/openai/openai-agents-python/tree/main/examples
- **Major update:** https://openai.com/index/the-next-evolution-of-the-agents-sdk/
- **Docs MCP setup:** https://developers.openai.com/learn/docs-mcp
- **Docs index (llms.txt):** https://developers.openai.com/api/docs/llms.txt
- **Current model IDs:** https://platform.openai.com/docs/models