n4nAI

How to define tool schemas for GPT-4o and Claude

Learn to define tool schemas for GPT-4o and Claude with step-by-step code examples, parameter validation, and cross-provider compatibility patterns.

n4n Team3 min read727 words

Audio narration

Coming soon — every post will get a voice note here.

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: False prevents the model from inventing fields
  • enum on unit constrains output to valid values
  • default values 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: true call succeeds and returns parseable arguments
  • Claude call succeeds and tool_use.input passes validation
  • Round-trip produces a coherent final answer on both providers
  • Tool registered in TOOL_REGISTRY with 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.

Tagstool-schemagpt-4oclaudehow-to

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All function calling & tool use posts →