Skip to content

3. Typed answers

Turn a messy recipe into clean JSON your code can use straight away. When a person reads the answer, prose is fine. When your code reads it, prose means parsing sentences, and that breaks. A schema makes the interaction answer in a shape you choose.

You’ll learn

  • how to describe the shape of an answer with a JSON Schema
  • how to make an interaction answer in that shape
  • how to read the answer into a typed value in TypeScript, Python and C#
  • why a field that may be missing should allow null

New concepts: structured output

Builds on: 2. Prompts and inputs

Time: ~15 min · Cost: under $0.01 on DeepSeek · Needs: nothing beyond Get started

The agent’s job is to copy facts out of text, not to be creative:

maitento/agents/extractor.mtr
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You turn messy text into clean, structured data. Never invent a value that is not in the text."
summary = "Extracts structured data from text"

“Never invent a value” matters here. You’ll see why when you break it.

The task takes the text as an input, as in tutorial 2:

maitento/prompts/extract-recipe-task.md
Extract the recipe from the text below.
%%text%%

The task doesn’t describe the shape of the answer. The schema does that.

The shape is a JSON Schema in json/:

maitento/json/recipe.json
{
"type": "object",
"properties": {
"title": { "type": "string" },
"servings": { "type": ["integer", "null"] },
"ingredients": {
"type": "array",
"items": {
"type": "object",
"properties": {
"item": { "type": "string" },
"quantity": { "type": ["string", "null"] }
},
"required": ["item", "quantity"],
"additionalProperties": false
}
},
"steps": { "type": "array", "items": { "type": "string" } }
},
"required": ["title", "servings", "ingredients", "steps"],
"additionalProperties": false
}

Three habits make a schema easy for a model to fill:

  • List every property under required. Your code can then rely on each one being there.
  • Allow null for anything the text may not say, such as servings. null is how the model says “not given”.
  • Set "additionalProperties": false, so there is nothing in the answer your code doesn’t expect.

schema.solution says what the final answer must look like:

maitento/interactions/one-shot/extract-recipe.mtr
agent = {
name = "Extractor"
agent = @ref("extractor")
}
task = @prompt("extract-recipe-task")
inputs.prompts = [
{
name = "text"
description = "Text that contains a recipe"
required = true
}
]
// The answer must match this JSON Schema, so your code can use it directly.
schema.solution = @json("recipe")

Like a prompt file, @json("recipe") is copied in when you deploy. Change the schema, deploy again.

Done when: your project has four files: agents/extractor.mtr, interactions/one-shot/extract-recipe.mtr, json/recipe.json and prompts/extract-recipe-task.md.

The run’s output is a JSON string that matches the schema. Each script declares the same shape as a type, reads the JSON into it, and uses the fields:

import { Maitento } from "@maitento/sdk";
// The same shape as ../maitento/json/recipe.json.
interface Recipe {
title: string;
servings: number | null;
ingredients: { item: string; quantity: string | null }[];
steps: string[];
}
const mt = new Maitento();
await mt.deploy("../maitento");
const text = "Grandma's pancakes (serves 4): whisk 200g flour, 2 eggs and 300ml milk. " +
"Rest 10 minutes, then fry thin pancakes in a little butter.";
const run = await mt.interactions.run("extract-recipe", { inputs: { text } });
// The output is JSON that matches the schema, so it can be used directly.
const recipe = JSON.parse(run.output!) as Recipe;
console.log(`${recipe.title} (serves ${recipe.servings ?? "?"})`);
for (const { item, quantity } of recipe.ingredients) console.log(`- ${quantity ?? ""} ${item}`.trim());

Run it from the language’s folder in examples/03-typed-answers:

cd examples/03-typed-answers/typescript
npm install
node recipe.ts

Done when: your code prints the recipe from the fields it read:

Grandma's pancakes (serves 4)
- 200g flour
- 2 eggs
- 300ml milk
- a little butter

The run’s output was this JSON. Nothing in your code had to understand a sentence:

{"title":"Grandma's pancakes","servings":4,"ingredients":[{"item":"flour","quantity":"200g"},{"item":"eggs","quantity":"2"},{"item":"milk","quantity":"300ml"},{"item":"butter","quantity":"a little"}],"steps":["Whisk 200g flour, 2 eggs and 300ml milk.","Rest 10 minutes, then fry thin pancakes in a little butter."]}
  • Constrained output: where the model supports it, the schema is sent with the request, so the model can only answer in that shape.
  • Checked every time: every answer is checked against the schema. If it doesn’t match, the model is told what is wrong and tries again, up to three times. If it still doesn’t match, the run ends with Error.

So a Finished run with a schema always has an output your code can read. More in Structured output.

On GitHub: examples/03-typed-answers · TypeScript · Python · C#

All the files in this project (4)
model = @ref("deepseek.deepseek-v4-flash")
prompt = "You turn messy text into clean, structured data. Never invent a value that is not in the text."
summary = "Extracts structured data from text"

Take away the model’s way of saying “not given”. In recipe.json, make servings a plain integer:

"servings": { "type": "integer" },

Then take (serves 4) out of the text in your script, and run it again. The answer still matches the schema, but the number is made up. One run printed this, and another (serves 0):

Grandma's pancakes (serves 1)

The text never says how many it serves, and the agent was told never to invent a value. But the schema demanded a whole number, so the model had to put one there. A schema is a promise your code relies on: make sure it’s a promise the text can keep. Put "null" back, and the same text gives (serves ?).

  • JSON file recipes not found (extract-recipe.mtr:14): a @json("...") name doesn’t match a file under json/. The name is the file name without .json.
  • The run ends Error: Unable to parse JSON result: the model’s answer didn’t match the schema after three tries. Check that the schema can be met: every name under required must also be under properties, and a field the text may lack must allow null.
  • C#: an empty title, then NullReferenceException: the JSON’s names are camelCase (title) and the record’s are PascalCase (Title), so nothing was read. Keep JsonSerializerDefaults.Web on the serializer context.
  • No namespace, Project folder not found: ../maitento, [api:model-keys:none-for-provider]: see tutorial 1.

You built:

  • an extractor that turns free text into JSON of a shape you chose;
  • a script that reads that JSON into a typed value, in TypeScript, Python and C#;
  • a schema that lets the model say “not given” instead of making something up.

The concept:

  • Structured output: schema.solution = @json("name") makes every finished answer match a JSON Schema. Maitento constrains the model where it can, checks every answer and retries up to three times.

Challenge: add a vegetarian field (true or false) to the schema, give it a description so the model knows what you mean, read it in your code, and print “vegetarian” after the title when it’s true. The solution is in examples/03-typed-answers/solution.

Next: 4. Runs starts a run without waiting for it, then checks on it.