# Structured output

Turn a model reply into a Java object. `Agent.chatWithFormat` does it in one call. The parsers underneath also work on replies you get yourself.

## Ask the agent for an object

`chatWithFormat(message, type, format, maxRetries)` appends format instructions to the message, parses the reply into `type`, and on a parse failure sends the model a correction prompt, up to `maxRetries` times. When the last attempt fails, it throws `ParseException`.

Without the `format` argument, the format comes from `@OutputFormatSpec` on the target type, then on the agent class, and is otherwise `JSON`. `chatWithStructure(message, parser, maxRetries)` does the same with any `OutputParser` you pass.

<!-- java-contract: src/main/java/com/example/tnsai/docs/StructuredOutputExample.java -->
```java
package com.example.tnsai.docs;

import com.tnsai.agents.Agent;
import com.tnsai.llm.LLMClient;
import com.tnsai.output.OutputFormat;
import com.tnsai.output.OutputSerializerRegistry;
import com.tnsai.parser.JsonOutputParser;
import com.tnsai.parser.ParseResult;
import com.tnsai.parser.RetryableParser;

import java.util.List;

public final class StructuredOutputExample {
    private StructuredOutputExample() {}

    public record Analysis(String summary, List<String> issues, double score) {}

    // One call: the agent adds format instructions, parses, and asks again on a parse failure.
    static Analysis analyze(Agent agent, String code) {
        return agent.chatWithFormat("Review this code:\n" + code, Analysis.class, OutputFormat.JSON, 2);
    }

    // Your own LLM call: parse the reply and send correction prompts yourself.
    static ParseResult<Analysis> analyzeWith(LLMClient llm, String code) {
        JsonOutputParser<Analysis> json = JsonOutputParser.forType(Analysis.class);
        RetryableParser<Analysis> parser = RetryableParser.wrap(json).maxRetries(2).build();

        String prompt = "Review this code:\n" + code + "\n\n" + json.getFormatInstruction();
        String reply = llm.chat(prompt).getContent();
        return parser.parseWithRetry(reply, correction -> llm.chat(correction).getContent());
    }

    // Write a parsed result in another format.
    static String toYaml(Analysis analysis) {
        return OutputSerializerRegistry.getInstance().serialize(analysis, OutputFormat.YAML);
    }
}
```

## Parsers

`OutputParser<T>` turns text into `T`.

| Method | Returns |
|---|---|
| `parse(text)` | A `ParseResult<T>`: the value or an error, never an exception |
| `parseOrThrow(text)` | `T`, or throws `ParseException` |
| `parseOptional(text)` | `Optional<T>` |
| `getFormatInstruction()` | Prompt text that tells the model the expected format |
| `getSchemaDescription()` | A schema for the target type |
| `getErrorCorrectionPrompt(failedOutput, error)` | A prompt asking the model to fix its reply |

`JsonOutputParser.forType(type)` finds the JSON in a reply, inside a ```` ```json ```` fence or as a bare `{...}` or `[...]`, and reads it into a record or POJO with Jackson. By default it ignores unknown properties. `JsonOutputParser.builder(type)` sets your own `objectMapper`, `strictMode(true)` to reject unknown properties, or a `validator`.

`RetryableParser.wrap(parser).maxRetries(n).build()` adds a retry loop. `parseWithRetry(reply, llmFunction)` sends each correction prompt through `llmFunction` and parses again. `getAttempts()` lists every attempt with its output and error.

## `ParseResult`

| Method | Returns |
|---|---|
| `isSuccess()`, `isFailure()` | Whether the parse worked |
| `get()` | The value, or throws `IllegalStateException` on a failure |
| `getOrElse(fallback)`, `toOptional()` | The value without throwing |
| `getError()`, `getValidationErrors()` | What went wrong |
| `getRawOutput()` | The text that was parsed |
| `map(fn)`, `flatMap(fn)` | A transformed result; a failure passes through |
| `ifSuccess(fn)`, `ifFailure(fn)`, `ifSuccessOrElse(ok, err)` | Run code for one case |

## Output formats

| `OutputFormat` | MIME type | Nested data |
|---|---|---|
| `JSON` | `application/json` | Yes |
| `YAML` | `application/x-yaml` | Yes |
| `TOON` | `text/x-toon` | Yes |
| `TONL` | `text/x-tonl` | Yes |
| `TEXT` | `text/plain` | No |

TOON and TONL are compact text formats meant to use fewer tokens than JSON. `OutputSerializerRegistry.getInstance()` holds one serializer per format: `serialize(data, format)`, `deserialize(text, format, type)` and `getFormatInstructions(format, type)`. `register(format, serializer)` replaces one, and `reset()` restores the built-ins.

## Validate fields and retry: `StructuredOutputExecutor`

`StructuredOutputExecutor` in `tnsai-intelligence` (package `com.tnsai.output.validation`) calls an `LLMClient` directly, parses the reply and checks field rules:

```java
import com.tnsai.output.OutputFormat;
import com.tnsai.output.validation.StructuredOutputExecutor;
import com.tnsai.output.validation.ValidationRule;

StructuredOutputExecutor<OrderSummary> executor = StructuredOutputExecutor.<OrderSummary>builder()
    .llm(client)
    .targetType(OrderSummary.class)
    .outputFormat(OutputFormat.JSON)
    .rules(List.of(
        ValidationRule.notNull("orderId"),
        ValidationRule.range("total", 0, 100_000),
        ValidationRule.pattern("email", "[\\w.-]+@[\\w.-]+")))
    .maxRetries(3)
    .systemPrompt("You are an order processing assistant.")
    .build();

StructuredOutputExecutor.StructuredOutputResult<OrderSummary> result =
    executor.generate("Summarize this order: ...");

if (result.success()) {
    OrderSummary order = result.value();
} else {
    List<String> errors = result.errors();
}
```

| Builder | Default | Meaning |
|---|---|---|
| `llm(LLMClient)` | Required | The client to call |
| `targetType(Class<T>)` | Required | The type to parse into |
| `outputFormat(OutputFormat)` | `JSON` | The format asked for and parsed |
| `rules(List<ValidationRule>)` | None | Checks on the parsed fields |
| `maxRetries(int)` | `3` | Extra attempts after the first |
| `systemPrompt(String)` | None | System prompt for every call |

`generate` adds the format instructions to the prompt. When the reply does not parse or a rule fails, the next attempt sends a correction prompt with the errors, the original request and the format instructions. When the call itself throws, the same prompt is sent again. The result has `success()`, `value()` (null on failure), `errors()` from every attempt, and `attempts()`. It does not throw on failure.

Rules see the parsed object as a map of its top-level fields:

| `ValidationRule` | Fails when |
|---|---|
| `notNull(field)` | The field is missing or null |
| `range(field, min, max)` | The value is outside `[min, max]`, or not a number |
| `pattern(field, regex)` | The whole string does not match, or the value is not a string |
| `custom(description, predicate)` | The predicate on the field map is false; the error is `description` |

`range` and `pattern` pass a missing or null field; add `notNull` for that. `ValidationRule` has one method, `validate(Map<String, Object>)`, so a lambda also works.

## Related

- [Create an agent](create.md): the other chat methods.
- [Streaming](streaming.md): replies as they arrive.
- [Exceptions](exceptions.md): `ParseException` is a `TnsAIException`.
