Skip to contents

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$status

chat_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_output

result$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.