A poorly specified json schema for function calling is the fastest way to get silent tool failures in production. Models will omit required parameters, guess enum values, or refuse to call entirely when the contract is ambiguous.
Step 1: Wrap your tool in the standard function object
The wire format for tools in the OpenAI-compatible API is a JSON object with type: "function" and a function field containing name, description, and parameters. The parameters field is a JSON Schema document. Keep additionalProperties: false at the top level; most inference servers enforce this anyway, and it prevents the model from inventing fields.
Function names must match ^[a-zA-Z0-9_-]{1,64}$. Use snake_case. The description is not optional filler—it is the primary instruction the model uses to decide whether to call your tool. Write one to two sentences that specify the trigger condition.
{
"type": "function",
"function": {
"name": "create_refund",
"description": "Issue a refund to a customer for a specific order. Call only when the user explicitly requests a refund and provides an order id.",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The order identifier, e.g. 'ord_12345'."
},
"amount": {
"type": "number",
"description": "Refund amount in major currency units (e.g. dollars)."
}
},
"required": ["order_id", "amount"],
"additionalProperties": false
}
}
}
That skeleton is valid. Every field you expose is a surface area for model error, so add fields only when the tool actually needs them.
Step 2: Type every parameter explicitly and constrain primitives
Never use type: "string" alone when you can narrow it. Use integer for counts, number for floats, and apply minimum, maximum, minLength, pattern where they matter. A model respects numeric bounds better than a prose description.
For money, prefer integer minor units (cents) over floating dollars to avoid rounding drift in downstream systems.
{
"type": "object",
"properties": {
"order_id": {
"type": "string",
"pattern": "^ord_[0-9a-z]+$",
"description": "Order identifier issued at checkout."
},
"amount_cents": {
"type": "integer",
"minimum": 1,
"maximum": 1000000,
"description": "Refund amount in cents (1/100 of USD)."
},
"reason_code": {
"type": "integer",
"enum": [1, 2, 3],
"description": "1=duplicate, 2=not_as_described, 3=other."
}
},
"required": ["order_id", "amount_cents"],
"additionalProperties": false
}
If you need a date, use format: "date-time" and document the timezone in the description. Do not rely on the model to infer ISO-8601; say it explicitly.
Step 3: Replace free-text fields with enums wherever possible
Enums are the highest-leverage construct in a json schema for function calling. They convert an open generation problem into a classification problem. The model will almost always pick a valid member. Order enum values by likelihood; some models exhibit position bias toward the first element.
{
"type": "object",
"properties": {
"channel": {
"type": "string",
"enum": ["email", "sms", "push"],
"description": "Delivery channel for the notification."
}
},
"required": ["channel"],
"additionalProperties": false
}
When an enum is too restrictive, use a closed string with pattern instead of leaving it fully open. For example, a country code field should be pattern: "^[A-Z]{2}$" rather than free text.
Step 4: Model nested structures with the same discipline
Arrays and objects are where schemas rot. Define items for every array. Define properties for every nested object, and set additionalProperties: false on those too. A model will fill nested objects if the shape is unambiguous.
{
"type": "object",
"properties": {
"recipient": {
"type": "object",
"properties": {
"name": { "type": "string" },
"email": { "type": "string", "format": "email" }
},
"required": ["email"],
"additionalProperties": false
},
"line_items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"sku": { "type": "string", "pattern": "^sku_[0-9]+$" },
"qty": { "type": "integer", "minimum": 1 }
},
"required": ["sku", "qty"],
"additionalProperties": false
}
}
},
"required": ["recipient", "line_items"],
"additionalProperties": false
}
If the array can be empty, omit minItems. If it must contain at least one element, set minItems: 1 as shown. Tuple validation (prefixItems) is not consistently supported across providers, so avoid it.
Step 5: Write descriptions that specify, not narrate
A description like “The amount” is useless. “Refund amount in cents, must be positive, excludes tax” is actionable. The model reads your descriptions as instructions. Include units, formats, and edge-case constraints. For boolean flags, state what true and false mean.
{
"type": "object",
"properties": {
"notify_customer": {
"type": "boolean",
"description": "True to send the customer an email receipt; false to suppress all outbound messages."
}
},
"additionalProperties": false
}
Avoid contradictions between description and type. If you say “optional” but list the field in required, the model will hesitate or hallucinate a value. Keep the required array as the single source of truth for mandatory inputs.
Step 6: Validate your schema before it reaches a model
Your schema is code. Lint it. Use the JSON Schema meta-schema or a library to catch missing required arrays or illegal keywords. In Python:
from jsonschema import Draft202012Validator
schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount_cents": {"type": "integer"}
},
"required": ["order_id", "amount_cents"],
"additionalProperties": False
}
Draft202012Validator.check_schema(schema)
print("schema is valid")
This catches structural mistakes before they become silent model errors. Add this check to your CI pipeline so a broken tool definition fails the build.
Step 7: Send the schema and parse the response
Use the OpenAI Python client (or any OpenAI-compatible endpoint). The tools parameter takes your function list. When the model calls the function, message.tool_calls[0].function.arguments is a JSON string that should validate against your schema.
from openai import OpenAI
client = OpenAI(base_url="https://api.openai.com/v1", api_key="sk-...")
tools = [{
"type": "function",
"function": {
"name": "create_refund",
"description": "Issue a refund to a customer for a specific order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "pattern": "^ord_[0-9a-z]+$"},
"amount_cents": {"type": "integer", "minimum": 1}
},
"required": ["order_id", "amount_cents"],
"additionalProperties": False
}
}
}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Refund ord_99 1250 cents"}],
tools=tools,
tool_choice="auto"
)
call = resp.choices[0].message.tool_calls
if not call:
raise SystemExit("model did not call the tool")
args = call[0].function.arguments
print(call[0].function.name, args)
If you route through n4n.ai, the same payload works against its OpenAI-compatible endpoint and the json schema for function calling is forwarded unchanged while the gateway handles provider fallback.
For deterministic tests, set tool_choice={"type": "function", "function": {"name": "create_refund"}} to force the call regardless of input.
Step 8: Verify the model output against the schema
Success means the arguments validate and required fields are present. Write a quick check:
import json
from jsonschema import validate
parsed = json.loads(args)
validate(instance=parsed, schema=tools[0]["function"]["parameters"])
assert set(tools[0]["function"]["parameters"]["required"]).issubset(parsed.keys())
print("tool call verified:", parsed)
Add negative tests: send prompts that lack required info and confirm the model either asks a clarifying question or omits the call. If it returns malformed arguments, tighten the schema.
Step 9: Iterate with real prompts, not toy ones
Test with messy input: typos, partial info, multi-intent requests. If the model omits a required field, either the description is weak or the field shouldn’t be required. Log every schema violation in staging. Over a week you will see which constraints the model fights.
A json schema for function calling is an interface contract. Treat it like one: version it, review it in PRs, and test it against the actual model you ship. The payoff is tool calls that work on the first try without retry loops.