Request a response with structured output from an LLM using Microsoft.Extensions.AI in .NET

By · · AI Engineering

A few months back I was building an LLM-powered app that screened candidate bios for a hiring pipeline: paste in a LinkedIn summary, get back a name, years of experience, primary skill, and a seniority level to route the review to the right person. The only tool I had was the prompt, so I wrote one that spelled out exactly what I wanted:

Prompt:
Extract the candidate's name, years of experience, primary skill, and
seniority level (Junior, Mid, Senior, or Lead) from the text below.
Respond in the format Name: ..., Experience: ..., Skill: ..., Seniority: ...

Bio: Priya Nair has spent the last 7 years building backend services in
Go and Kotlin, most recently leading the checkout team at a mid-sized
retailer.
Response:
Sure, here's the extracted information:
Name: Priya Nair
Experience: 7 years
Skill: Backend development (Go, Kotlin)
Seniority: Senior-level

That's still just text with labels in it. Before I could use any of it, I had to strip the "Sure, here's..." preamble, split on the labels, pull "7 years" apart from the number I actually needed, and match "Senior-level" against my Seniority enum, which only knew about Senior. Every one of those steps was a place my code could break, and regularly did, because the next bio would phrase things just differently enough to slip past whatever I'd hardcoded for the last one. I was tracking a success rate on this step the way you'd track uptime, and it wasn't one I trusted unattended.

Dog sitting calmly at a table while the room burns around him, captioned this is fine

That was me, shipping a demo on a parser I knew would fall over the moment someone's bio didn't read like the last one.

What was actually going wrong

The mistake, in hindsight, was asking the model to describe a shape in English and then reverse-engineering that description back into a real shape myself. The model's job was to understand the bio and decide the seniority. My job, the part I was doing badly, was translating its answer back into a typed object. Those are two different problems, and I was solving the second one with string manipulation, which is exactly the wrong tool for anything that has to survive contact with real, varied human writing.

Microsoft.Extensions.AI removes that translation step entirely, and it does it by inverting who's responsible for the shape. Instead of me describing the shape in the prompt and parsing it back out of prose, I declare the shape as an actual C# type, and the library generates a JSON schema from it, attaches that schema to the request as a hard constraint on the model's output, and deserializes the result straight into an instance of that type before handing it back to me. There's no prose left to strip, no labels to split on, no enum name to fuzzy-match by hand.

Setting it up

You need .NET 8 or later and an Azure OpenAI resource with a chat-capable model deployed (the official quickstart uses gpt-5), and anything that supports structured/constrained decoding will work the same way. Five packages:

dotnet new console -o CandidateScreen
cd CandidateScreen
dotnet add package Azure.AI.OpenAI
dotnet add package Azure.Identity
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.UserSecrets

Credentials belong in user secrets, not a config file checked into source control:

dotnet user-secrets init
dotnet user-secrets set AZURE_OPENAI_ENDPOINT <your-Azure-OpenAI-endpoint>
dotnet user-secrets set AZURE_TENANT_ID <your-tenant-ID>

DefaultAzureCredential picks up whatever's already signed in on your machine: the Azure CLI, Visual Studio, a managed identity if you're running in Azure. So there's no key sitting in plain text anywhere. You'll need the Azure AI Developer role on the account doing the authenticating, and if you're in a single-tenant setup you can usually drop the tenant ID line.

Wiring up the chat client is the same regardless of what you're extracting:

IConfigurationRoot config = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .Build();

string endpoint = config["AZURE_OPENAI_ENDPOINT"];
string tenantId = config["AZURE_TENANT_ID"];
string model = "gpt-5";

AzureOpenAIClient azureClient =
    new(
        new Uri(endpoint),
        new DefaultAzureCredential(new DefaultAzureCredentialOptions() { TenantId = tenantId }));

IChatClient chatClient = azureClient
    .GetChatClient(deploymentName: model)
    .AsIChatClient();

IChatClient is deliberately provider-agnostic: the same interface sits in front of Azure OpenAI, OpenAI directly, or a local model through Ollama. The structured-output method that's about to do the actual work doesn't care which one is behind it.

Declaring the shape instead of describing it

Here's the candidate profile I used to hand-assemble from split strings, now written as what it actually is: a type.

public enum Seniority
{
    Junior,
    Mid,
    Senior,
    Lead
}

public record CandidateProfile(
    string Name,
    int YearsOfExperience,
    string PrimarySkill,
    Seniority Seniority
);

No parsing attributes, no schema-building DSL. The record's shape is the schema. Then the request:

string bio = "Priya Nair has spent the last 7 years building backend services in Go and Kotlin, "
    + "most recently leading the checkout team at a mid-sized retailer.";

