Skip to content

Give an agent a typed answer

View as Markdown

This guide has an agent answer with JSON that matches a schema, and reads its fields in your app and in a flow.

An agent's answer is text unless you give it an output schema. With one, the final answer must be JSON that matches the schema. Kindgi checks it before the turn completes, and your code reads the fields.

  • A pack on a runtime such as kindgi dev, with Kindgi 0.1's CLI.
  • A registered model, as Models shows. dev-echo only repeats the message, so it can't triage a ticket.
  • For the app's half: Node 22.12 or later, Python 3.11 or later, or Java 17 or later, with Kindgi 0.1's client, and KINDGI_API_URL and KINDGI_API_TOKEN set.

An output schema says what the answer must be. Ask your coding agent: "I want an agent that triages support tickets and answers with JSON my code can read." Kindgi's skills in your pack tell it how (How it knows Kindgi). For reference, the agent looks like this. This guide calls it acme.ticket-triage:

agents/ticket-triage/index.ts
import { defineAgent } from '@kindgi/sdk/define';
import { z } from 'zod';
export const Triage = z.object({
category: z.enum(['billing', 'shipping', 'returns', 'other']),
priority: z.enum(['low', 'normal', 'urgent']),
summary: z.string(),
});
const defined = defineAgent({
id: 'acme.ticket-triage',
version: '0.1.0',
name: 'Ticket triage',
instructions: [
'You triage support tickets for Acme. Reply with only a JSON object:',
'{"category": "billing" | "shipping" | "returns" | "other",',
' "priority": "low" | "normal" | "urgent",',
' "summary": "<one sentence>"}',
].join('\n'),
capabilities: [{ needs: [] }],
tools: [],
retrieval: [],
guardrails: [],
output: { schema: Triage, name: 'triage' },
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;

schema takes a Zod schema or a JSON Schema object.

  • schema is what the answer must match.
  • name names the answer in what the model is told when it gets it wrong, and in errors. It's output by default.
  • maxRepairs is how many times a wrong answer goes back to the model, 1 by default, as When the answer doesn't fit describes.
  • capabilities: [{ needs: [] }] lets any registered model answer: this agent calls no tools.

The model is sent the schema from its first call. The system message ends with it, in the words a repair repeats, and tells the model to call the tools it needs first. So its first answer can already fit. Describing the JSON in the instructions, as this agent does, still helps the model fill the fields well.

The schema goes with every model call, its descriptions included, so every call's prompt tokens count it. Keep it, and its descriptions, to what the answer needs.

From a local model, Qwen 2.5 7B through Ollama, with instructions that don't describe the JSON, the first call's system message ends:

…"properties":{"summary":{"type":"string","description":"One sentence, for the queue"},"category":{"enum":["billing","shipping","returns","other"],"type":"string","description":"What the message is about"},"priority":{"enum":["low","normal","urgent"],"type":"string","description":"How soon it needs a person"}},"additionalProperties":false}
Call the tools you need first; then give your final answer as that JSON alone.
Excerpt

and its first answer fits, in one model call:

{
"summary": "Request for refund of duplicate charge for order A-1001 urgently needed today",
"category": "billing",
"priority": "urgent"
}

A run of the agent returns the parsed answer beside the model's text:

Terminal window
pnpm exec kindgi runs start --agent=acme.ticket-triage --input='{"userMessage":"I was charged twice for order A-1001 and I need the money back today."}'

--agent is the agent to run, and --input the turn's input, with the ticket as userMessage. It prints the run:

{
…
"status": "completed",
…
"output": {
…
"usage": { "steps": 1, "durationMs": 2448, "promptTokens": 88, "totalCostUsd": 0.000343, "completionTokens": 51 },
"output": {
"summary": "Customer was double-charged for order A-1001 and requests immediate refund.",
"category": "billing",
"priority": "urgent"
},
…
"response": {
"role": "agent",
"actor": "acme.ticket-triage",
"content": "```json\n{\n \"category\": \"billing\",\n \"priority\": \"urgent\",\n \"summary\": \"Customer was double-charged for order A-1001 and requests immediate refund.\"\n}\n```",
…
},
…
}
}
Excerpt

The run's output.output is the parsed answer, and output.response.content the text the model wrote. A JSON answer in a fenced json code block, as here, is accepted.

Ask your coding agent: "I want my app to triage a ticket and read its category and priority." For reference, the code looks like this. Your app parses the fields with the same schema:

read-triage.ts
import { createClient } from '@kindgi/sdk/client';
import { Triage } from './agents/ticket-triage/index.js';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN
const run = await kindgi.runs.start({
agent: 'acme.ticket-triage',
input: { userMessage: 'I was charged twice for order A-1001 and I need the money back today.' },
});
const triage = Triage.parse((run.output as { output: unknown }).output);
console.log(triage.category, triage.priority); // billing urgent

An answer that isn't JSON, or doesn't match the schema, goes back to the model with a message that lists what's wrong and gives the schema:

[kindgi:output-repair]
Your answer must be the triage as JSON matching this JSON Schema, and nothing else:
{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"category":{"enum":["billing","shipping","returns","other"],"type":"string"},…},"required":["category","priority","summary"],"type":"object"}
It did not fit:
- the answer is not valid JSON (Unexpected token 'I', "I understa"... is not valid JSON)
Reply again with only the corrected triage JSON.
Excerpt

Here an older runtime, which didn't send the schema with the first call, ran a version of the agent whose instructions didn't describe the JSON: its first answer was prose. The repair worked, and the turn took two steps instead of one. Each repair is a model call: it counts as a step against the agent's budget, and it costs.

When the repairs run out, the turn fails with output-schema-violation. maxRepairs sets how many, 1 by default. With maxRepairs: 0, an answer whose priority is "high" fails at once:

Error [output-schema-violation]: The agent's answer does not match its triage schema after 0 repairs: /priority must be equal to one of the allowed values

A Zod object refuses fields it doesn't name: its schema has "additionalProperties": false, as the repair message shows. The schema of the pydantic model doesn't.

A flow reads a typed answer at nodeOutputs.<step>.output.<field>. Ask your coding agent: "I want a flow that triages a ticket and returns its category and priority." For reference, the flow looks like this:

flows/triage-ticket/index.ts
import { defineFlow } from '@kindgi/sdk/define';
const defined = defineFlow({
id: 'acme.triage-ticket',
version: '0.1.0',
name: 'Triage a ticket',
nodes: [
{
id: 'triage',
kind: 'agent',
ref: 'acme.ticket-triage',
inputMapping: { ticket: { path: 'runInput.ticket' } },
},
],
edges: [
{ id: 'e-start', from: '$start', to: 'triage' },
{ id: 'e-end', from: 'triage', to: '$end' },
],
output: {
mapping: {
category: { path: 'nodeOutputs.triage.output.category' },
priority: { path: 'nodeOutputs.triage.output.priority' },
},
},
});
if (defined.kind === 'err') throw new Error(defined.error.message);
export default defined.value;

Run the flow:

Terminal window
pnpm exec kindgi runs start --flow=acme.triage-ticket --input='{"ticket":"I was charged twice for order A-1001 and I need the money back today."}'

--flow is the flow, and --input its input, with the ticket. The run's output holds the two fields the flow mapped:

{
…
"flowId": "acme.triage-ticket",
…
"status": "completed",
…
"output": {
"category": "billing",
"priority": "urgent"
}
}
Excerpt

A dry run, with --dry-run, skips the model call, and with it the check. The turn completes with no output, and its answer is [dry-run: model call skipped].