Defining a Tool Schema
Schemas Are the Entire Interface
Lesson 1 introduced the shape of a tool definition at a high level. This lesson goes deeper into the parameters schema specifically, since it functions as the entire interface between the model and your function — the model never sees your function's actual Python signature, docstring, or implementation, only the JSON Schema you provide. Every piece of information the model needs to call a function correctly (what arguments exist, what type each one is, which are required, what values are valid) has to be encoded in that schema, because it is genuinely the only thing the model has to go on.
This is worth sitting with for a moment: a schema that under-specifies a parameter (a vague type, a missing description, no constraints on valid values) doesn't just produce worse documentation — it directly increases the chance the model sends arguments your function can't actually handle correctly, since the model is inferring your function's real requirements entirely from what the schema tells it.
Basic Types and When to Use Each
schema = {
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "The unique customer identifier, e.g. 'CUST-4471'"},
"order_count": {"type": "integer", "description": "Number of most recent orders to retrieve"},
"include_cancelled": {"type": "boolean", "description": "Whether to include cancelled orders in the results"},
"minimum_total": {"type": "number", "description": "Only include orders with a total at or above this amount"},
},
"required": ["customer_id"],
"additionalProperties": False,
}
Each JSON Schema primitive type maps onto a specific kind of value, and choosing the right one matters for the same reason Unit 6 emphasized precise typing for structured outputs: string for text values (names, identifiers, free-form text), integer for whole-number counts or quantities where a fractional value would be meaningless (you can't request 3.5 orders), number for values that can legitimately have a fractional component (a monetary amount, a measurement), and boolean for a genuine yes/no flag. Using string for something that is really a number (representing a quantity as "3" instead of 3) works in the sense that JSON accepts it, but it pushes a parsing and validation burden onto your function that a correctly typed schema would have handled automatically, and it invites the model to occasionally send malformed numeric strings that a proper integer or number type would have prevented entirely.
Required Versus Optional Parameters
schema = {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "description": "Temperature unit; defaults to celsius if not specified"},
},
"required": ["city"],
"additionalProperties": False,
}
Only city appears in required here, meaning the model can supply unit when the user's request makes it relevant ("what's the temperature in Boston in Fahrenheit") but can omit it otherwise, in which case your function's own default behavior takes over. This mirrors Unit 6, Lesson 2's required-versus-nullable distinction directly: a parameter should be required only when the function genuinely cannot do its job without it, and left optional when a sensible default exists — marking every parameter required regardless of whether it's actually needed forces the model to either guess a value it has no real basis for, or avoid calling the function at all when it can't confidently fill in every field, neither of which is a good outcome.
Note: Some SDK versions and strict-mode configurations for function tools require every property to be listed in
required, using a nullable type (["string", "null"]) to represent an optional value instead of omitting it fromrequired— mirroring the strict-mode behavior Unit 6, Lesson 2 described for structured outputs. Confirm the current behavior for function-calling schemas specifically against your installed SDK version's documentation, since this detail is a frequent source of confusion between structured-output schemas and function-calling schemas.
Enums for Constrained Choices
schema = {
"type": "object",
"properties": {
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "urgent"],
"description": "The priority level to assign to the new support ticket",
},
"category": {
"type": "string",
"enum": ["billing", "technical", "account", "other"],
},
},
"required": ["priority", "category"],
"additionalProperties": False,
}
Exactly as Unit 6, Lesson 1 argued for structured outputs, an enum is the right choice whenever a parameter's valid values form a fixed, known set, rather than leaving it as an unconstrained string and hoping the model happens to send one of the values your function actually recognizes. Without the enum constraint here, a create_support_ticket function might receive "High", "HIGH", "very high", or "urgent!!" for what should be one of exactly four values, forcing normalization logic into the function itself; with the enum constraint, the model is limited to the exact four strings listed, and that normalization problem simply does not arise.
Nested Objects and Arrays
Parameters aren't limited to flat, single-value fields — a function that needs a more complex argument shape can describe it with nested objects and arrays, the same way Unit 6 covered nested structures for output schemas.
schema = {
"type": "object",
"properties": {
"recipient": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name", "email"],
"additionalProperties": False,
},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"product_name": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1},
},
"required": ["product_name", "quantity"],
"additionalProperties": False,
},
},
},
"required": ["recipient", "line_items"],
"additionalProperties": False,
}
This schema describes a function like create_invoice(recipient, line_items), where recipient is itself a small object with its own required fields, and line_items is an array where every element must independently satisfy its own nested schema. Nested structures like this let a single function call carry as much structured information as the underlying operation genuinely needs — an invoice with multiple line items is a single real-world action, and modeling it as one function call with an array argument is more faithful to that reality than forcing the model to make one function call per line item, which would also lose the natural grouping of "these items belong to the same invoice."
Constraining String and Numeric Values Further
Beyond basic types and enums, JSON Schema supports additional constraints worth using whenever a parameter has a known valid range or format, narrowing the space of arguments a model can validly produce.
schema = {
"type": "object",
"properties": {
"zip_code": {"type": "string", "pattern": "^[0-9]{5}$", "description": "Five-digit US ZIP code"},
"age": {"type": "integer", "minimum": 0, "maximum": 120},
"discount_percentage": {"type": "number", "minimum": 0, "maximum": 100},
"confirmation_code": {"type": "string", "minLength": 6, "maxLength": 6},
},
"required": ["zip_code"],
"additionalProperties": False,
}
pattern applies a regular expression to a string field (useful for a known fixed format like a ZIP code, a product SKU, or a phone number), while minimum/maximum on numeric fields and minLength/maxLength on string fields bound values to a known valid range. As with the duration_minutes example in Lesson 1, these constraints are enforced before your function ever receives the arguments, meaning a schema with well-chosen constraints does part of your function's input validation for you — not a replacement for validating within the function itself (a point Lesson 5 returns to when discussing untrusted arguments), but a first line of defense that catches a meaningful share of malformed values for free.
Writing Descriptions That Actually Help
A property's description field is not optional documentation — it is one of the few places you can give the model context it has no other way to infer.
schema = {
"type": "object",
"properties": {
"date_range_start": {
"type": "string",
"description": "Start date in YYYY-MM-DD format. If the user says a relative date like 'last week', compute the actual calendar date based on today's date before calling this function.",
},
"sort_by": {
"type": "string",
"enum": ["date", "amount", "customer_name"],
"description": "Field to sort results by. Use 'date' unless the user explicitly asks for a different order.",
},
},
"required": ["date_range_start"],
"additionalProperties": False,
}
The date_range_start description here does more than name the field's format — it tells the model what to do with an ambiguous or relative input ("last week") before calling the function at all, which is a genuinely useful thing to communicate through the schema, since the model has no other channel for this kind of calling-convention guidance beyond the function's own top-level description and each parameter's description. Treating these description fields as a place to put real, functional guidance (not just a restatement of the field name) tends to produce noticeably more reliable function calls than leaving them as an afterthought.
A Complete, Realistic Tool Definition
Pulling the pieces above together, a well-specified real-world tool definition tends to combine several of these techniques at once.
search_orders_tool = {
"type": "function",
"name": "search_customer_orders",
"description": (
"Search a customer's order history. Use this whenever the user asks about "
"past orders, order status, or purchase history for a specific customer. "
"Requires the customer's ID; if you don't have it, ask the user for it "
"before calling this function."
),
"parameters": {
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "The unique customer identifier, e.g. 'CUST-4471'.",
},
"status": {
"type": "string",
"enum": ["pending", "shipped", "delivered", "cancelled", "any"],
"description": "Filter by order status. Use 'any' if the user doesn't specify a status.",
},
"max_results": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "Maximum number of orders to return. Default to 10 if the user doesn't specify a number.",
},
},
"required": ["customer_id", "status", "max_results"],
"additionalProperties": False,
},
}
Notice how the top-level description includes a piece of behavioral guidance ("if you don't have it, ask the user for it before calling this function") that goes beyond describing what the function does — it also shapes when the model should call it at all, addressing a realistic scenario where a user's request implies this function but doesn't supply every needed piece of information. This kind of guidance, placed directly in the schema the model actually sees, is more reliable than hoping the model infers the right behavior on its own.
Mapping Schema Types to Your Function's Actual Parameters
Since the schema is the model's only view into your function, it's worth keeping the schema and the actual Python function signature deliberately in sync — a mismatch between the two is a bug that won't surface until a real call is attempted, since nothing checks the schema against the function's signature automatically.
| JSON Schema | Typical Python equivalent | Notes |
|---|---|---|
"type": "string" | str | Also used for enums, patterns, dates encoded as strings |
"type": "integer" | int | Use for counts, quantities, anything without a fractional component |
"type": "number" | float (or int) | Use when fractional values are meaningful |
"type": "boolean" | bool | Genuine binary flags only |
"type": "array" | list | Pair with "items" describing the element schema |
"type": "object" | dict or a nested dataclass/model | Pair with "properties" and its own "required" |
Keeping this mapping explicit in mind while writing a schema — and re-checking it whenever the underlying function's signature changes — helps catch drift early: a function that gains a new required parameter needs its schema updated at the same time, or the model will keep generating calls missing that argument, and a parameter removed from the function needs the same field pulled from the schema, or the model may keep sending an argument the function silently ignores or errors on. Treating the schema as a piece of code that must stay synchronized with the function it describes, rather than a one-time description written once and forgotten, avoids a class of quiet integration bugs that are easy to miss during manual testing but surface as real failures once the tool is used against varied, unscripted user input.
Common Mistakes
Leaving every parameter as an unconstrained string, even for values that are really numbers, booleans, or one of a known fixed set, pushing type coercion and validation work onto the function that the schema could have handled automatically.
Writing descriptions that just restate the parameter name ("city: the city") rather than providing real guidance about format, valid values, or what to do in ambiguous cases.
Marking every parameter as required regardless of whether the function actually needs it, forcing the model to guess values for fields that have a perfectly good default, or avoid calling the function when it can't confidently fill in every field.
Omitting enum for a parameter with a small, fixed set of valid values, inviting inconsistent variations of what should be a small number of exact string values.
Best Practices
Choose the most specific applicable JSON Schema type for each parameter — integer over string for counts, enum over free-text string for fixed categorical choices, boolean for genuine yes/no flags — so the schema itself enforces as much correctness as possible before your function ever runs.
Use required deliberately, listing only the parameters the function genuinely cannot operate without, and documenting sensible defaults for the rest in their descriptions.
Write parameter and function descriptions as functional guidance, not just labels, including how to handle ambiguous input, what defaults to assume, and when the function should or shouldn't be called.
Apply value constraints (enum, pattern, minimum/maximum, minLength/maxLength) wherever a parameter has a known valid range or format, catching a meaningful share of malformed arguments at the schema level rather than relying entirely on validation inside the function.