var response = await chatClient.GetResponseAsync<CandidateProfile>(
    
quot;Extract a candidate profile from this bio: {bio}"); var profile = response.Result; Console.WriteLine(
quot;Name: {profile.Name}"); Console.WriteLine(
quot;Experience: {profile.YearsOfExperience} years"); Console.WriteLine(
quot;Primary skill: {profile.PrimarySkill}"); Console.WriteLine(
quot;Seniority: {profile.Seniority}");

Which produces something like:

Name: Priya Nair
Experience: 7 years
Primary skill: Backend development (Go, Kotlin)
Seniority: Senior

profile isn't a string I have to go trim and split anymore. profile.YearsOfExperience is already an int. profile.Seniority is already a Seniority.Senior, not the word "senior" that I'd have had to Enum.Parse and hope didn't come back as "Senior-level" instead. The entire block of code I used to write between "the model answered" and "my program can use this" is gone, because there's nothing left to translate.

Todd Howard shrugging and saying it just works

Not quite "it just works" with no caveats — but a lot closer than an Enum.Parse call wrapped in three layers of try/catch.

Running it across more than one bio

A hiring pipeline doesn't screen one candidate at a time by hand, so the real test is running the same extraction over a batch of genuinely different bios and seeing whether the shape holds up when the input stops being tidy:

string[] bios = [
    "Marcus Reid, 2 years into his career, currently a QA engineer picking up test automation.",
    "Led platform engineering for a decade across three companies, deepest expertise in Kubernetes and observability tooling. That's Elena Fischer.",
    "Just graduated, done a couple of internships doing frontend work with React.",
    "Twelve years in enterprise Java, spent the last four as a staff engineer mentoring two teams. Name's David Okafor."
];

foreach (var bio in bios)
{
    var result = await chatClient.GetResponseAsync<CandidateProfile>(
        
quot;Extract a candidate profile from this bio: {bio}"); var p = result.Result; Console.WriteLine(
quot;{p.Name} — {p.Seniority}, {p.YearsOfExperience}y, {p.PrimarySkill}"); }

Some of those bios put the name at the start, some bury it at the end, one never states a number of years directly and one never even uses the word "years" for the recent graduate. None of that mattered. Every response came back as a fully-formed CandidateProfile, because I never asked the model to format an answer. I asked it to fill in a shape, and the shape doesn't care where in the sentence the name happens to sit.

Getting the reasoning alongside the data

Sometimes you want more than the extracted fields: the model's read on the bio in its own words too, alongside the structured data, rather than one or the other. A record with two fields covers that:

record ScreeningResult(string Assessment, CandidateProfile Profile);

string bio2 = "Sana Malik has three years of experience, mostly in mobile development with Swift, "
    + "and recently started contributing to a shared backend service too.";

var result2 = await chatClient.GetResponseAsync<ScreeningResult>(
    
quot;Extract a candidate profile from this bio, and give a one-sentence assessment: {bio2}"); Console.WriteLine(
quot;Assessment: {result2.Result.Assessment}"); Console.WriteLine(
quot;Profile: {result2.Result.Profile.Name}, {result2.Result.Profile.Seniority}");
Assessment: A solid mid-level mobile engineer who is starting to branch into backend work, which is worth noting for full-stack-leaning roles.
Profile: Sana Malik, Mid

Same mechanism, just a wider shape. The model still writes freely in Assessment, which is exactly where prose belongs, while Profile stays fully typed underneath it.

What this doesn't fix

What changed here isn't the model's judgment. If it reads someone with four years of backend experience and calls them "Mid" when your team would call that "Senior," structured output doesn't correct that. It just means you get a clean Seniority.Mid instead of a paragraph you'd have misjudged the exact same way. I still route anything the pipeline flags as Seniority.Lead or Senior through a human reviewer before it moves forward. The type safety kicks in after that judgment call, not instead of it.

It also depends on the model actually supporting constrained decoding. Ask an older or smaller model for a type and you can still technically get a response, but you're more likely to hit a deserialization exception when it drifts off the schema instead of a silently wrong answer, which, to be fair, is a better failure mode than what I had before, but it's not nothing. Test against whatever model you're actually going to ship with.

Why this was worth switching to

Here's the honest version: I didn't get a smarter model out of this. I got rid of a job I was never going to do well (hand-parsing prose back into objects) by not doing it at all. The extraction step went from a pile of string-splitting and defensive TryParse calls with a success rate I was quietly unhappy with, to a single generic type argument on a method call. Less code, less time spent chasing the one bio format that broke my parser this week, and a failure mode I can actually catch instead of one I might not notice until a candidate gets routed to the wrong reviewer.

If you're building anything where an LLM's answer has to become a real object in your program rather than text on a screen, quit writing a prompt that describes the shape and then hand-recovering it in code afterward. Declare the type instead, and let the library hold the model to it.

References