Structured output (generative scenarios)

Give an agent a Zod responseFormat in its config to get typed, parsed JSON back instead of free text. The schema's .describe() calls are the spec the LLM follows. Replies are parsed by your own schema, so .refine() and .transform() work, and a failed parse is retried with a correction. One agent, one fixed format — per-turn schemas break prompt caching, so variable options go in the message and are enforced with a .refine(). Ideal for generating a whole scenario, cast, or config once at game start, then driving the rest of the game from it.

Sometimes you don't want prose from an agent — you want data: a generated cast of characters, a puzzle layout, a branching scenario, a config object. Give the agent a Zod schema as responseFormat in its config and it becomes a structured agent: every reply send returns carries the result of parsing the reply with your schema on .data (alongside the raw .text), typed by it. The LLM is constrained to emit JSON matching the schema, and your .describe() calls on each field are the instructions it follows.

This turns one agent call into the generative seed for the whole game: author the scenario once at game start, store it in state, and build everything else from it.

The schema is the spec

Write the schema with rich .describe() annotations — they're what the model reads. Derive your TypeScript types from the same schema with z.infer so the blueprint has exactly one representation:

ts
// mystery.ts
import { z } from '@aichatgames/sdk'

export const GuestSchema = z.object({
  name: z.string().describe('Full name, period-appropriate and memorable'),
  role: z.string().describe("This guest's relationship to the victim, e.g. 'estranged business partner'"),
  secret: z.string().describe('What this guest is hiding. For the culprit: how and why they did it.'),
})

export const MysterySchema = z.object({
  title: z.string().describe('An evocative title for the mystery'),
  setting: z.string().describe('One or two sentences establishing place, era, and mood'),
  guests: z.array(GuestSchema).length(4).describe('Exactly four guests, exactly one of whom is the killer'),
  culpritName: z.string().describe('The exact name of the killer — must match one of the guests'),
  solution: z.string().describe('How and why the culprit did it — the truth revealed at the end'),
}).refine(m => m.guests.some(g => g.name === m.culpritName), {
  message: 'culpritName must be the exact name of one of the guests',
})

// One representation: the type IS the schema.
export type MysteryBlueprint = z.infer<typeof MysterySchema>

Generating the scenario

Put the schema in the agent's config; send then returns .data, typed as MysteryBlueprint. The config is written in code, in a module-level constant; the game creates the agent once, where the game starts (a second createAgent for the name throws), and any later handler would reach it with io.getAgent('designer', MysterySchema):

ts
// agents/designer.ts
import type { AgentConfig } from '@aichatgames/sdk'
import type { Ctx } from '../types.js'
import { MysterySchema, type MysteryBlueprint } from '../mystery.js'

const designerConfig: AgentConfig<MysteryBlueprint> = {
  systemPrompt: DESIGNER_SYSTEM_PROMPT,
  responseFormat: MysterySchema,
}

export const createDesigner = ({ io }: Ctx) => io.createAgent('designer', designerConfig)
ts
// handlers/game-start.ts
import { createDesigner, DESIGN_PROMPT } from '../agents/designer.js'

export async function handleGameStart(_event: GameStart, ctx: Ctx) {
  // `.length(4)` and the culprit `.refine()` already held: a reply that broke either was sent
  // back to the model as a correction and retried.
  const { data: bp } = await createDesigner(ctx).send('design-mystery', {
    messages: [{ role: 'user', content: DESIGN_PROMPT }],
  })

  // Store the blueprint — it's the single source of truth for the rest of the game.
  ctx.state.set(d => { d.blueprint = bp })
}

Then build the game from the blueprint

The blueprint drives everything else. Spin up one agent per generated character, each configured from its slice of the data, then generate matching art in parallel. Like any agent, each guest is created once with io.createAgent and reached by name after with io.getAgent (io.getAgent(name, Schema) for a structured one) — the game needn't keep or rebuild a config minted at runtime:

ts
bp.guests.forEach((g, i) => {
  io.createAgent(guestId(i), guestConfig(g, bp, g.name === bp.culpritName))
})
// Later handlers reach a guest by name alone — no need to rebuild its config:
//   io.getAgent(guestId(i))

