When you define tool schema for GPT-4o or Claude, you’re writing the contract between your application and the model. A sloppy schema produces hallucinated arguments, silent failures, or worse — actions that execute with wrong parameters. This guide walks through the complete lifecycle: designing the JSON Schema, wiring it to each provider’s API, handling edge cases, and verifying the integration works end to end.
Step 1: Design the JSON Schema contract
Both OpenAI and Anthropic expect a JSON Schema subset (draft 2020-12) describing the function’s name, description, and parameters. Start with a single source of truth in your codebase — a Python dict or TypeScript object — then serialize it for each provider.
# tools/schemas.py
from typing import Any
GET_WEATHER_SCHEMA: dict[str, Any] = {
"name": "get_weather",
"description": "Get current weather for a location. Use when users ask about temperature, conditions, or forecasts.",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "City and state/country, e.g., 'San Francisco, CA' or 'London, UK'",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "Temperature unit. Defaults to fahrenheit for US locations.",
"default": "fahrenheit",
},
"include_forecast": {
"type": "boolean",
"description": "Whether to include a 3-day forecast.",
"default": False,
},
},
"required": ["location"],
"additionalProperties": False,
},
}
Key decisions in this schema:
additionalProperties: Falseprevents the model from inventing fieldsenumonunitconstrains output to valid valuesdefaultvalues let the model omit optional params safely- Descriptions are written for the model, not humans — be specific about format and when to call
Step 2: Register the tool with GPT-4o
OpenAI’s Chat Completions API accepts a tools array where each entry wraps your schema in a function object. The strict: true flag (available on gpt-4o-2024-08-06 and later) enables structured output enforcement — the model will never return invalid arguments.
# tools/openai_adapter.py
from openai import OpenAI
from tools.schemas import GET_WEATHER_SCHEMA
client = OpenAI()
def call_gpt4o_with_tools(messages: list[dict], tools: list[dict] = None) -> dict:
if tools is None:
tools = [{"type": "function", "function": GET_WEATHER_SCHEMA, "strict": True}]
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=messages,
tools=tools,
tool_choice="auto", # or {"type": "function", "function": {"name": "get_weather"}}
temperature=0.1,
)
return response.choices[0].message
Verification: send a message like "What's the weather in Tokyo?" and inspect message.tool_calls. The function.arguments string should parse as valid JSON matching your schema exactly. If strict: true is set and the model violates the schema, the API returns a 400 error — catch this and retry.
Step 3: Register the tool with Claude
Anthropic’s Messages API uses a tools array with a slightly different envelope. The schema lives under input_schema, and there’s no strict flag — validation is your responsibility. Claude also expects tool_choice as an object, not a string.
# tools/anthropic_adapter.py
import anthropic
from tools.schemas import GET_WEATHER_SCHEMA
client = anthropic.Anthropic()
def call_claude_with_tools(messages: list[dict], tools: list[dict] = None) -> anthropic.types.Message:
if tools is None:
tools = [{
"name": GET_WEATHER_SCHEMA["name"],
"description": GET_WEATHER_SCHEMA["description"],
"input_schema": GET_WEATHER_SCHEMA["parameters"],
}]
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=messages,
tools=tools,
tool_choice={"type": "auto"},
temperature=0.1,
)
return response
Verification: check response.content for blocks with type == "tool_use". The input field is already a parsed dict (not a JSON string). Validate it against your schema before executing — Claude can and will hallucinate extra fields or wrong types.
Step 4: Normalize tool calls across providers
Your application logic shouldn’t care which provider generated the call. Write a normalizer that extracts a consistent (name, arguments) tuple.
# tools/normalizer.py
from typing import Any
import json
def normalize_tool_call(message: Any, provider: str) -> list[tuple[str, dict]]:
"""
Returns list of (tool_name, arguments_dict) from a provider response message.
"""
calls = []
if provider == "openai":
if message.tool_calls:
for tc in message.tool_calls:
args = json.loads(tc.function.arguments)
calls.append((tc.function.name, args))
elif provider == "anthropic":
for block in message.content:
if block.type == "tool_use":
calls.append((block.name, block.input))
return calls
Now your executor receives plain Python dicts:
# tools/executor.py
from tools.schemas import GET_WEATHER_SCHEMA
import jsonschema
def execute_tool(name: str, arguments: dict) -> dict:
if name != GET_WEATHER_SCHEMA["name"]:
raise ValueError(f"Unknown tool: {name}")
# Validate before execution — defense in depth
jsonschema.validate(arguments, GET_WEATHER_SCHEMA["parameters"])
# Your actual implementation here
location = arguments["location"]
unit = arguments.get("unit", "fahrenheit")
include_forecast = arguments.get("include_forecast", False)
return {
"location": location,
"temperature": 72 if unit == "fahrenheit" else 22,
"condition": "sunny",
"forecast": [{"day": "tomorrow", "high": 75, "low": 60}] if include_forecast else None,
}
Step 5: Handle the tool result round-trip
After executing, feed the result back to the model. Both providers expect a specific message format.
# tools/roundtrip.py
def build_tool_result_messages(
tool_calls: list[tuple[str, dict]],
results: list[dict],
provider: str,
) -> list[dict]:
messages = []
for (name, args), result in zip(tool_calls, results):
if provider == "openai":
messages.append({
"role": "tool",
"tool_call_id": f"call_{hash(str(args)) % 1000000}", # placeholder; use real ID from response
"name": name,
"content": json.dumps(result),
})
elif provider == "anthropic":
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": f"toolu_{hash(str(args)) % 1000000}",
"content": json.dumps(result),
}],
})
return messages
Note: in production, capture the actual tool_call_id / tool_use_id from the model’s response and pass it through your executor. The placeholder above works for demos but will break multi-turn conversations.
Step 6: Validate end-to-end with a test harness
Write a single test that exercises both providers against the same schema and asserts the contract holds.
# tests/test_tool_schema.py
import pytest
from tools.openai_adapter import call_gpt4o_with_tools
from tools.anthropic_adapter import call_claude_with_tools
from tools.normalizer import normalize_tool_call
from tools.executor import execute_tool
from tools.roundtrip import build_tool_result_messages
@pytest.mark.parametrize("provider,call_fn", [
("openai", call_gpt4o_with_tools),
("anthropic", call_claude_with_tools),
])
def test_weather_tool_end_to_end(provider, call_fn):
messages = [{"role": "user", "content": "What's the weather in Denver, CO in celsius?"}]
# Step 1: model decides to call tool
response = call_fn(messages)
tool_calls = normalize_tool_call(response, provider)
assert len(tool_calls) == 1
name, args = tool_calls[0]
assert name == "get_weather"
assert args["location"] == "Denver, CO"
assert args["unit"] == "celsius"
# Step 2: execute
result = execute_tool(name, args)
assert result["temperature"] == 22 # celsius
# Step 3: feed back and get final answer
followup_messages = messages + [
{"role": "assistant", "content": response.content} if provider == "anthropic" else response.model_dump()
] + build_tool_result_messages(tool_calls, [result], provider)
final_response = call_fn(followup_messages)
final_text = final_response.content[0].text if provider == "anthropic" else final_response.content
assert "22" in final_text or "celsius" in final_text.lower()
Run with pytest -v tests/test_tool_schema.py. Both providers should pass. If one fails, the schema or adapter has a mismatch — fix the source schema, not the test.
Step 7: Version and evolve schemas safely
Tools change. Add a version field to your schema name and keep old versions registered.
# tools/registry.py
from tools.schemas import GET_WEATHER_SCHEMA
from tools.openai_adapter import call_gpt4o_with_tools
from tools.anthropic_adapter import call_claude_with_tools
TOOL_REGISTRY = {
"get_weather:v1": GET_WEATHER_SCHEMA,
}
def get_tool_schema(name: str) -> dict:
if name not in TOOL_REGISTRY:
raise KeyError(f"Tool not found: {name}. Available: {list(TOOL_REGISTRY.keys())}")
return TOOL_REGISTRY[name]
def build_tools_array(provider: str, tool_names: list[str]) -> list[dict]:
tools = []
for name in tool_names:
schema = get_tool_schema(name)
if provider == "openai":
tools.append({"type": "function", "function": schema, "strict": True})
elif provider == "anthropic":
tools.append({
"name": schema["name"],
"description": schema["description"],
"input_schema": schema["parameters"],
})
return tools
When you need to add a humidity field, create get_weather:v2 with the new schema. Deploy both. Migrate callers gradually. Delete v1 after traffic hits zero.
Common pitfalls and how to avoid them
Pitfall: trusting the model’s output without validation.
Claude does not enforce additionalProperties: False. Always validate with jsonschema before executing. OpenAI’s strict: true helps but isn’t a substitute — network errors, timeouts, or proxy layers can still deliver malformed calls.
Pitfall: mismatched tool_choice formats.
OpenAI accepts "auto" | "none" | {"type": "function", "function": {"name": "..."}}. Anthropic requires {"type": "auto"} | {"type": "any"} | {"type": "tool", "name": "..."}. Normalize at the call site.
Pitfall: forgetting tool_call_id / tool_use_id in multi-turn conversations.
The model matches results to calls by ID. If you generate fake IDs on the follow-up, the model gets confused and may hallucinate. Store the real IDs from the initial response.
Pitfall: schema drift between providers.
Keep one source of truth (Step 1). Generate provider-specific envelopes programmatically (Step 7). Never hand-edit two copies.
Verification checklist
Before merging a new tool:
- Schema validates against JSON Schema draft 2020-12
-
jsonschema.validate()passes for 10+ realistic argument sets - OpenAI
strict: truecall succeeds and returns parseable arguments - Claude call succeeds and
tool_use.inputpasses validation - Round-trip produces a coherent final answer on both providers
- Tool registered in
TOOL_REGISTRYwith versioned name - Integration test added to
test_tool_schema.py
Defining tool schemas is not a one-time task — it’s an API design discipline. Treat schemas like public contracts: version them, test them, and validate at runtime. The upfront investment pays off every time a model calls your code correctly on the first try.