Prose is great for a chat window and terrible for a program. The moment you want an agent’s output to drive code — populate a form, branch on a category, call another service — you need structured output: reliable, parseable, ideally strongly-typed data instead of a paragraph. “Please respond in JSON” in the prompt is not reliable enough for production. This article covers the techniques that actually are, in C#.
If you’re new to agents, start with the tool-using agent tutorial.
Why prompting for JSON isn’t enough
Ask a model to “return JSON” and most of the time it will — but “most of the time” is a bug generator. It occasionally wraps the JSON in markdown fences, adds a chatty preamble (“Sure! Here’s the JSON:”), or emits a trailing comma. Your JsonSerializer.Deserialize then throws in production, at 2 a.m., on an input you didn’t test. You need a mechanism that constrains the output, not a polite request.
The failure rate is what makes this insidious. It isn’t 20%, which you would catch in testing — it’s closer to a fraction of a percent, which passes every review and then produces a steady trickle of JsonException in your logs, weighted towards exactly the unusual inputs you most wanted the feature to handle. Teams typically respond by writing a fence-stripper and a regex that finds the first {, which raises the success rate enough to hide the problem for another quarter. That code is a symptom. Delete it and constrain the decoder instead.
Technique 1: Ask for a typed result directly
The cleanest option is to let the framework do the work. Microsoft.Extensions.AI supports requesting a strongly-typed response: you give it your C# type, it generates a JSON schema from that type, sets that schema on the request, and deserialises the result back into your object.
public record SupportTicket(
string Category, // e.g. "billing", "technical", "account"
string Priority, // "low" | "medium" | "high"
string Summary);
var result = await chatClient.GetResponseAsync<SupportTicket>(
"Classify this message: 'I was charged twice this month and I'm furious.'");
SupportTicket ticket = result.Result; // a real C# object, not a string
Console.WriteLine($"{ticket.Category} / {ticket.Priority}");
You get a SupportTicket you can use immediately — no manual parsing, no fence-stripping. Under the hood it builds a JSON schema from T and sets it on ChatOptions.ResponseFormat, so the model is constrained to your shape rather than politely asked.
The detail that catches people out: .Result throws when the output doesn’t parse. That’s the right default for a batch job and the wrong one for a request-scoped API, where you’d rather degrade than 500. There’s a non-throwing variant, and it’s worth using anywhere a failed parse should not become an exception:
if (!result.TryGetResult(out SupportTicket? ticket))
{
// Fall back, retry, or route to a human. result.Text still holds
// whatever the model actually said, which is what you want to log.
logger.LogWarning("Unparseable classification: {Raw}", result.Text);
return TriageResult.NeedsHuman;
}
Log result.Text when this happens. Nine times out of ten it tells you immediately what went wrong — a truncated object, a refusal, or a model that decided to add an explanatory sentence — and none of those are visible from the exception alone.
The Agent Framework exposes the same idea one layer up: RunAsync<T> on AIAgent returns an AgentResponse<T>, so an agent with tools can still hand you a typed object at the end of its run.
Technique 2: Provider-enforced JSON schema
When you need a raw JSON object rather than a mapped type — the shape isn’t known at compile time, the schema came from configuration, or you’re passing the JSON straight through to another service — set the response format explicitly:
using Microsoft.Extensions.AI;
var options = new ChatOptions
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<SupportTicket>()
};
There are three variants worth knowing. ChatResponseFormat.Text is the default. ChatResponseFormat.Json asks for some valid JSON with no particular shape, which is almost never what you want — it stops the markdown fences but permits any object at all. ChatResponseFormat.ForJsonSchema is the one that actually constrains structure, and it also accepts a raw schema as a JsonElement with a name and description, for the cases where no .NET type exists.
Two limitations to plan around. The response-format path doesn’t handle primitives or bare arrays — a schema whose root is string or string[] isn’t supported, so wrap it:
// Not supported as a root schema: List<string>
// Supported:
public class MovieList
{
public List<string> Movies { get; set; } = [];
}
And provider “strict” schema modes only accept a subset of JSON Schema. The exact subset varies, but the common restrictions are that every property must be listed as required (express optionality as a nullable type rather than an absent field), additional properties must be disallowed, and exotic constructs — deep oneOf unions, regex pattern, most format values — may be rejected outright. You find this out via a 400 from the provider at request time, not at compile time, so validate your schema against your actual provider early rather than the week before launch.
Technique 3: Function calling as structured output
There’s a neat trick: a tool’s parameters are a schema. If you define a tool whose arguments match the structure you want and force the model to call it, the model’s tool-call arguments are your structured data — validated against the tool’s schema. This works well on providers with solid function-calling but weaker dedicated JSON modes, and it composes naturally if you’re already using tools.
It’s also the pragmatic answer when you need structure during a run rather than at the end of one. A typed final response gives you one object after the agent finishes; a tool call gives you a structured payload at the moment the model decides something, which is what you want if the structured value needs to be persisted or acted on mid-conversation.
The failure mode nobody warns you about: silent fallback
This is the most important paragraph in the article.
Typed responses take an optional useJsonSchemaResponseFormat parameter, and it defaults to true. When it’s true, the schema is handed to the provider’s native structured-output mode and the model is genuinely constrained. When the provider doesn’t support that mode — an older deployment, a smaller model, a self-hosted endpoint, a provider whose client hasn’t wired it up — the library falls back to describing the schema in the prompt and asking nicely.
That fallback is a reasonable engineering decision and a genuine trap in production, because your code looks identical in both cases. Same method call, same typed result, same green tests. What changes is the reliability: you’ve silently reverted to technique zero, the one this article opened by rejecting, and you’ll discover it as a slow drip of parse failures that correlate with nothing you can see.
Two habits protect you. First, verify at integration-test time that your specific model deployment actually enforces the schema — send a prompt engineered to tempt the model out of shape and confirm it can’t oblige. Second, treat a change of model or deployment as a change that needs re-testing, not a config tweak. Downgrading a model to save cost is the single most common way teams lose schema enforcement without noticing.
Always validate — constraints reduce, don’t eliminate, risk
Even with schema enforcement, treat the output as untrusted until you’ve checked it. Schema-valid JSON can still be semantically wrong: a Priority of "urgent" when your enum only allows low/medium/high, a date in the wrong format, a summary that references a customer who doesn’t exist, a total that doesn’t equal the sum of the line items.
Here’s the pattern that holds up, with the retry bounded:
public async Task<SupportTicket?> ClassifyAsync(string message, CancellationToken ct)
{
string prompt = $"Classify this message: {message}";
for (int attempt = 0; attempt < 2; attempt++) // one retry, no more
{
var response = await chatClient.GetResponseAsync<SupportTicket>(prompt, cancellationToken: ct);
if (!response.TryGetResult(out SupportTicket? ticket))
{
prompt = $"{prompt}\n\nYour previous reply was not valid JSON for the requested shape.";
continue;
}
if (Enum.TryParse<Priority>(ticket.Priority, ignoreCase: true, out _))
return ticket;
prompt = $"{prompt}\n\nPriority must be one of: low, medium, high. " +
$"You returned '{ticket.Priority}'.";
}
return null; // caller decides: default, queue for a human, or fail the request
}
Models are usually good at fixing a mistake they’re told about, so the first retry converts most failures into successes. The second rarely does, which is why the loop caps at two — an unbounded retry against a stubborn model burns latency and tokens on a request that was never going to succeed. (See cost control.)
One more check worth adding: look at the finish reason before you look at the JSON. If generation stopped because it hit the output-token limit, you have a truncated object, and no amount of retrying the same prompt will fix it — the fix is a bigger limit or a smaller schema. Diagnosing that as “the model is bad at JSON” has cost more than one team a week.
Keep schemas small and specific
The model conforms better to a tight schema than a sprawling one. Prefer enums over free text where you can, keep nesting shallow, and describe each field.
There’s a cost argument too. The schema is serialised into every request, so it’s input tokens you pay for on each call — a schema with forty fields and long descriptions can easily be larger than the content you’re classifying. That’s cheap per call and expensive at volume, and it’s the sort of overhead that never shows up in a code review because nobody sees the wire format. If your token metrics show input tokens that dwarf your actual inputs, the schema is usually the reason.
The other reason to keep it small is accuracy. A single call asked to fill twenty semi-related fields will do several of them badly; two calls with focused schemas usually beat it, and they fail independently so you can retry only the half that went wrong. Split when the fields stop being about the same thing.
When not to bother
Structured output is the wrong tool when the answer is genuinely prose — a written explanation, a drafted email, a summary a human will read. Forcing that through a schema gives you a JSON object with one long string in it, which is strictly worse than plain text.
It also fights with streaming. A typed result can only be materialised once the whole response has arrived, so if your UX depends on tokens appearing as they’re generated, you’ll have to assemble the full response before deserialising and accept that the structured part appears all at once at the end. The usual compromise is to stream the human-facing prose and make a second, cheap, structured call for the machine-facing fields.
Note: the
Microsoft.Extensions.AIand Agent Framework APIs in this area are stabilising but still move between releases, and several Agent Framework packages ship as prerelease. Verify signatures against the current .NET AI docs and the Agent Framework structured-output guide. The techniques — typed responses, provider-enforced schemas, function-calling-as-schema, validate-and-retry — are the durable part.
Takeaway
Don’t ask an agent for JSON and hope. Constrain it: request a strongly-typed result and let the framework generate the schema and deserialise, use ChatResponseFormat.ForJsonSchema when you need raw objects, or exploit function calling as a structured-output channel.
Then do the three things that separate a demo from a system. Confirm your actual model deployment enforces the schema rather than silently falling back to prompting. Validate semantically and retry exactly once with the error fed back, then give up gracefully. And check the finish reason before you blame the model for malformed JSON. That’s the difference between an agent whose output you can read and one whose output your code can depend on.
Next: text-to-SQL in .NET for a demanding application of the same technique.
Have a correction or a topic you want covered? Email mani.bc72@gmail.com.
