When the next step of your analysis needs a list or a data frame rather than prose, describe the shape you want with an ellmer type. Deputy asks the model for that shape and gives you the converted R value.
Extract from what’s already there
chat_structured() works like ellmer’s: it asks the model
to answer in the given type, without calling tools, and returns the R
value.
agent <- Agent$new(ellmer::chat("openai/gpt-6-luna"))
review_type <- ellmer::type_object(
status = ellmer::type_enum(c("ok", "needs_review")),
findings = ellmer::type_array(ellmer::type_string())
)
review <- agent$chat_structured(
"The review found no problems.",
type = review_type
)
review$statuschat_structured_async() returns a promise for the same
value. The request counts against the agent’s usage limits like any
other, and agent$last_run()$usage shows what it cost.
If you already have a JSON Schema,
ellmer::type_from_schema() converts it:
status_type <- ellmer::type_from_schema(
'{"type": "object",
"properties": {"status": {"type": "string"}},
"required": ["status"],
"additionalProperties": false}'
)Do the work, then extract
Pass type to run_sync(),
run_async() or run() and the agent works in
two steps: it completes the task with its tools as usual, then extracts
the typed value from the conversation. Both steps share one budget, and
the extraction step doesn’t call tools, so no tool runs twice.
agent <- Agent$new(
ellmer::chat("openai/gpt-6-luna"),
tools = tools_file(),
permissions = permissions_readonly(),
usage_limits = UsageLimits(max_requests = 8)
)
result <- agent$run_sync("Review the README.", type = status_type)
if (!result_is_success(result)) {
cli::cli_abort("The review stopped early: {result$stop_reason}.")
}
result$structured_outputresult$response holds the model’s text from the first
step, and result$structured_output holds the extracted
value. Check result_is_success() first: a run that hit a
limit has no structured value.
Check the value yourself
ellmer makes sure the value has the right shape. Rules about its
content are yours to check. Pass a validate function that
returns TRUE when the value is acceptable, and
FALSE or a string explaining the problem when it isn’t.
With max_corrections above zero, Deputy sends the
explanation back to the model and asks again:
data <- agent$chat_structured(
"Extract the review status.",
type = status_type,
validate = function(x) {
if (x$status %in% c("ok", "needs_review")) {
TRUE
} else {
"status must be 'ok' or 'needs_review'"
}
},
max_corrections = 1
)max_corrections defaults to zero, so a failed check is
an error unless you opt in. Each correction is another model request,
without tools, and counts against the run’s limits. A reply that isn’t
valid JSON gets the same treatment. When the corrections run out, Deputy
signals a deputy_structured_output_invalid error.
Some failures are never retried: the validator itself erroring or
returning NA, a conversion error ellmer can’t explain, a
provider error, or a response cut off by the token limit. Every attempt
is recorded as a "structured_attempt" event on the result,
including the rejected values, so be careful where you log those events
if the data is sensitive.
Stream structured output
stream(type = ) and stream_async(type = )
use ellmer’s structured streaming: chunks arrive as raw JSON text while
the model writes. Streaming doesn’t support validate or
corrections. Some providers can only produce structured output through a
tool call; use chat_structured() with those.