AI agents

Schema-First Prompting: Design the Contract First

Antonio Evans8 min read

A split-screen scene: on the left, a clean JSON schema block with numbered fields and type annotations; on the right, three arrows fanning out from it, each pointing to a labeled panel: 'Prompt text', 'Examples'.
Key points

Key points

  1. 1Write the output schema before any prompt text, then derive instructions, examples and eval cases from it.
  2. 2Required fields, enums, types and constraints in the schema become mandatory sentences, allowed-value lists and test assertions.
  3. 3Structured output features enforce shape at generation time, but shape is not truth, so domain validation still runs after the model returns.
  4. 4Each field in the schema becomes at least one positive test and one negative test, so the contract and the test suite cannot drift apart.

Why the reverse order fails

The common sequence is: write a prompt, look at the output, notice it does not match what your code expects, then add a sentence to the prompt. Repeat. The failure is structural. When you describe the contract in prose, the model has to infer field names, types and optionality from natural language, and there is nothing stopping it from choosing a synonym. Practitioners hit this constantly: a field the application calls status comes back as current_state, and the parser breaks Agenta's structured output guide. The Vellum example is the canonical case: a prompt asking for JSON only still gets the object wrapped in Markdown backticks, because the model learned that JSON appears inside code fences Vellum.

Write the schema first and the ambiguity disappears. A JSON Schema is a vocabulary that describes data types, required fields, format constraints and validation rules, so it acts as a blueprint rather than a suggestion PromptLayer. Every decision the model would otherwise guess at is already made: the key is spelled status, its type is string, and its values come from a fixed list.

There is a second cost to the reverse order: retries. A validation failure means another API call, another chance to fail differently, and added latency in a pipeline that another program is waiting on. When the model is told the exact shape up front, most of those cycles never happen. Once you accept that, the schema stops being a validation artifact at the end of the pipeline and becomes the design document at the start.

Which mechanism matches your contract

Before writing prompt text, decide what kind of contract you are enforcing. The two structures look similar because both pass JSON schemas to the API, but they do different jobs.

Your situationUseWhy
Extract data from text, classify, or reshape input into a fixed objectStructured outputs (response format)Single-turn generation confined to the exact shape you defined Machine Learning Mastery
The model must choose among several external actionsFunction callingMulti-turn flow: choose tool, generate arguments, your code executes, result returns to the model
You need a strict enum for a category or routing decisionEither, with enum in the schemaThe sampler is forced to return one of the listed options nuric Blog
Syntax valid but shape unconstrainedNeither: JSON mode alone is not enoughValid JSON does not prove required fields, enums or object shape match TrueFoundry

The decision rule: if the model has all the information it needs and only has to reshape it, use structured outputs with a response format. If the model has to pick an action or collect parameters across turns, use function calling, which is structured output with consequence Machine Learning Mastery. Vellum's guidance matches: reach for function calling when external API requests, tool selection or interactive parameter collection are involved, and use the response format when the assistant's reply is the last step in the pipeline Vellum. One practical wrinkle: provider support differs, and Gemini has historically exposed structured output through function calling rather than a standalone response format Vellum. That matters if you route the same contract across several vendors.

Step 1: write the schema as a contract, not a wish list

Start with the object your downstream code will actually consume. Design for the parser, not for the model. A workable extraction schema for a support ticket looks like this:

{
  "type": "object",
  "properties": {
    "category": {
      "type": "string",
      "enum": ["billing", "shipping", "technical", "account"],
      "description": "The single best-fitting support category."
    },
    "anger_level": {
      "type": "integer",
      "minimum": 0,
      "maximum": 5,
      "description": "0 for calm, 5 for explicit demand for a manager."
    },
    "should_escalate": { "type": "boolean" },
    "summary": {
      "type": "string",
      "description": "One sentence, under 25 words, in the customer's language."
    }
  },
  "required": ["category", "anger_level", "should_escalate", "summary"],
  "additionalProperties": false
}

Four rules make a schema prompt-ready. Keep it simple, because complex and restrictive schemas raise the chance of an invalid response nuric Blog. Put natural language in the description field of every property, since descriptions travel with the schema and are read as instructions Agenta. Pick enum values close to how the model already talks, then map them to your internal taxonomy after parsing, rather than forcing esoteric labels nuric Blog. And break genuinely nested contracts into smaller schemas called in sequence, so the model focuses on one object at a time nuric Blog.

