Back to homepage

Niels Kootstra

Agent Framework structured output in C# with Claude

Summarize with AI

The assistants open a new chat that asks for a short summary of this post.

The idea: a schema constrains the format of a model’s answer, not its content. A typed result still goes through checks in code.

A dinner plan written as prose is fine for a person and useless for code. To put the dinners in a calendar or check them against the household’s rules, the program needs a list of days, dishes, cooking times and ingredients, not a paragraph. Agent Framework can ask the model for exactly that: RunAsync<T> sends a JSON schema generated from a C# record and reads the answer back into that record. The schema settles the format. It does not stop the model from planning salmon three times in one week, so the plan then goes through a plain C# check of the household’s rules, and any problems go back to the model.

Versions: Microsoft Agent Framework 1.24.0, .NET 10.

Code: samples/agent-framework/07-structured-output, a runnable version of this post with its tests.

Who this is for: C# developers who have given an agent tools, as in Tool calling for AI agents in C#, and met the planner and the household’s rules in Planner, then reviewer: two agents in one workflow.

Note

The code runs on Claude Haiku 5.5, but it depends only on IChatClient: the first post shows the swap to OpenAI or Ollama.

How the schema reaches the model depends on the chat client: this post follows Anthropic’s, compares it with OllamaSharp, and shows how to catch a client that ignores the schema. The output limit of 4,096 tokens is sized for Haiku, which counts its reasoning as output.

Describe the answer as a record

A console app with the usual two packages:

The answer the program wants is a plan with one dinner per day. It goes in DinnerPlan.cs:

The [Description] attributes do the same job as on tool parameters: they are written for the model. Day is a DayOfWeek, so the answer can only name a real day. The [Range] on Minutes becomes minimum and maximum in the schema, and the rest of this post follows those two keywords: Claude’s API doesn’t accept them, and the deserializer never checks them.

The schema RunAsync<T> builds

Program.cs starts with the chat client for Claude Haiku 5.5, and then prints the schema that RunAsync<T> hands to the chat client:

The output limit is 4,096 tokens rather than the 1,024 of most earlier posts, because Claude Haiku 5.5 counts its reasoning as output; the workflow post found the reviewer’s answers cut off at 1,024.

ChatResponseFormat.ForJsonSchema<T> from Microsoft.Extensions.AI builds the schema, and AgentAbstractionsJsonUtilities.DefaultOptions are the serializer options that RunAsync<T> uses when it gets none. The schema is a single line in the output and formatted here:

Every description is there, the one on the record type included. The property names are camelCase and the day is a string from a fixed list, because the default options use the web defaults of System.Text.Json and write enums as strings. All four properties are required, because each is a constructor parameter without a default. [Range] became minimum and maximum.

Ask for the type with RunAsync<T>

The planner is an ordinary agent. Like the planner in the workflow post, it doesn’t know the household’s rules: nobody eats peanuts, fish at most once a week, and every weekday dinner takes under 40 minutes.

RunAsync<T> builds the same schema, puts it in the run’s ResponseFormat and runs the agent as usual. It returns an AgentResponse<DinnerPlan>: the normal response, with a Result property that reads the text as a DinnerPlan. A type whose JSON is not an object, such as a List<Dinner>, is wrapped in an object with a single data property for the request and unwrapped again when it is read.

An agent whose every answer needs the same format can carry it in ChatClientAgentOptions.ChatOptions.ResponseFormat instead. The agent uses that format for any run that doesn’t set its own, but RunAsync then returns a plain AgentResponse, and reading the JSON is up to you. There is no streaming counterpart: AIAgent has no RunStreamingAsync<T> in 1.24.0. To stream anyway, set the format in AgentRunOptions.ResponseFormat for RunStreamingAsync, collect the updates into an AgentResponse with ToAgentResponseAsync, and wrap that in an AgentResponse<T>, as the helper at the end of this post does with its response.

RunAsync<T> also takes a serializerOptions argument. The same options build the schema and read Result, so an app that is trimmed or compiled with Native AOT can pass the options of a source-generated JsonSerializerContext there.

Claude gets the schema as a constraint

What happens to the schema depends on the chat client. Anthropic’s AsIChatClient sends it as output_config.format with the type json_schema, Anthropic’s native structured outputs. The API then constrains the model’s decoding to the schema, so the text that comes back is JSON that fits it. The schema is not added to your instructions; Anthropic adds a system prompt of its own that explains the format, and it counts as input. The same request without a schema used 60 input tokens; with it, 634.

Before sending it, the client rewrites the schema to what the API accepts. That is Anthropic’s C# SDK at work (the Anthropic package 12.53.0, which Microsoft.Agents.AI.Anthropic 1.24.0 pulls in), not Agent Framework. Anthropic’s structured outputs don’t support minimum, maximum, minLength, maxLength and several other keywords, and reject them with a 400 error. The client keeps a fixed list of keywords per type, removes everything else and appends it to the description as text, and it adds "additionalProperties": false to every object. It also turns oneOf into anyOf, and moves string formats the API doesn’t support and a minItems above 1 into the description the same way. The minutes property arrives at Anthropic like this:

The range reaches the model as a sentence it may follow, not as a rule the decoder enforces. The $schema line goes the same way, appended to the top-level description.

Check the finish reason before reading Result

The rest of Program.cs is one loop: read the plan, check it, and send the problems back, for at most three plans:

RunAsync<T> never fails because of the JSON. It returns the response, and Result deserializes Text each time it is read. That is where a bad answer shows up: an empty text throws an InvalidOperationException (“The response did not contain JSON to be deserialized.”), JSON that can’t be read as the type throws the JsonException from System.Text.Json, and JSON that reads as null throws an InvalidOperationException too. The loop catches both types, reads Result once and keeps the plan in a variable.

