Structured output (generative scenarios)
Use agent.send with responseFormat and a Zod schema to get typed, parsed JSON back instead of free text. The schema's .describe() calls are the spec the LLM follows. 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. Pass a Zod schema as
responseFormat and send returns the parsed, typed result on .data (alongside the raw
.text). 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:
// 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).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'),
})
// One representation: the type IS the schema.
export type MysteryBlueprint = z.infer<typeof MysterySchema>Generating the scenario
Call send with responseFormat; read .data, typed as MysteryBlueprint:
// handlers/game-start.ts
import { MysterySchema } from '../mystery.js'
import { designerConfig, DESIGN_PROMPT } from '../agents/designer.js'
export async function handleGameStart(_event: GameStart, { state, io }: Ctx) {
const designer = new io.Agent('designer', designerConfig)
const { data: bp } = await designer.send('design-mystery', {
message: { role: 'user', content: DESIGN_PROMPT },
responseFormat: MysterySchema,
})
// Fail fast if the model broke the contract the schema couldn't enforce.
if (bp.guests.length !== 4) throw new Error(`Expected 4 guests, got ${bp.guests.length}`)
if (!bp.guests.some(g => g.name === bp.culpritName)) {
throw new Error(`culpritName "${bp.culpritName}" matches no guest`)
}
// Store the blueprint — it's the single source of truth for the rest of the game.
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:
bp.guests.forEach((g, i) => {
io.agents[guestId(i)] = new io.Agent(guestId(i), guestConfig(g, bp, g.name === bp.culpritName))
})
// Portraits in parallel — each lands independently and fills into state.
await Promise.all(bp.guests.map(async (g, i) => {
const { url } = await io.activities.generateImage(`portrait-${i}`, { prompt: portraitPrompt(g), model: 'flux' })
state.set(d => { d.portraits[guestId(i)] = url })
}))Notes
responseFormatreturns{ text, data }—datais typed asz.infer<typeof Schema>. Without it,sendreturns just{ text }.- The describe() text is the prompt. Spend your effort there; it's where the model learns the contract. A constraint the schema can't express ("exactly one killer") goes in the system prompt and gets a fail-fast assertion after the call.
- One-shot generation is usually
transient-friendly. A designer that runs once at game start doesn't need its exchange kept in history; create it, call it, discard it. (Only keep an agent onio.agentsif later handlers will talk to it again.) - 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.