If you write models in Python, generate the schema from a Pydantic model instead of hand-writing JSON. BaseModel.model_json_schema() returns a JSON-schema-compliant dict, and the docs show how defaults, descriptions and constraints land in the output Pydantic docs. Field docstrings and Field(description=...) become schema descriptions automatically, which is exactly the text you want the model to read.

One portability check: strict modes accept only a subset of JSON Schema. Optional fields, unions and numeric constraints like minimum/maximum can be rejected by some strict implementations and have to be rewritten or moved into descriptions LangChain forum. Test your schema against the provider before you build prompt text on top of it.

Step 2: derive the instructions from the schema

Now write the prompt as a translation of the schema, not as a fresh description of the task. The model's job is to fill fields, so the instructions should read field by field.

A worked derivation from the ticket schema above:

Schema elementGenerated instruction
required: ["category", ...]"You must return every one of these fields: category, anger_level, should_escalate, summary."
enum on category"category must be exactly one of: billing, shipping, technical, account."
anger_level integer 0 to 5"anger_level is a whole number from 0 to 5, never a word."
should_escalate: boolean"should_escalate is true or false, never a string."
summary description"summary is one sentence under 25 words, in the customer's language."
additionalProperties: false"Do not add fields that are not listed."

Notice what drops out. You no longer need to write "respond with JSON only, nothing before nothing after" and hope. Provider documentation makes the point directly: with structured outputs there is no need for strongly worded formatting prompts, because the schema does that work OpenAI structured outputs guide. The prompt's remaining job is semantic: define what good values look like, not what the container looks like. That split is the core insight of the workflow. Formatting is the schema's problem; judgment is the prompt's problem.

For a function-calling contract the derivation shifts slightly. The tool name and parameter descriptions carry the meaning, and the required list tells the model which arguments are non-negotiable, as in the classic weather tool where location is required and unit is an enum Prompt Engineering Guide. Write tool descriptions the way you would write the first sentence of a docstring, then write the prompt to say when the tool should not be used.

Step 3: derive positive and negative examples

Examples are cheapest to generate when the schema already exists, because you can copy permitted values into a filled example instead of inventing them. The guidance is blunt: provide examples of expected output where you can, since they improve the chance of a valid response nuric Blog. Do that, but generate both directions.

Positive example, derived from the schema. For the input "This is the third time my order has not arrived and I want a manager," the expected object is {"category": "shipping", "anger_level": 5, "should_escalate": true, "summary": "Customer reports a third missing delivery and demands a manager."}. Every value is legal under the schema: the enum is satisfied, the integer is inside 0 to 5, no extra keys appear.

Negative examples, also derived from the schema. Each one breaks exactly one constraint, which makes failures readable in the eval report:

  • {"category": "delivery", ...} violates the enum.
  • {"anger_level": "very angry", ...} violates the integer type.
  • {"anger_level": 7, ...} violates the maximum.
  • An object missing should_escalate violates required.
  • An object with an extra customer_name key violates additionalProperties: false.

The structural inversion is the point. In prompt-first development, test cases come from whatever the model happened to produce, so coverage is accidental. In schema-first development, each constraint generates a test by construction, and a schema change that adds a field or tightens a bound immediately implies new tests.

Step 4: generate the eval suite from the schema

Turn each field and constraint into an assertion. The suite then checks the contract rather than a vibe.

schema = load_schema("ticket.json")

def check_structure(obj):
    errors = []
    for field in schema["required"]:
        if field not in obj:
            errors.append(f"missing required field: {field}")
    for field, spec in schema["properties"].items():
        value = obj.get(field)
        if value is None:
            continue
        if "enum" in spec and value not in spec["enum"]:
            errors.append(f"{field} not in enum: {value}")
        if spec["type"] == "integer" and not isinstance(value, int):
            errors.append(f"{field} not an integer: {value!r}")
        if "maximum" in spec and isinstance(value, int) and value > spec["maximum"]:
            errors.append(f"{field} above maximum: {value}")
    extra = set(obj) - set(schema["properties"])
    if schema.get("additionalProperties") is False and extra:
        errors.append(f"unexpected fields: {sorted(extra)}")
    return errors

Run it over a fixed set of inputs that covers each field: one case where the enum value is obvious, one where two categories are close, one empty or hostile input, one case that should trigger escalation and one that should not. Record the structural errors, the retry rate and the validation failures per case.

