Structured output
Force an LlmAgent to reply with schema-conforming JSON and capture the parsed result in session state.
Setting .output_schema(...) on an LlmAgent switches the model into JSON mode: every response must conform to the schema you declare, and with .output_key(...) the parsed result is written into session state where downstream agents — or your own code — can read it.
How it works
LlmAgentBuilder::output_schema takes a Schema from adk_rs::genai_types — the OpenAPI-3-flavored schema type the crate uses everywhere (tool declarations, OpenAPI tools, structured output). When the agent builds each LlmRequest, it calls LlmRequest::set_output_schema, which sets two fields on the request config: response_schema to your schema, and response_mime_type to "application/json".
LlmAgentBuilder::output_schema(self, schema: Schema) -> Self- Force structured JSON output conforming to
schema. LlmAgentBuilder::output_key(self, key: impl Into<String>) -> Self- Save the agent's final response into session state under this key, via the final event's
state_delta. Withoutput_schema, the stored value is the parsed JSON value rather than raw text. LlmRequest::set_output_schema(&mut self, schema: Schema)- Sets
config.response_schema = Some(schema)andconfig.response_mime_type = Some("application/json").
Building a Schema
Schema has fluent constructors for each JSON type plus property/require for objects. If you already derive schemars::JsonSchema on your structs, Schema::from_schemars converts a schemars RootSchema (best-effort: $ref into #/definitions is resolved, but constructs like oneOf become SchemaErrors). The crate depends on schemars 0.8.
use adk_rs::genai_types::Schema;
// Hand-built.
let schema = Schema::object()
.property("capital", Schema::string().with_description("Capital city"))
.property("population", Schema::integer())
.require("capital")
.require("population");
// Derived from a schemars type.
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct CountryInfo {
capital: String,
population: u64,
}
let root = schemars::schema_for!(CountryInfo);
let schema = Schema::from_schemars(&root)?;Capturing the result with output_key
When the agent produces its final response (an event with no function calls left to dispatch), it concatenates the text parts and stamps the value into event.actions.state_delta under your key. With output_schema set, the text is first parsed with serde_json::from_str; if the model somehow returned invalid JSON, the agent logs a warning and stores the raw text as a JSON string instead of failing the run.
Because string instructions are templated against session state, a later agent in a workflow pipeline can reference the value directly: an instruction like "Summarize this data: {info}" has {info} replaced with the stored state value when the request is built. See Sessions & state.
Provider mapping
All three providers now enforce the schema server-side. The adk schema (OpenAPI-3 flavour) is converted to each provider's JSON Schema dialect on the way out: types lowercased, nullable becoming a ["type", "null"] union, additionalProperties: false added to objects, and advisory keywords the providers reject (pattern, format, minimum, length/item bounds, …) stripped.
| Provider | What is sent |
|---|---|
| Gemini | Native: responseSchema and responseMimeType go onto the wire request. |
| OpenAI-compatible | Strict structured outputs: response_format: {"type": "json_schema", "json_schema": {"strict": true, ...}}. Strict mode requires every property listed in required, so previously-optional properties are sent as required-but-nullable — the original semantics survive. A JSON mime type without a schema still maps to plain {"type": "json_object"} mode. |
| Anthropic | Native structured outputs: the Messages API's output_config: {"format": {"type": "json_schema", ...}}. Requires a structured-outputs-capable Claude model (Claude 4.5-generation and newer). |
Complete example
use adk_rs::agents::LlmAgent;
use adk_rs::genai_types::Schema;
use adk_rs::providers::gemini::Gemini;
use adk_rs::runner::Runner;
use adk_rs::services::mem::InMemorySessionService;
use futures::StreamExt;
use std::sync::Arc;
#[derive(Debug, serde::Deserialize)]
struct CountryInfo {
capital: String,
population: u64,
}
#[tokio::main]
async fn main() -> adk_rs::Result<()> {
let schema = Schema::object()
.property("capital", Schema::string().with_description("Capital city"))
.property("population", Schema::integer())
.require("capital")
.require("population");
let agent = LlmAgent::builder("country_info")
.model(Arc::new(Gemini::from_env("gemini-2.5-flash")?))
.instruction("Answer with facts about the country the user names.")
.output_schema(schema)
.output_key("info") // parsed JSON also lands in state["info"]
.build()?;
let runner = Runner::builder()
.app_name("structured")
.agent(Arc::new(agent))
.session_service(Arc::new(InMemorySessionService::new()))
.build()?;
let mut events = runner.run("user", None, "Tell me about France").await?;
while let Some(event) = events.next().await {
let event = event?;
if event.is_final_response() {
if let Some(content) = &event.response.content {
let info: CountryInfo = serde_json::from_str(&content.text_concat())?;
println!("capital={} population={}", info.capital, info.population);
}
}
}
Ok(())
}Interaction with tools
The agent applies the schema to every request it builds inside its LLM↔tool loop, including requests that also carry tool declarations — adk-rs does not forbid the combination. JSON parsing only happens on the final response (the turn with no function calls). In practice structured output works best on leaf agents whose single job is to emit a record; if you need tool use and structured output, consider a two-step pipeline where the first agent gathers data with tools and the second formats it.
- LlmAgent — the full builder surface.
- Sessions & state — how
state_deltais applied and persisted. - Workflow agents — chaining agents that read each other's
output_key.