Give an agent a typed answer
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.
Before you start
Section titled “Before you start”- A pack on a runtime such as
kindgi dev, with Kindgi 0.1's CLI. - A registered model, as Models shows.
dev-echoonly 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_URLandKINDGI_API_TOKENset.
Declare the answer
Section titled “Declare the answer”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:
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.
from typing import Literal
from pydantic import BaseModel
from kindgi import Agent
class Triage(BaseModel): category: Literal["billing", "shipping", "returns", "other"] priority: Literal["low", "normal", "urgent"] summary: str
ticket_triage = Agent( id="acme.ticket-triage", version="0.1.0", name="Ticket triage", instructions="\n".join([ "You triage support tickets for Acme. Reply with only a JSON object:", '{"category": "billing" | "shipping" | "returns" | "other",', ' "priority": "low" | "normal" | "urgent",', ' "summary": "<one sentence>"}', ]), capabilities=[{"needs": []}], output={"schema": Triage, "name": "triage"},)output=Triage alone works too. The full form also takes name and
maxRepairs.
package acme.agents;
import com.fasterxml.jackson.annotation.JsonProperty;import com.kindgi.pack.Agent;import java.util.List;import java.util.Map;
public final class TicketTriage { public enum Category { @JsonProperty("billing") BILLING, @JsonProperty("shipping") SHIPPING, @JsonProperty("returns") RETURNS, @JsonProperty("other") OTHER }
public enum Priority { @JsonProperty("low") LOW, @JsonProperty("normal") NORMAL, @JsonProperty("urgent") URGENT }
public record Triage(Category category, Priority priority, String summary) {}
public static final Agent AGENT = Agent.define("acme.ticket-triage") .version("0.1.0") .name("Ticket triage") .instructions(String.join("\n", "You triage support tickets for Acme. Reply with only a JSON object:", "{\"category\": \"billing\" | \"shipping\" | \"returns\" | \"other\",", " \"priority\": \"low\" | \"normal\" | \"urgent\",", " \"summary\": \"<one sentence>\"}")) .capability(Map.of("needs", List.of())) .output(Triage.class) .build();
private TicketTriage() {}}output(Triage.class) derives the schema from the record. It sets no
name, so repairs and errors call the answer output; for name or
maxRepairs, give the schema yourself: .set("output", Map.of("schema", <JSON Schema as a Map>, "name", "triage", "maxRepairs", 2)).
package acme.agents
import com.kindgi.pack.scaladsl._
object TicketTriage { enum Category { case billing, shipping, returns, other }
enum Priority { case low, normal, urgent }
final case class Triage(category: Category, priority: Priority, summary: String)
val agent: Agent = Agent("acme.ticket-triage") .version("0.1.0") .name("Ticket triage") .instructions(Seq( "You triage support tickets for Acme. Reply with only a JSON object:", """{"category": "billing" | "shipping" | "returns" | "other",""", """ "priority": "low" | "normal" | "urgent",""", """ "summary": "<one sentence>"}""").mkString("\n")) .capability(Map("needs" -> Seq())) .output[Triage] .build()}output[Triage] derives the schema from the case class. It sets no name,
so repairs and errors call the answer output; for name or maxRepairs,
give the schema yourself with .set("output", Map("schema" -> …, "name" -> "triage", "maxRepairs" -> 2)).
schemais what the answer must match.namenames the answer in what the model is told when it gets it wrong, and in errors. It'soutputby default.maxRepairsis 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.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"}Run it
Section titled “Run it”A run of the agent returns the parsed answer beside the model's text:
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."}'uv run kindgi runs start --agent=acme.ticket-triage --input='{"userMessage":"I was charged twice for order A-1001 and I need the money back today."}'./kindgiw runs start --agent=acme.ticket-triage --input='{"userMessage":"I was charged twice for order A-1001 and I need the money back today."}'./kindgiw 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```", … }, … }}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.
Read it in your app
Section titled “Read it in your app”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:
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 urgentfrom kindgi.client import Kindgi
from agents.ticket_triage import Triage
kindgi = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN
run = kindgi.runs.start( agent="acme.ticket-triage", input={"userMessage": "I was charged twice for order A-1001 and I need the money back today."},)if run.output is None: raise SystemExit(f"The run has no output: it's {run.status}.")triage = Triage.model_validate(run.output["output"])print(triage.category, triage.priority) # billing urgentpackage com.acme.app;
import com.kindgi.client.Kindgi;import com.kindgi.client.models.Run;import com.kindgi.client.models.StartRunBody;import java.util.Map;
public final class ReadTriage { public static void main(String[] args) { Kindgi kindgi = Kindgi.create(); // KINDGI_API_URL, KINDGI_API_TOKEN
Run run = kindgi.runs().start(StartRunBody.WithAgent.builder() .agent("acme.ticket-triage") .input(Map.of("userMessage", "I was charged twice for order A-1001 and I need the money back today.")) .build()); Map<?, ?> triage = (Map<?, ?>) ((Map<?, ?>) run.output()).get("output"); System.out.println(triage.get("category") + " " + triage.get("priority")); // billing urgent }}The client's JSON reader doesn't see your types' Jackson annotations, so read the fields from the answer's map.
package acme.app
import acme.agents.TicketTriage.Triageimport com.kindgi.client.{Kindgi, KindgiJson}import com.kindgi.client.models.StartRunBodyimport scala.jdk.CollectionConverters._
object ReadTriage { def main(args: Array[String]): Unit = { val kindgi = Kindgi.create() // KINDGI_API_URL, KINDGI_API_TOKEN
val run = kindgi.runs().start(StartRunBody.WithAgent.builder() .agent("acme.ticket-triage") .input(Map[String, AnyRef]( "userMessage" -> "I was charged twice for order A-1001 and I need the money back today.").asJava) .build()) val answer = run.output.asInstanceOf[java.util.Map[String, AnyRef]].get("output") val triage = KindgiJson.read(KindgiJson.write(answer), classOf[Triage]) println(s"${triage.category} ${triage.priority}") // billing urgent }}When the answer doesn't fit
Section titled “When the answer doesn't fit”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.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 valuesA 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.
In a flow
Section titled “In a flow”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:
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;from kindgi import Flow
from ..agents.ticket_triage import ticket_triage
triage_ticket = Flow( id="acme.triage-ticket", version="0.1.0", name="Triage a ticket", nodes=[ { "id": "triage", "kind": "agent", "ref": 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"}, }, },)package acme.flows;
import acme.agents.TicketTriage;import com.kindgi.pack.Flow;import java.util.Map;
public final class TriageTicket { public static final Flow FLOW = Flow.define("acme.triage-ticket") .version("0.1.0") .set("name", "Triage a ticket") .node(Map.of( "id", "triage", "kind", "agent", "ref", TicketTriage.AGENT, "inputMapping", Map.of("ticket", Map.of("path", "runInput.ticket")))) .edge("e-start", "$start", "triage") .edge("e-end", "triage", "$end") .set("output", Map.of("mapping", Map.of( "category", Map.of("path", "nodeOutputs.triage.output.category"), "priority", Map.of("path", "nodeOutputs.triage.output.priority")))) .build();
private TriageTicket() {}}package acme.flows
import acme.agents.TicketTriageimport com.kindgi.pack.scaladsl._
object TriageTicket { val flow: Flow = Flow("acme.triage-ticket") .version("0.1.0") .set("name", "Triage a ticket") .node(Map( "id" -> "triage", "kind" -> "agent", "ref" -> TicketTriage.agent, "inputMapping" -> Map("ticket" -> Map("path" -> "runInput.ticket")))) .edge("e-start", "$start", "triage") .edge("e-end", "triage", "$end") .set("output", Map("mapping" -> Map( "category" -> Map("path" -> "nodeOutputs.triage.output.category"), "priority" -> Map("path" -> "nodeOutputs.triage.output.priority")))) .build()}Run the flow:
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."}'uv run kindgi runs start --flow=acme.triage-ticket --input='{"ticket":"I was charged twice for order A-1001 and I need the money back today."}'./kindgiw runs start --flow=acme.triage-ticket --input='{"ticket":"I was charged twice for order A-1001 and I need the money back today."}'./kindgiw 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" }}Dry runs
Section titled “Dry runs”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].