A worked calculation shows why this matters. Suppose a pipeline processes 10,000 documents a day and the prompt-first version fails structure validation on 3 percent of calls, needing one retry each. That is 300 retries a day, plus the downstream failures when a malformed object slips through a loose parser. If schema-first prompting with strict output cuts structural failures to near zero at generation time, the 300 retries vanish from the queue and the remaining failures are semantic ones, which is where your human review time should go. Use your own measured failure rate in place of 3 percent; the decision rule is that any schema constraint with no corresponding test is a retry waiting to happen.

Then validate what the schema cannot see. Shape is not truth: a schema-valid customer ID, amount or date can still violate a domain rule TrueFoundry. So keep a second layer for business rules, such as "a billing ticket cannot have anger_level 5 and should_escalate false," and assert those in the same eval run.

Step 5: wire the schema into the runtime

Pass the schema to the API as the response format and parse the result into the typed object, then validate again on your side. The OpenAI Python helper accepts a Pydantic class and returns a parsed object, so the contract you designed is the object your code receives OpenAI structured outputs guide. For tools, register the function with its parameter schema and let the framework convert your Python signature into that schema automatically OpenAI Agents SDK.

Three runtime rules keep the loop honest. First, fix parsing errors by editing the schema and regenerating the prompt, never by patching the output with regex. Second, log the structural errors from your checker with the input that produced them, so the eval suite grows from real traffic. Third, re-run the whole eval suite whenever the schema changes, because a new field changes both the prompt and the tests. If the same contract has to run across several providers, a gateway that accepts one model interface and keeps the response format consistent removes a class of portability work; Quanta Inference exposes text, image and embedding models through one OpenAI-compatible API at https://quantaintelligence.ai/inference, which keeps the schema and the eval suite stable while models underneath change.

Finally, measure two numbers per release: schema-pass rate and retry rate. If the first rises, your contract wording is working. If the second stays flat while the first rises, the failures have moved from structure to semantics, and the fix belongs in descriptions and examples, not in stricter output constraints.

FAQ

FAQ

Why write the schema before the prompt instead of after?

Because the schema removes the guesses that cause retries: field names, types and optionality. When the contract is prose, the model can rename a field or wrap JSON in code fences, and the parser breaks Agenta. When the contract is a schema, the same constraints are enforced at generation time. The prompt then only has to explain what good values look like.

What is the difference between structured outputs and function calling here?

Structured outputs constrain a single reply to your schema, while function calling lets the model pick a tool and produce arguments that your code then executes, in a multi-turn loop Machine Learning Mastery. Use structured outputs when the model just reshapes information you already provided. Use function calling when the model must choose an action or gather parameters across turns.

Can I generate the schema from Pydantic instead of writing JSON by hand?

Yes, and it is usually safer. model_json_schema() produces a JSON Schema compliant dict, and field descriptions and constraints flow into the schema automatically Pydantic docs. Check the generated output against your provider's strict-mode subset before shipping, because constructs like optionals, unions and numeric constraints are sometimes rejected and must be rewritten LangChain forum.

How many test cases does a schema need?

At least one positive case per field and one negative case per constraint. A schema with four fields, one enum, one integer range and a required list implies roughly a dozen assertions before you add domain rules. Write them by hand once, then automate the generation from the schema so adding a field forces adding its test.

Does a schema-valid response mean the data is correct?

No. Valid JSON is not a contract, and a strict schema narrows shape without guaranteeing truth, so a schema-valid customer ID or amount can still violate business rules TrueFoundry. Keep a validation layer after generation for domain rules, and put the structural checker and the business checker in the same eval pipeline so both numbers are visible.

Sources

Sources

  1. The guide to structured outputs and function calling with ...
  2. When should I use function calling, structured outputs or JSON mode?
  3. How JSON Schema Works for LLM Tools & Structured ...
  4. Structured Outputs vs. Function Calling: Which Should Your Agent ...
  5. Structuring LLM Responses with JSON Schemas | nuric Blog
  6. LLM Structured Outputs with JSON Schema
  7. JSON Schema | Pydantic Docs
  8. Add JSON Schema Sanitizer for OpenAI Structured Outputs ...
  9. Structured model outputs | OpenAI API
  10. Function Calling with LLMs - Prompt Engineering Guide
  11. Function schema - OpenAI Agents SDK
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles