Getting gpt-5 function calling structured outputs right is the difference between a demo agent and one that survives production traffic. The feature lets you bind strict JSON Schemas to both tool calls and tool responses, so the model’s output matches your agent’s expectations without ad-hoc parsing. Treat the schema as a compile-time check for your prompts, not a nice-to-have.
Step 1: Define strict tool schemas
Strict mode in GPT-5 function calling requires that every parameter object sets additionalProperties: false and marks all properties as required. The model cannot invent fields, and it must satisfy the type constraints you declare. This eliminates the classic “sometimes it returns a string, sometimes an object” bug that plagues loosely typed agents.
Write one schema per tool. Keep descriptions precise—they are part of the contract. If a field is genuinely optional, model it as a union with null rather than omitting it from required.
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Fetch current weather for a city. Call before giving packing advice.",
"strict": True,
"parameters": {
"type": "object",
"additionalProperties": False,
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'Boston'"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"]
},
"days": {
"type": ["integer", "null"],
"description": "Forecast horizon, null for current conditions"
}
},
"required": ["city", "unit", "days"]
}
}
}
]
Nested objects work the same way: declare them with their own additionalProperties: false and full required lists. Deeply nested strict schemas are honored, but keep your depth reasonable—both for latency and for the model’s ability to fill them correctly.
Step 2: Call the model with tools and strict mode
Use any OpenAI-compatible client. If you front requests with n4n.ai, the gateway honors your routing directives and forwards provider cache-control hints, so you can pin GPT-5 and still get automatic fallback when the upstream is degraded. The call itself is unchanged from earlier OpenAI versions.
from openai import OpenAI
client = OpenAI(base_url="https://api.openai.com/v1", api_key="sk-...")
messages = [
{"role": "system", "content": "You are a travel agent that uses tools."},
{"role": "user", "content": "What should I pack for Boston tomorrow?"}
]
resp = client.chat.completions.create(
model="gpt-5",
messages=messages,
tools=tools,
tool_choice="auto"
)
tool_choice="auto" lets the model decide. If you need to force a specific tool, pass {"type": "function", "function": {"name": "get_weather"}}. GPT-5 may emit parallel tool calls in a single message; each entry in tool_calls is independently schema-validated under strict mode.
Step 3: Parse and execute the function
Extract the call, run your local code, and append the result. Strict mode means the arguments already conform, but a local assertion catches schema drift during deployments.
import json
msg = resp.choices[0].message
if msg.tool_calls:
call = msg.tool_calls[0]
args = json.loads(call.function.arguments)
# args is guaranteed: city str, unit enum, days int|null
weather = get_weather(args["city"], args["unit"], args.get("days"))
messages.append(msg)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps({
"temp": weather["temp"],
"cond": weather["cond"],
"city": args["city"]
})
})
else:
# no tool call yet, handle as plain response
pass
Wrap get_weather in try/except. A tool failure should return a structured error string, not raise, so the model can recover in the next iteration.
Step 4: Return structured tool results and force final shape
Tool results travel as strings, but you control their JSON shape. After the tool message, ask GPT-5 for a typed final answer using response_format with a JSON Schema. This closes the loop with a contract instead of free text.
final_schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"packing_list": {
"type": "array",
"items": {"type": "string"}
},
"reasoning": {"type": "string"},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["packing_list", "reasoning", "confidence"]
}
resp2 = client.chat.completions.create(
model="gpt-5",
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "packing", "schema": final_schema, "strict": True}
}
)
final = json.loads(resp2.choices[0].message.content)
Now final["packing_list"] is a list of strings and final["confidence"] is bounded. No scraping, no guesswork.
Step 5: Implement the agent loop
Combine the steps into a bounded loop. Stop when the model returns no tool calls and you have a structured final answer.
def run_agent(user_prompt, max_iter=5):
messages = [
{"role": "system", "content": "You are a travel agent that uses tools."},
{"role": "user", "content": user_prompt}
]
for _ in range(max_iter):
resp = client.chat.completions.create(
model="gpt-5",
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = resp.choices[0].message
if not msg.tool_calls:
resp = client.chat.completions.create(
model="gpt-5",
messages=messages,
response_format={
"type": "json_schema",
"json_schema": {"name": "packing", "schema": final_schema, "strict": True}
}
)
return json.loads(resp.choices[0].message.content)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = dispatch(call.function.name, args)
messages.append(msg)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result)
})
raise RuntimeError("agent did not converge")
Handling multiple tools
Add more entries to tools. The dispatch function switches on call.function.name. Each tool’s strict schema isolates its arguments. You never need a central validator that guesses which fields belong to which tool.
Step 6: Verify success with contract tests
Write tests that mock the model and assert your code enforces the contracts. Use jsonschema to validate both outgoing arguments and the final answer.
import jsonschema
import pytest
def test_final_schema_valid():
sample = {
"packing_list": ["coat", "boots"],
"reasoning": "Cold and wet in Boston",
"confidence": 0.9
}
jsonschema.validate(sample, final_schema)
def test_tool_args_valid():
args = {"city": "Boston", "unit": "fahrenheit", "days": 1}
jsonschema.validate(args, tools[0]["function"]["parameters"])
def test_agent_loop_mock(monkeypatch):
# monkeypatch client.chat.completions.create to return canned responses
# assert run_agent returns valid final schema within max_iter
...
Run pytest. If a schema changes upstream, the test fails before the agent reaches users.
Verification in production
Success means three things: (1) every tool call validates against its schema with zero exceptions, (2) the agent terminates with a valid response_format object inside the iteration cap, (3) no additionalProperties appear in logs or traces. Add a counter for schema violations—strict mode should keep it at zero.
Pitfalls we’ve hit
Strict mode rejects nullable unless you explicitly list "null" in the type array. A plain {"type": "string"} cannot be omitted; make it {"type": ["string", "null"]} and keep it required.
Vague tool descriptions produce correct-shaped but semantically useless arguments. “Get data” yields a city named “data”. Write descriptions that state preconditions and units.
Tool results as strings lose type safety on the wire. If you need structured tool returns, define a schema for the content and parse it on the way back, but keep the transport JSON.
Parallel tool calls can duplicate side effects. Make get_weather idempotent; the model may call it twice in one turn.
Operating in production
When you route through n4n.ai, per-token usage metering lets you attribute cost per agent run while automatic fallback covers provider outages. That matters because a single GPT-5 outage should not stall your agent loop—the gateway shifts to a healthy endpoint and your strict schemas stay compatible across providers that support the same contract.
Ship the agent with the loop capped, schemas versioned, and tests in CI. GPT-5 function calling structured outputs move the validation burden from your runtime to the model boundary, which is exactly where it belongs.