Agora
Authoring

Structured output

Constrain a turn's answer to a schema, and get the validated value back alongside the prose.

Every answer this library produced was free text, so the only way to get a typed value back out of a turn was to declare a TOOL whose whole job was to receive it.

AgentLoopDeps.outputSchema takes any Standard Schema — Zod, Valibot, ArkType:

const deps: AgentLoopDeps<{ city: string; tempC: number }> = {
  // …
  outputSchema: z.object({ city: z.string(), tempC: z.number() }),
}

const { text, object } = await runAgentLoop(deps, input, hooks)
//      ^ the prose      ^ { city: 'Recife', tempC: 21 }, validated

The validated value comes back as object on the run's result, typed when the loop is called directly, and is recorded on the assistant message as a synthetic auto-executed structured_output tool call — the same device inject-mode retrieval uses, so it persists and renders without a store gaining a column.

It is declared on the agent, not per request, because a schema is a live object and AgentRunInput crosses a JSON boundary on its way into a durable workflow.

How it composes with tool calling: a separate formatting pass, always

The turn runs its model→tools iteration exactly as it would without a schema. Once a step comes back with no tool calls, one extra non-streamed call (structured:<step>:<attempt>, tools: [], outputSchema set) restates that answer as the schema.

Most providers cannot serve a response format and a tool set in the same request. Skipping the pass for an agent that happens to have no tools would be cheaper and is deliberately not done: that decision would read the tool registry of whichever process is replaying, which is how a resumed run ends up asking for a checkpoint position its history has no room for. So the pass is unconditional, and it costs one model call per turn, billed as its own structured_output usage row.

A translation, not a second answer

The pass is shown the question and the answer — not the turn's whole transcript. It is a translation of the answer, and its own instruction tells the model to use only what the conversation already contains, so handing it the transcript again would roughly double what a turn with a schema costs, at no discount: the pass swaps the system block for the schema instruction, and the system block is the prompt cache's prefix.

The answer it restates is the one that survived the output gate, never the model's raw reply — and the question comes off the processed prompt, so a question an InputProcessor masked cannot reappear in the clear here.

outputFromTranscript: true restores the whole transcript for an agent whose answer genuinely cannot be restated from its own words — one that reports on rows a tool returned and names only their total in the prose.

An answer that fails the schema is a defined outcome

Up to outputRepairAttempts further calls (default 1) re-ask with the previous attempt's validation issues attached. After that the run fails with a StructuredOutputError carrying the issues, the text that failed them, and the attempt count. Bounded because a model that cannot satisfy a schema usually cannot satisfy it on the fourth try either, and every attempt is billed. outputRepairAttempts: 0 fails on the first invalid reply.

The provider constrains, the loop validates

aiSdkModel maps the schema onto streamText's output: Output.object(...) so the provider constrains generation, and passes its parsed value back — but the loop validates it regardless. "The provider says it matched" is not the same claim as "it matches", and a provider that ignored the schema has to fail where the failure is repairable rather than downstream. An adapter that cannot constrain generation at all still works: the loop reads the JSON out of the reply text, fences and lead-in prose included (extractJson is exported for the same job).

If an output processor rewrote the reply, the provider's parsed object describes text that no longer exists — so the gated text is re-parsed rather than trusted.

Determinism

Every attempt is its own checkpoint with its own usage row, so a suspend between two attempts resumes on the reply the first one got rather than paying for a third. Validation itself sits outside the checkpoints, on the same footing as HistoryWindow.select: its inputs are the schema and a reply a checkpoint already holds, so every replay reaches the same verdict — and the same number of attempts — without a position of its own.

A consumer who declares no outputSchema sees no new checkpoint, no extra call, and no change to the loop's checkpoint sequence.

On this page