Anthropic’s guarantee that the JSON fits the schema has documented exceptions. Two show in the stop reason: a refusal, which the client reports as ChatFinishReason.ContentFilter, and an answer that hits the output token limit, reported as Length. In both, the text may not match the schema. So the loop checks FinishReason before it touches Result. The third exception, enum values that differ only in capitalization, does no harm here: the default options read "monday" as Monday. With the limit lowered from 4096 to 100, the run stops there:

With the finish-reason check removed as well, the loop reads Result and the cut-off JSON throws:

The catch is still needed when the stop reason is Stop: another provider, or a format the client dropped, can return text that isn’t the plan, or no text at all.

Warning

A property in the schema can be enough for a refusal. On Claude Sonnet 5.5, a free-text property that reads as the model’s scratch work, such as scratchpad or one described as “Your working notes while you plan.”, was refused on every run with the category reasoning_extraction, which Anthropic documents. Opus 5.5 refused scratchpad too; Haiku 5.5 answered. The refusal arrives as ContentFilter, with no text or with JSON that stops partway, so the finish-reason check catches it. A retry was refused again, but describing the property for the reader, as “A short note for the household about the plan.”, got an answer.

A class without a required list loses the schema

Anthropic’s client drops the format without a warning when the schema lacks properties or required at the top level. A plan written in the usual C# class style is enough:

Dinners is a settable property, not a constructor parameter or a required member, so the schema for this class has no required list, and the request goes out without output_config. Haiku answered in prose:

60 input tokens is also what the same request cost without any schema. A constructor parameter, as in the record, or the required keyword on the property puts dinners back in the required list.

The rules belong in code

Nothing so far has checked the content. The deserializer reads "minutes": 500 without complaint, because System.Text.Json doesn’t look at [Range]. Data annotations are checked only by code that asks for it, such as Validator.TryValidateObject, and even that doesn’t look inside the Dinners list unless you validate each dinner yourself. The household’s rules aren’t in the schema at all. A plain C# class checks them, in HouseholdRules.cs:

Each problem is a sentence the model can act on, the same idea as a tool result that says what to do instead. The check is only as good as its inputs: it sees the main ingredients the model listed, so peanuts hidden in a satay sauce or a fish missing from the list get through. Because the plan is typed, this check is ordinary code that a unit test can feed a known-bad plan, without a model.

A run, with one fix

In this run the first plan was valid JSON, read into a DinnerPlan without trouble, and broke the fish rule on all three days. The second request carried the problem in the same session, so the model saw its own plan and kept Monday’s salmon. Saturday’s dinner takes 50 minutes, which is allowed at the weekend. The schema shaped both answers. Only the check in code knew that the first one was wrong.

Going further

Other chat clients

RunAsync<T> has no fallback. It sets ResponseFormat, and nothing on the RunAsync<T> path in Agent Framework 1.24.0 writes the schema into the instructions or the messages. What reaches the model is up to the chat client. Anthropic’s client sends output_config.format. The OpenAI adapter in Microsoft.Extensions.AI.OpenAI 10.10.1 sends it natively too, as a json_schema response format, after a rewrite of its own that also moves minimum and maximum into the description, but without strict unless ChatOptions.AdditionalProperties["strict"] is true, so by default OpenAI isn’t asked to enforce it. OllamaSharp 5.5.0, the community client for Ollama from the first post, sends the schema in Ollama’s format field, which Ollama’s documentation says enforces the schema on the model’s response. With the Ollama version of the chat client from the first post and llama3.2, the same program printed:

A client or model without support is another matter. A comment in the Microsoft.Extensions.AI source says such a model may throw or ignore the schema, and Ollama’s documentation says its cloud currently doesn’t support structured outputs. When the schema is ignored, the model answers in whatever form it likes, RunAsync<T> returns as usual, and the failure shows up only when Result is read.

Put the schema in the prompt yourself

For a provider without native structured output, the schema has to travel as text. Microsoft.Extensions.AI does that for a bare chat client: GetResponseAsync<T> on IChatClient with useJsonSchemaResponseFormat: false sets ChatResponseFormat.Json, a request for JSON without a schema, and adds a user message that starts “Respond with a JSON value conforming to the following schema:”, followed by the schema in a code fence. Agents have no such option in 1.24.0, but the same steps fit in a small helper, in SchemaInPrompt.cs:

AgentResponse<T> has a public constructor, so the helper wraps the plain response, and the loop reads Result as before. With both planner.RunAsync<DinnerPlan>(...) calls in Program.cs replaced by SchemaInPrompt.RunAsync<DinnerPlan>(planner, ...), Haiku gets the schema only as text, because Anthropic’s client has nothing to send for a format without a schema:

The last line of the prompt comes from an earlier run. Without it, Haiku put the JSON in a Markdown code fence, and Result threw '`' is an invalid start of a value. Nothing on this path makes Haiku follow the schema: it is a request, and the answer can still be prose, broken JSON or JSON of the wrong shape. The finish-reason check and the catch in the loop keep prose, broken JSON and an empty answer from being read as a plan. JSON of the wrong shape still gets through, with null, 0 or Sunday wherever a field is missing. An answer of {} reads as a plan whose Dinners is null, so the foreach throws a NullReferenceException, which the catch doesn’t cover, and the program stops with an unhandled exception. Where the provider supports a schema, RunAsync<T> is the better fit: the Microsoft.Extensions.AI documentation says a native schema improves reliability.

Try it

  • Remove the [Description] from Minutes and print the schema again. The property keeps its type and range, and the model no longer learns what the minutes measure.
  • Ask for “Dinners for Monday to Friday. We love satay.” and see whether the check catches the peanuts, or whether they hide in an ingredient called “satay sauce”.