In this article

🧊 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.

JSON asked in the prompt vs. structured output

«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.

The record is the schema
From C# record to JSON Schema: on the left the record TicketTriage with [Description] attributes and typed properties (Category enum, Priority enum, Summary string, Language string, ChurnRisk bool); an arrow shows how GetResponseAsync on TicketTriage generates the JSON Schema on the right, with properties, allowed enum values and required.

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.
Architecture: Minimal API + Ollama + MongoDB
Architecture of the triage Minimal API: the POST /tickets/triage endpoint passes ticket text to TriageService, which calls IChatClient (Ollama via OllamaSharp) with the JSON Schema generated from the TicketTriage record; the constrained JSON is deserialized into the record and saved in the tickets collection of MongoDB.

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.

Download a model and start Ollama
# 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
  }'

Structured outputs · Ollama

The format field accepts a JSON Schema: this is what Microsoft.Extensions.AI fills in for you.

🧱 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.

Create the project and add packages
# 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.Driver

Structured output · Microsoft Learn

GetResponseAsync<T> is in Microsoft.Extensions.AI package, not in Abstractions alone.

⚙️ 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.

appsettings.json
{
  "Ollama": {
    "Endpoint": "http://localhost:11434",
    "ChatModel": "llama3.1"
  },
  "Mongo": {
    "ConnectionString": "mongodb://localhost:27017",
    "Database": "helpdesk"
  }
}
Model and host go here: change config without recompiling.

🧩 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.

TriageServiceExtensions.cs
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;
    }
}

OllamaSharp · GitHub

OllamaApiClient implements IChatClient: the rest of the code depends only on the abstraction.

📐 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.

TicketTriage.cs
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);
Enum become the closed list of allowed values: no invented categories.

🤖 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.

TriageService.cs
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

One typed call: schema generated from the record, response deserialized into the record.

🚪 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.

TriageEndpoints.cs + Program.cs
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();
Enum saved as readable strings on Mongo, but typed in every downstream LINQ query.

🧪 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.

Test the triage
$ 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
}
Free text in, guaranteed form out: the schema makes the contract stick.

🔎 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.

The contract in the logs
Terminal showing the flow: POST /tickets/triage request arrives, log shows the JSON Schema sent to Ollama in the format field (with enum values of category and priority), then the constrained raw JSON returned by the model and finally the document saved in the tickets collection of MongoDB.

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.
Plan B: schema in prompt and targeted retry
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

Same typed signature even without native schema; retry at temperature 0 is the last net.

✅ 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.

The tutorial in 8 moves
  1. 01
    Ollama + modelllama3.1; native structured output
  2. 02
    Project + NuGetExtensions.AI + OllamaSharp + Driver
  3. 03
    appsettings.jsonOllama and Mongo
  4. 04
    AddTriageIChatClient without tool loop
  5. 05
    TicketTriagerecord + enum + [Description]
  6. 06
    TriageServiceGetResponseAsync<T> + TryGetResult
  7. 07
    Endpoint + Mongo422 on failure, typed insert
  8. 08
    Plan 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.

All articles