Classify and Extract Structured Data
Turn free text into a typed, validated record with a schema, a cheap model, and a turn quota.
The simplest real task: feed a free-text support ticket in, get a typed triage record out. No tools, no files, no subagents — a typed input/output schema, a cheap model, and a turn quota. The schema is the contract: drover validates the input before the model runs and decodes the output after, self-correcting on mismatch.
Level 1 of 7. This is the foundation every later recipe builds on. It
assumes only the quickstart: a Bun runtime and
OPENROUTER_API_KEY set. No prior recipe needed.
The build
-
Model the contract with TypeBox
inputSchemaandoutputSchemaare requiredTSchemas. Enums are aType.UnionofType.Literals — the model must return one of the listed values or the decode fails. Bound free-text withmaxLengthsosummarycan’t run away.triage.tstsimport { Type } from "@sinclair/typebox"; const TicketIn = Type.Object({ ticket: Type.String(), }); const Triage = Type.Object({ category: Type.Union([ Type.Literal("billing"), Type.Literal("bug"), Type.Literal("feature-request"), Type.Literal("other"), ]), priority: Type.Union([ Type.Literal("P0"), Type.Literal("P1"), Type.Literal("P2"), Type.Literal("P3"), ]), sentiment: Type.Union([ Type.Literal("angry"), Type.Literal("neutral"), Type.Literal("happy"), ]), summary: Type.String({ maxLength: 200 }), }); -
Define the agent
defineAgentis an identity builder — no runtime work, just TS inference.model: "cheap"resolves through the default alias map togoogle/gemini-3.1-flash-lite-preview.tools: []means a pure text-to-JSON turn.quota.maxTurns: 2caps the loop — one turn to answer, one spare for an output-retry.triage.tstsimport { defineAgent } from "@drover/core"; const triageAgent = defineAgent({ id: "ticket-triage", description: "Classify a support ticket into a triage record.", systemPrompt: "You triage support tickets. Read the ticket and return a JSON " + "object matching the output schema. Pick the closest category, " + "priority, and sentiment. Keep summary under 200 characters. No prose.", inputSchema: TicketIn, outputSchema: Triage, model: "cheap", tools: [], quota: { maxTurns: 2 }, }); -
Run it and stream events
runAgentreturns aRunHandle: aneventsasync-iterable, aresultpromise, andabort(). Draineventsfor live progress, thenawaitthe terminalresult.triage.tstsimport { runAgent } from "@drover/facade"; const handle = runAgent(triageAgent, { ticket: "I was charged twice this month and your billing page 500s. " + "Third time this year. Fix it now.", }); for await (const e of handle.events) { if (e.kind === "assistant_text") console.log("[text]", e.text); if (e.kind === "usage") console.log( "[usage]", e.usage.inputTokens + "/" + e.usage.outputTokens, ); } const result = await handle.result; -
Read the result
result.outputis populated only whenresult.status === "success". The promise never rejects — inspectresult.statusandresult.errorinstead of wrapping the run intry/catch.triage.tstsif (result.status === "success") { console.log("category:", result.output.category); console.log("priority:", result.output.priority); console.log("summary:", result.output.summary); } else { console.error("run did not succeed:", result.status, result.error); } console.log( "tokens:", result.usage.inputTokens + "/" + result.usage.outputTokens, );Plausible output:
[text] {"category":"billing","priority":"P1","sentiment":"angry","summary":"Double-charged this month; billing page returns 500. Recurring issue."} [usage] 214/41 category: billing priority: P1 summary: Double-charged this month; billing page returns 500. Recurring issue. tokens: 214/41
Why the schema is the contract
drover validates on both edges of the loop:
| Edge | When | On failure |
|---|---|---|
inputSchema | Before the model sees the input | Short-circuits with InputValidationError — result.status is error, the model never runs |
outputSchema | After the model’s final text | Feeds a corrective message back and retries, up to outputRetries (default 2) |
You can watch both in the event stream: input_validated fires once at the
start; output_retry fires per correction attempt; output_validated fires
when the decode finally passes. With maxTurns: 2 you allow exactly one
output-retry before the quota stops the loop. See
Events & streams for the full event union.
Reading status, usage, and cost
result.status is one of success, quota, cancelled, error, or
paused. Branch on it — output is undefined for every status except
success, and error carries { tag, message }.
switch (result.status) {
case "success":
return result.output; // typed as the Triage record
case "quota":
throw new Error("ran out of turns before a valid record");
case "error":
throw new Error(result.error?.message ?? "unknown");
default:
return null; // cancelled | paused
}result.usage carries at least { inputTokens, outputTokens } plus cost
fields, and the same numbers arrive live on each usage event. Sum across runs
for a billing total.
Swapping the model for quality
The model field takes a single alias string. Bump it when the cheap model
mislabels:
| Alias | Routes to | Use when |
|---|---|---|
cheap | gemini-3.1-flash-lite-preview | High volume, simple labels |
mini | openai/gpt-5-mini | Trickier categories, more nuance |
haiku | anthropic/claude-haiku-4.5 | Sentiment subtleties matter |
Append a :reasoning suffix for a thinking budget — "mini:high" — or use the
object form for full control:
model: { name: "openai/gpt-5-mini", reasoning: "medium", temperature: 0 },See @drover/model for the alias map and withAliases.
What you assembled
inputSchema/outputSchema(TypeBox) — the contract drover validates on both edges;Type.UnionofType.Literalgives you closed enums.defineAgent— JSON-serialisable AgentSpec with full TS inference forresult.output.model: "cheap"— a default alias, swappable for quality without touching the rest of the spec.tools: []— a pure extraction turn; no sandbox capability needed.quota.maxTurns: 2— caps the loop, leaving room for one output-retry.runAgent— the facade handle:eventsto stream,resultto read; the promise never rejects.