# Agent Framework structured output in C# with Claude

> Get a typed C# record from an Agent Framework agent on Claude with RunAsync<T>, then check what the schema can't.

Published: 25 Oct 2026
Updated: 27 Oct 2026
Series: Microsoft Agent Framework in C#, part 7
URL: https://kootstra.io/writing/structured-output-ai-agent-csharp/

**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](https://github.com/nkootstra/samples/tree/main/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#](https://kootstra.io/writing/tool-calling-ai-agents-csharp/), and met the planner and the household's rules in [Planner, then reviewer: two agents in one workflow](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/).

> [!NOTE]
> The code runs on Claude Haiku 5.5, but it depends only on `IChatClient`: [the first post](https://kootstra.io/writing/microsoft-agent-framework-first-csharp-agent/#swap-the-provider-by-changing-one-statement) 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:

```bash
dotnet new console -n KitchenPlan
cd KitchenPlan
dotnet add package Microsoft.Agents.AI --version 1.24.0
dotnet add package Microsoft.Agents.AI.Anthropic --version 1.24.0
```

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

```csharp
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

[Description("Dinners for the days the household asked about.")]
sealed record DinnerPlan(
    [property: Description("One dinner per requested day, in order.")]
    List<Dinner> Dinners);

sealed record Dinner(
    DayOfWeek Day,
    [property: Description("The dish in a few words.")]
    string Dish,
    [property: Description("Minutes from start to plate.")]
    [property: Range(1, 240)] // [!code highlight]
    int Minutes,
    [property: Description(
        "Main ingredients as plain nouns, for example salmon.")]
    List<string> MainIngredients);
```

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:

```csharp
using System.Text.Json;
using Anthropic;
using Anthropic.Core;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
    ?? throw new InvalidOperationException("Set ANTHROPIC_API_KEY.");

IChatClient chatClient =
    new AnthropicClient(new ClientOptions { ApiKey = apiKey })
        .AsIChatClient("claude-haiku-5-5", 4096);

ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<DinnerPlan>(
    AgentAbstractionsJsonUtilities.DefaultOptions);
Console.WriteLine(format.Schema);
```

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](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/) 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:

```text
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "description": "Dinners for the days the household asked about.",
  "type": "object",
  "properties": {
    "dinners": {
      "description": "One dinner per requested day, in order.",
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "day": {
            "type": "string",
            "enum": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
          },
          "dish": {
            "description": "The dish in a few words.",
            "type": "string"
          },
          "minutes": {
            "description": "Minutes from start to plate.",
            "type": "integer",
            "minimum": 1,
            "maximum": 240
          },
          "mainIngredients": {
            "description": "Main ingredients as plain nouns, for example salmon.",
            "type": "array",
            "items": { "type": "string" }
          }
        },
        "required": ["day", "dish", "minutes", "mainIngredients"]
      }
    }
  },
  "required": ["dinners"]
}
```

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](https://kootstra.io/writing/agent-framework-workflow-planner-reviewer/), 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.

```csharp
AIAgent planner = chatClient.AsAIAgent(
    name: "planner",
    instructions: """
        You plan dinners for one household. Plan one dinner
        for each day in the request.
        """);

AgentSession session = await planner.CreateSessionAsync();
AgentResponse<DinnerPlan> response = await planner.RunAsync<DinnerPlan>( // [!code highlight]
    "Dinners for Monday, Wednesday and Saturday. We have salmon.",
    session);
```

`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:

```json
"minutes": {
  "description": "Minutes from start to plate.\n\n{minimum: 1, maximum: 240}",
  "type": "integer"
}
```

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:

```csharp
for (int attempt = 1; ; attempt++)
{
    Console.WriteLine(
        $"[{response.Usage?.InputTokenCount} in, "
        + $"{response.Usage?.OutputTokenCount} out, "
        + $"finish: {response.FinishReason}]");

    if (response.FinishReason != ChatFinishReason.Stop) // [!code highlight]
    {
        Console.WriteLine("No complete plan, so it is not read.");
        break;
    }

    DinnerPlan plan;
    try
    {
        plan = response.Result;
    }
    catch (Exception ex)
        when (ex is JsonException or InvalidOperationException)
    {
        Console.WriteLine($"Not a dinner plan: {ex.Message}");
        break;
    }

    foreach (Dinner dinner in plan.Dinners)
    {
        Console.WriteLine(
            $"{dinner.Day}: {dinner.Dish}, {dinner.Minutes} min "
            + $"({string.Join(", ", dinner.MainIngredients)})");
    }

    List<string> problems = HouseholdRules.Check(plan);
    if (problems.Count == 0)
    {
        Console.WriteLine("The plan keeps every rule.");
        break;
    }

    foreach (string problem in problems)
    {
        Console.WriteLine($"- {problem}");
    }

    if (attempt == 3)
    {
        Console.WriteLine("Still breaking the rules after three plans.");
        break;
    }

    response = await planner.RunAsync<DinnerPlan>(
        "Fix these problems and return the whole plan:\n"
            + string.Join("\n", problems),
        session);
}
```

`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:

```text
[634 in, 100 out, finish: length]
No complete plan, so it is not read.
```

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

```text
[634 in, 100 out, finish: length]
Not a dinner plan: Expected end of string, but instead reached end of data. Path: $ | LineNumber: 0 | BytePositionInLine: 214.
```

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](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#keep-reasoning-in-thinking-blocks). 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:

```csharp
sealed class DinnerPlan
{
    public List<Dinner> Dinners { get; init; } = [];
}
```

`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:

```text
[60 in, 781 out, finish: stop]
Not a dinner plan: '*' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.
```

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`:

```csharp
static class HouseholdRules
{
    static readonly string[] Fish =
    [
        "salmon", "cod", "tuna", "haddock", "mackerel", "trout", "fish",
        "shrimp", "prawn", "crab", "mussel", "squid",
    ];

    public static List<string> Check(DinnerPlan plan)
    {
        List<string> problems = [];

        foreach (Dinner dinner in plan.Dinners)
        {
            if (dinner.MainIngredients.Any(i =>
                i.Contains("peanut", StringComparison.OrdinalIgnoreCase)))
            {
                problems.Add($"{dinner.Day}: contains peanuts.");
            }

            if (dinner.Minutes < 1)
            {
                problems.Add($"{dinner.Day}: takes {dinner.Minutes} minutes.");
            }

            bool weekday = dinner.Day
                is not (DayOfWeek.Saturday or DayOfWeek.Sunday);
            if (weekday && dinner.Minutes >= 40)
            {
                problems.Add($"{dinner.Day}: takes {dinner.Minutes} " +
                    "minutes, weekday dinners must take under 40.");
            }
        }

        List<DayOfWeek> fishDays = plan.Dinners
            .Where(d => d.MainIngredients.Any(i => Fish.Any(f =>
                i.Contains(f, StringComparison.OrdinalIgnoreCase))))
            .Select(d => d.Day)
            .ToList();
        if (fishDays.Count > 1)
        {
            problems.Add($"Fish on {string.Join(", ", fishDays[..^1])} " +
                $"and {fishDays[^1]}: at most once a week.");
        }

        return problems;
    }
}
```

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

```text
[634 in, 216 out, finish: stop]
Monday: Honey-glazed salmon with roasted vegetables, 35 min (salmon, carrot, zucchini, honey)
Wednesday: Salmon and vegetable stir-fry with rice, 25 min (salmon, bell pepper, broccoli, rice, soy sauce)
Saturday: Pan-seared salmon with lemon herb potatoes, 40 min (salmon, potato, lemon, green beans)
- Fish on Monday, Wednesday and Saturday: at most once a week.
[892 in, 336 out, finish: stop]
Monday: Honey-glazed salmon with roasted vegetables, 35 min (salmon, carrot, zucchini, honey)
Wednesday: Chicken and vegetable stir-fry with rice, 25 min (chicken, bell pepper, broccoli, rice, soy sauce)
Saturday: Lemon herb roast chicken thighs with potatoes, 50 min (chicken thigh, potato, lemon, green beans)
The plan keeps every rule.
```

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](https://kootstra.io/writing/microsoft-agent-framework-first-csharp-agent/), 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:

```text
[57 in, 164 out, finish: stop]
Monday: Salmon with Quinoa and Roasted Vegetables, 45 min (salmon, quinoa, vegetables)
Wednesday: Baked Salmon with Lemon and Herbs, 35 min (salmon, lemon, herbs)
Saturday: Grilled Salmon with Avocado Salsa, 40 min (salmon, avocado, salsa)
- Monday: takes 45 minutes, weekday dinners must take under 40.
- Fish on Monday, Wednesday and Saturday: at most once a week.
[264 in, 161 out, finish: stop]
Monday: Spinach and Feta Stuffed Chicken Breast, 25 min (chicken breast, spinach, feta cheese)
Wednesday: Roasted Vegetable Soup, 30 min (vegetables, broth, cream)
Saturday: Grilled Shrimp with Roasted Sweet Potatoes and Green Beans, 35 min (shrimp, sweet potatoes, green beans)
The plan keeps every rule.
```

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`:

```csharp
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

static class SchemaInPrompt
{
    public static async Task<AgentResponse<T>> RunAsync<T>(
        AIAgent agent, string message, AgentSession? session = null)
    {
        ChatResponseFormatJson format = ChatResponseFormat.ForJsonSchema<T>(
            AgentAbstractionsJsonUtilities.DefaultOptions);

        AgentResponse response = await agent.RunAsync(
            $"""
            {message}

            Respond with a JSON value conforming to the following schema:
            {format.Schema}
            Reply with the JSON only, without a code fence.
            """,
            session,
            new AgentRunOptions { ResponseFormat = ChatResponseFormat.Json });

        return new AgentResponse<T>( // [!code highlight]
            response, AgentAbstractionsJsonUtilities.DefaultOptions);
    }
}
```

`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:

```text
[390 in, 231 out, finish: stop]
Monday: Pan-seared salmon with lemon butter green beans, 25 min (salmon, green beans, lemon, butter, garlic)
Wednesday: Salmon teriyaki rice bowls with cucumber, 30 min (salmon, rice, cucumber, soy sauce, ginger, honey)
Saturday: Baked salmon with roasted potatoes and dill yogurt, 50 min (salmon, potatoes, yogurt, dill, olive oil)
- Fish on Monday, Wednesday and Saturday: at most once a week.
[993 in, 348 out, finish: stop]
Monday: Pan-seared salmon with lemon butter green beans, 25 min (salmon, green beans, lemon, butter, garlic)
Wednesday: Chicken stir-fry with peppers over rice, 30 min (chicken thigh, bell pepper, rice, soy sauce, ginger, honey)
Saturday: Beef and potato tacos with lime slaw, 45 min (ground beef, potatoes, cabbage, lime, tortillas, sour cream)
The plan keeps every rule.
```

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