In this article
- 🧊 What structured output is
- 🎯 When to use it (and difference from function calling)
- 🔬 From C# record to JSON Schema
- 🏗️ Solution architecture
- ⬇️ Step 1 · Ollama and a suitable model
- 🧱 Step 2 · Project and NuGet packages
- ⚙️ Step 3 · appsettings.json
- 🧩 Step 4 · Register the chat client
- 📐 Step 5 · The TicketTriage record: the type IS the contract
- 🤖 Step 6 · The triage service with GetResponseAsync<T>
- 🚪 Step 7 · Endpoint and MongoDB save
- 🧪 Step 8 · Try it from the terminal
- 🔎 Behind the scenes: what actually goes to Ollama
- 🛡️ Limits, pitfalls and plan B
- ✅ Final checklist and next steps
🧊 What structured output is
An LLM produces free text: perfect for a human, fragile for a program. Structured output forces the model to reply with JSON that respects a JSON Schema you define: fields, types, allowed values. It's not a request in the prompt, it's a constraint applied during generation.
The practical difference is huge: with the constraint active the model's runtime discards tokens that would violate the schema. No dangling commas, no invented fields, no «Here's the requested JSON:» before the object. The result always deserializes into your C# type.
«Reply in JSON» in the prompt
- Model can ignore the request
- Text around the object, markdown fence
- Fields renamed or missing by surprise
- Defensive parsing and manual retries
Structured output with schema
- Schema constrains generation
- Only valid JSON, no prose around it
- Fields, types and enum guaranteed
- Direct deserialization into C# record
The request in the prompt is advice; the schema is a contract.
🎯 When to use it (and difference from function calling)
Function calling is for when the model needs to make your code execute something; structured output is for when the model's reply is itself the data: a classification, field extraction, score, routing decision.
It's the building block for the most concrete use cases of AI in business apps: routing email and tickets, extracting data from unstructured text, normalizing descriptions, evaluating sentiment. Everything that ends up in a database column or an `if` needs a type, not prose.
- Classification: category, priority, sentiment of a ticket or review — enum, not free strings.
- Extraction: dates, amounts, order codes from email and documents — typed fields with guaranteed format.
- Routing: which department to forward to, with what urgency — a decision downstream code can execute.
- Combined with function calling: first tools fetch data, then structured output packages the final reply in a type.
🔬 From C# record to JSON Schema
With Microsoft.Extensions.AI you don't write the schema manually: `AIJsonUtilities` generates it from your type. You call `GetResponseAsync<TicketTriage>` and the library builds the JSON Schema from the `record`: properties, types, `enum` with only allowed values and descriptions taken from `[Description]` attributes.
As with function calling tools, descriptions are what the model reads: a clear `[Description]` on each property guides field filling much more than any generic system prompt instruction.
GetResponseAsync<T> generates the schema from the type: properties, enum and [Description] included.
🏗️ Solution architecture
I'm building a Minimal API in .NET: automatic triage of help desk tickets. Free ticket text goes in, a typed `TicketTriage` object comes out — category, priority, summary, language and churn risk — and ends up on MongoDB, ready for dashboards and LINQ filters.
The model runs locally with Ollama via OllamaSharp, like in the function calling tutorial: same base, different building block. A single `POST /tickets/triage` endpoint: the response structure is guaranteed by the schema, not the prompt.
- Model: Ollama locally (`llama3.1`), exposed as `IChatClient` by OllamaSharp.
- Structured output: `GetResponseAsync<TicketTriage>` from Microsoft.Extensions.AI — schema generated from the record.
- Persistence: classified tickets land in a typed MongoDB collection.
- NuGet packages: Microsoft.Extensions.AI, OllamaSharp, MongoDB.Driver.
Free text in, typed record out, MongoDB as the destination.
⬇️ Step 1 · Ollama and a suitable model
Ollama runs the model on your computer, on endpoint http://localhost:11434, and supports structured output natively: it accepts a JSON Schema in the `format` field of the request and constrains generation accordingly.
It works with the most common generalist models (`llama3.1`, `qwen3`, `mistral`): the constraint is applied by Ollama's runtime, not the model. Zero tokens spent and data stays on your machine.
# a generalist model works fine: Ollama applies the constraint
$ ollama pull llama3.1
# start the server (usually starts automatically)
$ ollama serve # http://localhost:11434
# quick test: structured output directly from REST API
$ curl http://localhost:11434/api/chat -d '{
"model": "llama3.1",
"messages": [{"role": "user", "content": "Is the sky blue? Reply with available and color"}],
"format": {"type": "object", "properties": {"available": {"type": "boolean"}, "color": {"type": "string"}}},
"stream": false
}'🧱 Step 2 · Project and NuGet packages
I'm creating a Minimal API and adding three packages: abstractions and extensions from Microsoft.Extensions.AI, the OllamaSharp provider and the MongoDB driver.
Watch for the right package: `GetResponseAsync<T>` lives in Microsoft.Extensions.AI (the package with high-level extensions), not in Microsoft.Extensions.AI.Abstractions. And like with function calling, the old `Microsoft.Extensions.AI.Ollama` is deprecated: use OllamaSharp.
# new Minimal API
$ dotnet new web -n TriageDemo && cd TriageDemo
# high-level AI extensions + Ollama provider + Mongo driver
$ dotnet add package Microsoft.Extensions.AI
$ dotnet add package OllamaSharp
$ dotnet add package MongoDB.DriverStructured output · Microsoft Learn ↗
⚙️ Step 3 · appsettings.json
All configuration lives in appsettings.json: Ollama endpoint and model, MongoDB connection and database. Ollama's endpoint is http://localhost:11434.
Changing the model — or switching to a cloud provider tomorrow — stays a configuration change, not code.
{
"Ollama": {
"Endpoint": "http://localhost:11434",
"ChatModel": "llama3.1"
},
"Mongo": {
"ConnectionString": "mongodb://localhost:27017",
"Database": "helpdesk"
}
}🧩 Step 4 · Register the chat client
I collect the wiring in an extension method `AddTriage`: it registers the Ollama client as `IChatClient`, the MongoDB database and the triage service. Unlike function calling, `.UseFunctionInvocation()` isn't needed here: structured output has no loop, it's a single constrained call.
I keep `.UseLogging()`: seeing the sent schema and raw JSON received in the logs is the fastest way to understand why a field comes back empty.
using Microsoft.Extensions.AI;
using MongoDB.Driver;
using OllamaSharp;
using TriageDemo.Services;
namespace TriageDemo.Extensions;
// A single extension method registers the stack: the Ollama chat client
// (via OllamaSharp), MongoDB and the typed triage service.
public static class TriageServiceExtensions
{
public static IServiceCollection AddTriage(this IServiceCollection services, IConfiguration config)
{
var ollama = config.GetSection("Ollama");
var chatClient = new OllamaApiClient(new Uri(ollama["Endpoint"]!), ollama["ChatModel"]!);
// No UseFunctionInvocation: structured output is a single
// schema-constrained call, not a tool loop.
services.AddChatClient(chatClient)
.UseLogging();
var mongo = config.GetSection("Mongo");
var mongoClient = new MongoClient(mongo["ConnectionString"]);
services.AddSingleton<IMongoDatabase>(_ => mongoClient.GetDatabase(mongo["Database"]));
services.AddScoped<TriageService>();
return services;
}
}📐 Step 5 · The TicketTriage record: the type IS the contract
This is where everything happens: the TicketTriage `record` is both the response DTO and the schema that constrains the model. The `enum`s are the most powerful detail: in the schema they become the list of only allowed values, so the model can't invent a category your code doesn't handle.
Never use `object`, `dynamic` or anonymous types here: the whole point of structured output is to have a typed boundary. Each property has its `[Description]`: it's the prompt part that lives next to the data it describes.
using System.ComponentModel;
namespace TriageDemo.Models;
// The record is both the DTO and the schema: properties, enum and [Description]
// become the JSON Schema that constrains the model's response.
public enum TicketCategory { Billing, Technical, Account, Shipping, Other }
public enum TicketPriority { Low, Medium, High, Urgent }
public record TicketTriage(
[property: Description("Category of the problem reported in the ticket.")]
TicketCategory Category,
[property: Description("Operating priority suggested based on impact and urgency.")]
TicketPriority Priority,
[property: Description("Summary of the problem in one sentence, in the ticket's language.")]
string Summary,
[property: Description("Ticket language as ISO 639-1 code, e.g. it, en, de.")]
string Language,
[property: Description("true if customer threatens to leave or is very frustrated.")]
bool ChurnRisk);🤖 Step 6 · The triage service with GetResponseAsync<T>
The core is one line: `GetResponseAsync<TicketTriage>`. The extension generates the schema from the record, sets it as the `ChatResponseFormat` of the request and deserializes the response into the type. Returns a `ChatResponse<TicketTriage>`.
To read the result I use `TryGetResult`: returns `false` if the JSON isn't deserializable, without throwing—unlike the `Result` property, which throws in that case. At the boundary with a model, parsing failure is an expected input, not an exception.
using Microsoft.Extensions.AI;
using TriageDemo.Models;
namespace TriageDemo.Services;
public class TriageService(IChatClient chat)
{
private const string SystemPrompt =
"You are a help desk triage agent. Classify the user's ticket. " +
"Don't invent information: if the category isn't clear use Other.";
// GetResponseAsync<T> generates the JSON Schema from the record, sets it as
// the response format of the request and deserializes the response into the type.
public async Task<TicketTriage?> TriageAsync(string ticketText, CancellationToken ct = default)
{
List<ChatMessage> messages =
[
new(ChatRole.System, SystemPrompt),
new(ChatRole.User, ticketText)
];
var response = await chat.GetResponseAsync<TicketTriage>(messages, cancellationToken: ct);
// TryGetResult doesn't throw if JSON isn't deserializable: at the boundary
// with a model, failure is an expected case, not an exception.
return response.TryGetResult(out var triage) ? triage : null;
}
}ChatClientStructuredOutputExtensions · Microsoft Learn ↗
🚪 Step 7 · Endpoint and MongoDB save
The endpoint receives the ticket text, calls the service and saves the result in a typed MongoDB collection. If triage fails I respond `422 Unprocessable Entity`: the caller knows they can retry.
Notice the structured output dividend: `Category` and `Priority` arrive already as `enum`, so downstream filters are typed LINQ, not string comparisons hoping for the right capitalization.
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using TriageDemo.Models;
using TriageDemo.Services;
namespace TriageDemo.Endpoints;
public record TriageRequest(string Text);
// Persisted document: the typed triage plus original text.
[BsonIgnoreExtraElements]
public class TriagedTicket
{
[BsonId]
public ObjectId Id { get; set; }
public string Text { get; set; } = "";
[BsonRepresentation(BsonType.String)]
public TicketCategory Category { get; set; }
[BsonRepresentation(BsonType.String)]
public TicketPriority Priority { get; set; }
public string Summary { get; set; } = "";
public string Language { get; set; } = "";
public bool ChurnRisk { get; set; }
}
public static class TriageEndpoints
{
// POST /tickets/triage — free text in, typed record out.
public static void MapTriageEndpoints(this IEndpointRouteBuilder app) =>
app.MapPost("/tickets/triage", async (
TriageRequest req,
TriageService triage,
IMongoDatabase db,
CancellationToken ct) =>
{
var result = await triage.TriageAsync(req.Text, ct);
if (result is null)
return Results.UnprocessableEntity();
var doc = new TriagedTicket
{
Text = req.Text,
Category = result.Category,
Priority = result.Priority,
Summary = result.Summary,
Language = result.Language,
ChurnRisk = result.ChurnRisk
};
await db.GetCollection<TriagedTicket>("tickets").InsertOneAsync(doc, cancellationToken: ct);
return Results.Ok(result);
});
}
// Program.cs — two lines of wiring:
// builder.Services.AddTriage(builder.Configuration);
// app.MapTriageEndpoints();🧪 Step 8 · Try it from the terminal
A `curl` with a realistic ticket and triage is live: the model reads the customer's complaint and replies only with the object constrained by the schema — category, priority, summary, language and churn risk.
Try varying the text: a ticket in English changes `language`, a cancellation threat lights up `churnRisk`, a billing issue shifts `category`. The structure never changes.
$ curl http://localhost:5000/tickets/triage \
-H "Content-Type: application/json" \
-d '{"text":"Third time I'm writing: order 4412 hasn't arrived and nobody answers. If you don't fix it by tomorrow I'm canceling everything."}'
# response: valid JSON, always with this shape
{
"category": "shipping",
"priority": "urgent",
"summary": "Order 4412 not delivered, customer without responses for days.",
"language": "en",
"churnRisk": true
}🔎 Behind the scenes: what actually goes to Ollama
What happens in that single call? `GetResponseAsync<T>` generates the schema with `AIJsonUtilities`, sets it as response format and the provider translates it into the backend's dialect: for Ollama it ends up in the `format` field of the request, and the runtime discards tokens that would violate the schema.
With `.UseLogging()` you see both halves of the contract in the logs: the schema sent and the raw JSON received. If a field always comes back empty, the cause is almost always there: a vague `[Description]` or an ambiguous property.
Schema sent and JSON received: with UseLogging both halves of the contract are traced.
🛡️ Limits, pitfalls and plan B
Structured output guarantees form, not truth: perfectly valid JSON can contain a wrong summary or a debatable priority. Domain validation is still your job, after deserialization.
And not all providers support native schema. For these cases `GetResponseAsync<T>` has a built-in plan B: with `useJsonSchemaResponseFormat: false` it asks for plain JSON and injects the schema into the prompt — fewer guarantees, same typed signature.
- Form ≠ truth: schema guarantees fields and types, not content correctness — validate business rules after parsing.
- Flat and small schemas: few fields, little nesting; with deeply nested structures smaller models' quality degrades fast.
- Enum as valve: always plan for an `Other`/`Unknown` value, so model uncertainty has a legitimate place to land.
- Targeted retry: if `TryGetResult` returns `false`, a single retry at lower temperature fixes most cases.
- Streaming: structured output is designed for complete responses; if you need text token by token, it's the wrong use case.
using Microsoft.Extensions.AI;
using TriageDemo.Models;
// Provider without native schema? useJsonSchemaResponseFormat: false asks for
// plain JSON and injects schema in the prompt: fewer guarantees, same signature.
var response = await chat.GetResponseAsync<TicketTriage>(
messages,
useJsonSchemaResponseFormat: false,
cancellationToken: ct);
// Targeted retry: a second attempt at lower temperature covers most
// parsing failures.
if (!response.TryGetResult(out var triage))
{
var retry = await chat.GetResponseAsync<TicketTriage>(
messages,
new ChatOptions { Temperature = 0 },
cancellationToken: ct);
triage = retry.TryGetResult(out var second) ? second : null;
}Structured output · Microsoft Learn ↗
✅ Final checklist and next steps
Let me recap the path. If you followed the steps, now you have a Minimal API .NET where a local LLM transforms free text into typed C# records, with enum guaranteeing allowed values and MongoDB downstream.
From here you can level up: combine structured output and function calling in the same assistant — tools fetch data, schema packages the decision — or process tickets in batch. The architecture doesn't change: you depend on interfaces, not providers.
- 01Ollama + modelllama3.1; native structured output
- 02Project + NuGetExtensions.AI + OllamaSharp + Driver
- 03appsettings.jsonOllama and Mongo
- 04AddTriageIChatClient without tool loop
- 05TicketTriagerecord + enum + [Description]
- 06TriageServiceGetResponseAsync<T> + TryGetResult
- 07Endpoint + Mongo422 on failure, typed insert
- 08Plan Bschema in prompt + retry at T=0
From here: structured output + tool in the same assistant, or batch triage.
Frequently asked questions about structured output .NET
What is LLM structured output?
It's the ability to constrain the model's response to a JSON Schema you decide: fields, types and allowed values. It's not a request in the prompt but a constraint applied during generation: the result is always valid JSON, deserializable into your type.
What's the difference between structured output and function calling?
Function calling makes the model execute functions in your code; structured output constrains the form of the final reply. They combine well: tools fetch data, schema packages the decision in a type.
How do you use GetResponseAsync<T> in .NET?
It's an extension method from Microsoft.Extensions.AI on IChatClient: you call chat.GetResponseAsync<YourType>(messages) and the library generates the JSON Schema from the type, sets it as response format and deserializes the response. Read the result with TryGetResult or the Result property.
Does Ollama support structured output?
Yes: it accepts a JSON Schema in the format field of the request and constrains generation accordingly, with any generalist model (llama3.1, qwen3, mistral). With Microsoft.Extensions.AI and OllamaSharp the schema is compiled and sent automatically.
Is Result or TryGetResult better for reading the typed reply?
TryGetResult: it returns false if JSON isn't deserializable, without throwing. The Result property throws an exception in that case. At the boundary with a model, parsing failure is an expected case to handle, not an unexpected exception.
Does structured output guarantee that data is correct?
No: it guarantees form (fields, types, enum), not content truth. Valid JSON can contain an imprecise summary or debatable priority: business rules must be validated after deserialization, like any external input.
Let's talk
If this topic is relevant to you, write to me: comparing notes on code and AI is always time well spent.


