A gpt-5 responses api agent gives you stateful, tool-using workflows without standing up your own orchestration server. This tutorial builds one from scratch against the OpenAI Responses API, showing the exact request shapes and the agent loop you need to run it in production.
Prerequisites
- Python 3.11+ (we use sync calls; async only appears in the streaming note).
openaiPython SDK >= 1.50.0. Earlier versions lack theresponsesnamespace.- A valid API key. If you route through n4n.ai, point the base URL at their OpenAI-compatible endpoint; the gateway addresses 240+ models and handles provider fallback when a backend is degraded, so a single
model="gpt-5"call won’t 429 you unexpectedly. - A local function to simulate a tool. We’ll fake a weather lookup.
pip install "openai>=1.50.0"
Mental model: items, not messages
In Chat Completions you juggle a list of messages. In Responses, you deal with an append-only item log. The input field accepts either a string (converted to a user item) or an explicit list. When you pass previous_response_id, the server prepends the entire prior item log automatically. Your local memory footprint is one ID, not a growing array.
That is a security win: you cannot accidentally truncate system instructions by rotating your own list. The model sees the same immutable history the gateway persisted.
Client initialization
The Responses API is served from /v1/responses. The OpenAI client abstracts this, but you must pass base_url if you aren’t using OpenAI directly.
from openai import OpenAI
client = OpenAI(
api_key="sk-...", # or your gateway key
# base_url="https://api.n4n.ai/v1", # uncomment if using the gateway
)
MODEL = "gpt-5"
Keep the model name in a constant. GPT-5 is a reasoned default for agentic loops because of its extended context and lower latency on tool rounds compared to the 4-class models.
Tool definition
The Responses API takes a tools array. Each tool is a JSON schema fragment. We define a get_weather function:
tools = [
{
"type": "function",
"name": "get_weather",
"description": "Return current temperature in Celsius for a city.",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name, e.g. 'Berlin'"}
},
"required": ["city"],
},
}
]
This is identical in shape to Chat Completions function calling, but the runtime handling differs: the model response contains structured output items instead of a tool_calls array buried in a message.
First stateless response
Fire a single call to see the shape. We ask the model to use the tool.
resp = client.responses.create(
model=MODEL,
input="What's the weather in Paris?",
tools=tools,
)
print(resp.model_dump_json(indent=2))
Expected output (truncated):
{
"id": "resp_01H...",
"model": "gpt-5",
"status": "completed",
"output": [
{
"type": "function_call",
"name": "get_weather",
"arguments": "{\"city\":\"Paris\"}",
"call_id": "call_abc123"
}
]
}
The function_call item is the signal to execute your local code. There is no assistant message to manage; the API returns exactly what the model wants to invoke.
Building the agent loop
A real gpt-5 responses api agent chains calls. You keep the previous_response_id from the last response and send tool outputs as input. The server stitches the conversation.
import json
def run_agent(user_input: str, previous_id: str | None = None) -> str:
inputs = [{"role": "user", "content": user_input}] if not previous_id else user_input
resp = client.responses.create(
model=MODEL,
input=inputs,
tools=tools,
previous_response_id=previous_id,
)
for item in resp.output:
if item.type == "function_call":
args = json.loads(item.arguments)
if item.name == "get_weather":
result = f"{args['city']} is 14C and cloudy"
else:
result = "unknown tool"
follow = client.responses.create(
model=MODEL,
input=[{
"type": "function_call_output",
"call_id": item.call_id,
"output": result,
}],
previous_response_id=resp.id,
)
for out in follow.output:
if out.type == "message":
return out.content[0].text
for out in resp.output:
if out.type == "message":
return out.content[0].text
return ""
Key detail: previous_response_id must be the ID of the response that produced the function_call, not the original user turn. The gateway reconstructs the full item list server-side, so you never resend the user prompt or prior tool calls.
End-to-end execution
Wire it up:
if __name__ == "__main__":
first = client.responses.create(
model=MODEL,
input="What's the weather in Paris?",
tools=tools,
)
print("first status:", first.status)
call = next(i for i in first.output if i.type == "function_call")
paris_result = '{"city":"Paris","temp":14,"sky":"cloudy"}'
second = client.responses.create(
model=MODEL,
input=[{"type":"function_call_output","call_id":call.call_id,"output":paris_result}],
previous_response_id=first.id,
)
final_msg = next(o for o in second.output if o.type=="message")
print("Agent says:", final_msg.content[0].text)
Checkpoint output after the second call:
{
"id": "resp_02...",
"previous_response_id": "resp_01...",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "It's 14°C and cloudy in Paris right now."}
]
}
]
}
That’s the entire gpt-5 responses api agent core. No thread objects, no polling, no cron cleanup.
Handling multi-step plans
GPT-5 will often chain two tools. Your loop must iterate until the output contains only message items:
def agent_turn(user_text, prev_id=None):
resp = client.responses.create(model=MODEL, input=user_text, tools=tools, previous_response_id=prev_id)
while any(i.type == "function_call" for i in resp.output):
for item in resp.output:
if item.type != "function_call":
continue
args = json.loads(item.arguments)
tool_out = execute_tool(item.name, args)
resp = client.responses.create(
model=MODEL,
input=[{"type":"function_call_output","call_id":item.call_id,"output":tool_out}],
previous_response_id=resp.id,
)
return resp.output[0].content[0].text
This tight loop is safe because the model decides termination; you just stop when no function_call remains. Cap iterations at 10 to avoid runaway schemas.
Validating tool arguments
Never trust item.arguments blindly. Wrap with pydantic:
from pydantic import BaseModel, ValidationError
class WeatherArgs(BaseModel):
city: str
def safe_parse(args_str: str):
try:
return WeatherArgs.model_validate_json(args_str)
except ValidationError:
return None
If safe_parse returns None, return a function_call_output with an error string. The model will self-correct on the next turn.
Streaming for UX
If you need token streaming, pass stream=True. The iterator yields response.output_text.delta events. The agent loop stays the same; you just accumulate deltas between tool rounds.
stream = client.responses.create(
model=MODEL,
input="Plan a trip to Lyon with weather checks",
tools=tools,
stream=True,
)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="")
Production notes
- Set
temperature=0for tool-heavy agents; nondeterminism wastes tool calls. - The Responses API honors
cache_controlon input items if your provider supports prompt caching. n4n.ai forwards those hints, so prefix static system context with{"cache_control": {"type": "ephemeral"}}to cut repeat token cost. - Always log
resp.idon every turn. It is your only forensic handle if a user disputes an agent action. - Use strict tool schemas (
strict: truein the tool dict) to force the model to emit only validated fields.
The gpt-5 responses api agent pattern is boring in the best way: a single endpoint, a list of items, and a while-loop. That’s all a stateful tool user needs.