// Portraits in parallel — each lands independently and fills into state.
await Promise.all(bp.guests.map(async (g, i) => {
  const { mediaId } = await io.activities.generateImage(`portrait-${i}`, { prompt: portraitPrompt(g), model: 'flux' })
  state.set(d => { d.portraits[guestId(i)] = mediaId })
}))

One agent, one format

The response schema is part of the prompt prefix the provider caches, just like the system prompt. Every send on an agent re-reads its kept history, and that history is only cheap while the prefix stays the same — so the format is fixed per agent, not chosen per send:

  • Need two formats? Use two agents — e.g. a prose narrator and a structured referee, each with its own fixed config. Alternating sends between them stays cached.
  • A changed config invalidates the cache. Create an agent once and reach it with io.getAgent; io.updateAgent with a changed config (schema included) makes the next send re-read the whole history at full price. Fine for a rare, genuine change of role; wrong as a per-turn habit. (An identical updateAgent writes nothing.)
  • Never build the schema's shape from game state. A z.enum(state.get().exits) rebuilt each turn is a new JSON Schema every turn — a full-price re-read of the history on every send. (A .refine() built from state is fine — see below.)
  • Compaction doesn't break it. When an agent's history is compacted, the platform asks the agent itself — same prompt, tools and schema — to call its compact tool with a summary, so the cached prefix carries straight through. Your schema needn't allow for it.

Variable options: in the message, enforced with a refine

When the valid answers change turn to turn, keep the schema fixed and general, list the options in the message, and enforce them with a .refine(). The model is sent only the schema's JSON Schema, which a refine doesn't change — so io.getAgent accepts this turn's refined schema as the agent's format (nothing is written, the cache holds), and the handle it returns parses with it:

ts
const PickSchema = z.object({
  exit: z.string().describe('The exact name of one of the exits listed in the message'),
})

// Once, at the start: io.createAgent('pathfinder', { systemPrompt: PATHFINDER_PROMPT, responseFormat: PickSchema })
const exits = state.get().room.exits
const pathfinder = io.getAgent('pathfinder',
  PickSchema.refine(p => exits.includes(p.exit), { message: `exit must be one of: ${exits.join(', ')}` }))
const { data } = await pathfinder.send(`pick-exit-${turn}`, {
  messages: [{ role: 'user', content: `Exits: ${exits.join(', ')}\nWhich way?` }],
  transient: true,
})

What a schema can be

data comes from your own schema, so zod's .refine() / .superRefine(), .transform(), .trim(), z.coerce, .catch() and .default() all behave as zod defines. The model is sent the schema's JSON Schema — for a .transform() or pipe, its input side, which is what the model must write. What can't be expressed that way — dates, maps, sets, custom and function types, a bare z.transform with no input schema — throws when the agent is created: ask for a string and .transform() it instead.

Notes

  • The reply gains data — typed as z.infer<typeof Schema>. A prose agent's reply has no data; io.getAgent(name, Schema) gets a structured agent back typed and parsed by that schema (and throws if its JSON Schema isn't the agent's). It is still the same reply (an assistant message with .text beside it), so addMessages keeps it like any other.
  • Failed attempts are retried, and recorded. Output that fails to parse — a refine included — gets a correction and another attempt, up to 5 retries; then the send throws. The rejected attempt and its correction stay in the call record (the inspector marks it rejected, with its correction) but are not part of the reply, so keeping the reply keeps only the accepted output.
  • The describe() text is the prompt. Spend your effort there; it's where the model learns the contract. A constraint the JSON Schema can't express ("the culprit is a guest") goes in the .describe() text and a .refine(). One that needs judgment against the story ("contradicts nothing established") is a job for a checker agent and a retry loop — see self-review.
  • One-shot generation is usually transient-friendly. A designer that runs once at game start doesn't need its exchange kept in history; send to it, and move on.
  • Store the result in state, not re-derived per turn — the generation costs a turn and should happen exactly once.
  • Determinism: under memoized dev/replay the same prompt replays the same blueprint, so a generative game is still reproducible for play-tests.