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

  1. Model the contract with TypeBox

    inputSchema and outputSchema are required TSchemas. Enums are a Type.Union of Type.Literals — the model must return one of the listed values or the decode fails. Bound free-text with maxLength so summary can’t run away.

    triage.ts
    ts
    import { 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 }),
    });
  2. Define the agent

    defineAgent is an identity builder — no runtime work, just TS inference. model: "cheap" resolves through the default alias map to google/gemini-3.1-flash-lite-preview. tools: [] means a pure text-to-JSON turn. quota.maxTurns: 2 caps the loop — one turn to answer, one spare for an output-retry.

    triage.ts
    ts
    import { 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 },
    });
  3. Run it and stream events

    runAgent returns a RunHandle: an events async-iterable, a result promise, and abort(). Drain events for live progress, then await the terminal result.

    triage.ts
    ts
    import { 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;
  4. Read the result

    result.output is populated only when result.status === "success". The promise never rejects — inspect result.status and result.error instead of wrapping the run in try/catch.

    triage.ts
    ts
    if (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:

EdgeWhenOn failure
inputSchemaBefore the model sees the inputShort-circuits with InputValidationErrorresult.status is error, the model never runs
outputSchemaAfter the model’s final textFeeds 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 }.

ts
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:

AliasRoutes toUse when
cheapgemini-3.1-flash-lite-previewHigh volume, simple labels
miniopenai/gpt-5-miniTrickier categories, more nuance
haikuanthropic/claude-haiku-4.5Sentiment subtleties matter

Append a :reasoning suffix for a thinking budget — "mini:high" — or use the object form for full control:

ts
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.Union of Type.Literal gives you closed enums.
  • defineAgent — JSON-serialisable AgentSpec with full TS inference for result.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: events to stream, result to read; the promise never rejects.

Level up

Type to search…

↑↓ navigate open esc close