Structured Output with Microsoft.Extensions.AI
Getting structured JSON responses from LLMs is a common need. Whether you're building agent routing, data extraction, or decision pipelines, you want type-safe responses — not raw text you have to parse. Microsoft.Extensions.AI already provides everything you need. No custom helpers required.
Schema Generation
The first step is generating a JSON schema from your C# type. AIJsonUtilities.CreateJsonSchema handles this automatically — it inspects your type's properties, types, attributes, and nullability annotations and produces a compliant JSON schema:
// M.E.AI generates JSON schemas from your types
var schema = AIJsonUtilities.CreateJsonSchema(typeof(DelegationPlan));
The generated schema includes property names, types, descriptions (from [Description] attributes if present), and required/optional markers. This schema is what the LLM provider uses to constrain its output — the model is forced to produce JSON that conforms to exactly this structure.
Structured Output Configuration
Once you have a schema, pass it to the chat client via ChatOptions.ResponseFormat. The ChatResponseFormat.ForJsonSchema method wraps your schema with the metadata the provider needs:
var chatOptions = new ChatOptions
{
ResponseFormat = ChatResponseFormat.ForJsonSchema(
schema: schema,
schemaName: nameof(DelegationPlan),
schemaDescription: "Agent delegation plan")
};
var response = await chatClient.GetResponseAsync(messages, chatOptions);
var plan = JsonSerializer.Deserialize<DelegationPlan>(response.Text);
The schemaName and schemaDescription parameters help the model understand what it's producing. The provider (OpenAI, Anthropic, Azure OpenAI) translates this into its native structured output mechanism — OpenAI's response_format, Anthropic's tool-use schema, etc. Your code stays provider-agnostic.
Even Simpler: GetResponseAsync<T>
If you want to skip the manual schema generation and deserialization, GetResponseAsync<T> does everything in one call:
// FabrCore's CreateChatClientAgent returns agents that support typed responses
var plan = await agent.GetResponseAsync<DelegationPlan>(userPrompt);
Under the hood, this generates the JSON schema from DelegationPlan, sets the response format, makes the LLM call, and deserializes the response — all in a single line. The result is a strongly-typed DelegationPlan object, ready to use.
This is the recommended approach for most use cases. Use the manual ChatOptions approach when you need more control — custom JsonSerializerOptions, additional chat options, or when you want to inspect the raw response text before deserializing.
Avoid Fragile Fallback Parsing
A pattern you'll sometimes see in LLM code is "brace extraction" — finding the first { and last } in the response text and treating everything between as JSON:
// Don't do this — fragile and unreliable
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
var json = text[start..(end + 1)];
var result = JsonSerializer.Deserialize<MyType>(json);
This is unreliable for several reasons:
- Nested objects: If the model's preamble text contains braces (common in explanations about JSON), you'll extract the wrong substring.
- Markdown-wrapped JSON: Models often wrap JSON in
```json ... ```code blocks. The brace extraction might work sometimes, but it's not robust. - Partial responses: If the model is cut off mid-response, brace extraction produces invalid JSON.
Modern providers — OpenAI, Anthropic, Azure OpenAI — all have reliable native JSON mode. When you use ChatResponseFormat.ForJsonSchema, the provider guarantees the response is valid JSON conforming to your schema. There's no need for fallback parsing.
If you're getting malformed JSON responses, the fix is to improve your prompts or switch providers — not to build increasingly elaborate parsing workarounds. Parsing around failures is treating a symptom while the disease spreads.
Learn More
Structured output is a solved problem in the Microsoft.Extensions.AI ecosystem. Use AIJsonUtilities.CreateJsonSchema for schema generation, ChatResponseFormat.ForJsonSchema for provider configuration, and GetResponseAsync<T> for the simplest possible integration.
Check out the full documentation for more details on tools, plugins, and agent configuration.
Builder of FabrCore and OpenCaddis.