Skip to content

Structured Output

Rig can hand you a Rust value instead of a string. You name a target type, Rig sends its JSON Schema to the model, and the reply is deserialized into that type. There are two ways to do it:

  • an Extractor turns a piece of text into a T. Use it when extraction is the whole job.
  • agent.prompt_typed::<T>(..) runs an ordinary agent prompt (tools, history, hooks) and parses the final answer as T. Use it when structured output is one step of a larger agent workflow.

The target type derives serde::Deserialize, serde::Serialize, and schemars::JsonSchema. Build an extractor for it with ExtractorBuilder::<T>::new(model), then call extract:

use rig::extractor::ExtractorBuilder;
use rig::providers::openai::{self, OpenAI};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct Person {
name: Option<String>,
age: Option<u8>,
profession: Option<String>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let model = OpenAI::from_env()?.completion(openai::GPT_5_5);
let extractor = ExtractorBuilder::<Person>::new(model).build();
let person = extractor
.extract("John Doe is a 30 year old doctor.")
.await?
.output;
println!("{person:?}");
Ok(())
}
Person { name: Some("John Doe"), age: Some(30), profession: Some("doctor") }

extract(..).await returns a TypedPromptResponse<T>: the value is in .output, and .usage holds the tokens spent, summed over every attempt.

An extractor is an agent with a fixed preamble and one tool, submit, whose parameters are your type’s JSON Schema. Each extraction is a single model turn with tool choice set to required, so the model must call submit; Rig deserializes the call’s arguments into T. If the model answers with plain JSON that already matches T instead of calling the tool, Rig accepts that too.

ExtractorBuilder exposes the agent settings that make sense for extraction:

let extractor = ExtractorBuilder::<Person>::new(model)
.append_preamble("Ages are given in years; ignore honorifics like 'Dr.'")
.context("Doctors in this dataset are always medical doctors.")
.max_tokens(512)
.retries(2)
.build();
  • append_preamble adds instructions after the built-in extraction preamble (which tells the model to call submit; it can’t be replaced).
  • context adds a static document; dynamic_context(n, index) retrieves n documents from a vector index for every extraction.
  • retries(n) re-runs a failed attempt up to n more times. An attempt fails when the request errors, the model doesn’t submit anything, or the submitted JSON doesn’t fit T.
  • additional_params and add_hook work as they do on AgentBuilder.

extract(..) returns a run you can adjust before awaiting it, for example .history(&messages) to extract from a conversation, or .retries(n) for one call.

When the agent already exists and you want its final answer as a value, use prompt_typed. It sets T’s schema as the run’s structured-output schema (providers with native structured output enforce it) and parses the final text as T. Tools, history, hooks and max_turns work as in any other run:

#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct Forecast {
city: String,
temperature_c: f64,
}
let forecast: Forecast = agent
.prompt_typed::<Forecast>("Make up a plausible forecast for Paris.")
.max_turns(3)
.retries(1)
.await?
.output;

To give every run of an agent a fixed schema instead, set it on the builder with AgentBuilder::output_schema::<T>() and parse the reply yourself.

Extractions and typed prompts fail with StructuredOutputError:

  • EmptyResponse: the model produced nothing to parse (for an extractor: it never called submit).
  • Deserialization { output, error }: the model’s JSON didn’t fit T. output holds the raw text.
  • Prompt(PromptError): the run itself failed, for example a provider error or the turn budget running out. See Error handling.
use rig::completion::StructuredOutputError;
match extractor.extract("...").await {
Ok(response) => { /* use response.output */ }
Err(StructuredOutputError::EmptyResponse) => {
eprintln!("the model did not produce structured data");
}
Err(StructuredOutputError::Deserialization { output, error }) => {
eprintln!("unparseable output ({error}): {output}");
}
Err(err) => return Err(err.into()),
}

Build an extractor once and reuse it: it is cheap to call repeatedly and can be shared by reference across concurrent tasks. Run extractions concurrently with futures, capping how many are in flight:

use futures::{StreamExt, TryStreamExt};
use rig::loaders::FileLoader;
let docs: Vec<String> = FileLoader::with_glob("notes/*.txt")?
.read()
.ignore_errors()
.into_iter()
.collect();
let people: Vec<Person> = futures::stream::iter(&docs)
.map(|doc| async { extractor.extract(doc.as_str()).await.map(|r| r.output) })
.buffered(4)
.try_collect()
.await?;

Document loaders cover reading files; the multi_extract example runs several extractors over the same inputs in parallel.