---
title: "Get LLM structured output into a C# record"
url: https://recursiveai.org/onellm/cookbook/structured-output-to-a-csharp-record/
description: "LLM structured output in C#: OneLLM builds a JSON Schema from your record, sends it as native structured output and returns a typed value. Free, open source."
version: "1.0.0"
---

# Get LLM structured output into a C# record

**OneLLM** is a free, open-source LLM and agent library for .NET from Recursive AI. MIT or Apache-2.0, no native dependencies.

[NuGet](https://www.nuget.org/packages/RecursiveAI.OneLlm) · [GitHub](https://github.com/recursive-ai-oss/onellm-csharp) · [Docs](https://recursiveai.org/onellm/docs/)

Structured output makes a model answer in JSON that follows a schema, so your code gets data rather than prose. OneLLM builds the schema from a C# record and parses the answer back into it.

```shell
dotnet add package RecursiveAI.OneLlm
```

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

```csharp
using System.Text.Json.Serialization;
using OneLlm;

Invoice inv = await Llm.AskAsync(
    "gpt-6-luna",
    "ACME Ltd billed 120.50 in fees.",
    AppJson.Default.Invoice);
Console.WriteLine(inv);

public sealed record Invoice(
    string Customer, decimal Total);

[JsonSerializable(typeof(Invoice))]
public sealed partial class AppJson
    : JsonSerializerContext;
```

**[Download this example (.zip)](https://recursiveai.org/onellm/cookbook/structured-output-to-a-csharp-record/structured-output-to-a-csharp-record.zip)**: unzip, then `dotnet run`.

Prints `Invoice { Customer = ACME Ltd, Total = 120.50 }`. To try it without a key, download the example: it runs as is, with OneLLM’s scripted provider answering in the model’s place, and that is how we ran everything on this page. To call OpenAI, set your key first, then run the snippet:

```shell
export OPENAI_API_KEY="sk-..."         # macOS, Linux
$env:OPENAI_API_KEY = "sk-..."        # Windows PowerShell
```

`gpt-6-luna`, `claude-sonnet` and `gemini-flash` are names and aliases from OneLLM’s [model catalog](https://recursiveai.org/onellm/docs/spec-catalog/), which maps each one to a provider’s model and its prices.

## The full program

This is the `Program.cs` in the download. It extracts an order from a customer’s email (`email.txt`) into a record with a nested list. The scripted provider answers with the JSON in `answer.json`, and the program then prints the schema OneLLM sent.

```csharp
using System.Text.Json.Serialization;
using OneLlm;
using OneLlm.Testing;

// Runs without a key: the scripted provider answers as gpt-6-luna would, priced from the catalog.
// To call OpenAI, set OPENAI_API_KEY and delete these five lines and the Enqueue line.
var openai = new ScriptedProvider("openai");
var runtime = new Runtime();
runtime.Providers.Set("openai", openai);
Llm.Default = runtime;
openai.Enqueue(ScriptStep.TextWithUsage(File.ReadAllText("answer.json"), 412, 61));

string email = File.ReadAllText("email.txt");
AskResult<Order> result = await Llm.GenerateAsync("gpt-6-luna",
    $"Extract the order from this email.\n\n{email}", [], AppJson.Default.Order);

Order order = result.Value;
Console.WriteLine($"{order.Customer}, {order.Lines.Count} lines, {order.Currency} {order.Total}");
foreach (OrderLine line in order.Lines)
    Console.WriteLine($"  {line.Quantity} x {line.Item} @ {line.UnitPrice}");
Console.WriteLine($"Rush: {order.Rush}, cost of the call: ${result.Cost.Total}");

// The JSON Schema OneLLM built from the record and sent with the request:
var format = openai.Requests[0].ResponseFormat!;
Console.WriteLine($"{format.Kind} '{format.SchemaName}', strict: {format.Strict}");
Console.WriteLine(format.Schema!.ToJsonString());

public sealed record Order(string Customer, string Currency, List<OrderLine> Lines, decimal Total, bool Rush);
public sealed record OrderLine(string Item, int Quantity, decimal UnitPrice);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Order))]
public sealed partial class AppJson : JsonSerializerContext;
```

`dotnet run` prints:

```text
Harbor Cafe, 2 lines, USD 192.00
  12 x house blend coffee, bag @ 14.50
  3 x paper filters, box @ 6.00
Rush: True, cost of the call: $0.0000717
JsonSchema 'Order', strict: True
{"type":"object","properties":{"customer":{"type":"string"},"currency":{"type":"string"},"lines":{"type":"array","items":{"type":"object","properties":{"item":{"type":"string"},"quantity":{"type":"integer"},"unitPrice":{"type":"number"}},"required":["item","quantity","unitPrice"],"additionalProperties":false}},"total":{"type":"number"},"rush":{"type":"boolean"}},"required":["customer","currency","lines","total","rush"],"additionalProperties":false}
```

`GenerateAsync` returns the value with its usage and cost. Its third argument is a list of attachments, such as images or PDFs (`Asset.File("scan.png")`); `[]` sends none. Here the cost is 412 input and 61 output tokens of `gpt-6-luna`, priced from the catalog. The schema names every property `required` and closes each object with `"additionalProperties": false`, and the request asks for strict mode. The JSON names start with a lowercase letter (`customer` for the `Customer` property) because `AppJson` sets `PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase`.

If the answer isn’t valid JSON for the type, the call throws `StructuredOutputException` naming the type (see the variations). To call OpenAI for real, set `OPENAI_API_KEY` and delete the five scripted lines at the top.

## Variations

Add each snippet to the full program: its statements go after the program’s last statement, and any record or class goes at the end of the file. The output shown is what the snippet added.

**Without a `JsonSerializerContext`.** `AskAsync<T>` finds the type by reflection. It’s shorter, but the docs say it “is not trim or AOT safe”:

```csharp
openai.EnqueueText(File.ReadAllText("answer.json"));
Order viaReflection = await Llm.AskAsync<Order>("gpt-6-luna", $"Extract the order.\n\n{email}");
Console.WriteLine(viaReflection.Total);
```

```text
192.00
```

**The same call on Claude or Gemini.** Only the model name changes, and each provider gets the schema through its own structured output feature. The first two lines register scripted providers for both; with real keys (`ANTHROPIC_API_KEY`, `GEMINI_API_KEY`), leave them out:

```csharp
runtime.Providers.Set("anthropic", new ScriptedProvider("anthropic").EnqueueText(File.ReadAllText("answer.json")));
runtime.Providers.Set("gemini", new ScriptedProvider("gemini").EnqueueText(File.ReadAllText("answer.json")));
foreach (string model in new[] { "claude-sonnet", "gemini-flash" })
{
    AskResult<Order> r = await Llm.GenerateAsync(model, $"Extract the order.\n\n{email}", [], AppJson.Default.Order);
    Console.WriteLine($"{model}: {r.Value.Customer}, {r.Value.Total}");
}
```

```text
claude-sonnet: Harbor Cafe, 192.00
gemini-flash: Harbor Cafe, 192.00
```

**An answer that isn’t JSON**, for example when the model can’t do the task:

```csharp
openai.EnqueueText("I can't find an order in this email.");
try
{
    await Llm.AskAsync("gpt-6-luna", $"Extract the order.\n\n{email}", AppJson.Default.Order);
}
catch (StructuredOutputException e)
{
    Console.WriteLine(e.Message);
}
```

```text
The model's answer is not a valid Order: 'I' is an invalid start of a value. Path: $ | LineNumber: 0 | BytePositionInLine: 0.
```

**Enums, nullable values and dates.** With `UseStringEnumConverter`, an enum becomes a list of allowed names; `DateOnly?` becomes a date string that may be null:

```csharp
openai.EnqueueText("{\"title\":\"Charged twice\",\"priority\":\"High\",\"due\":null}");
Ticket t = await Llm.AskAsync("gpt-6-luna", "Triage: I was charged twice!", TicketJson.Default.Ticket);
Console.WriteLine(t);
Console.WriteLine(openai.Requests[^1].ResponseFormat!.Schema!["properties"]!.ToJsonString());

public enum Priority { Low, Normal, High }
public sealed record Ticket(string Title, Priority Priority, DateOnly? Due);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, UseStringEnumConverter = true)]
[JsonSerializable(typeof(Ticket))]
public sealed partial class TicketJson : JsonSerializerContext;
```

```text
Ticket { Title = Charged twice, Priority = High, Due =  }
{"title":{"type":"string"},"priority":{"enum":["Low","Normal","High"]},"due":{"type":["string","null"],"format":"date"}}
```

## Related recipes

- [Unit test LLM calls in C# without an API key](https://recursiveai.org/onellm/cookbook/test-llm-code-without-api-key/): the scripted provider used here, in tests.
- [Build an AI agent with tool calling in C#](https://recursiveai.org/onellm/cookbook/ai-agent-with-tools-csharp/)
- [Track OpenAI API cost per call in C#](https://recursiveai.org/onellm/cookbook/track-llm-cost-per-call/)
- [Call Ollama from C#](https://recursiveai.org/onellm/cookbook/call-ollama-from-csharp/)

## The short answer

To get structured output from an LLM in C#, declare a record for the answer, list it in a source-generated `JsonSerializerContext`, and call `Llm.AskAsync(model, prompt, AppJson.Default.YourRecord)` from the `RecursiveAI.OneLlm` package. OneLLM builds a JSON Schema from the record, sends it through the provider’s native structured output, and deserializes the answer into your record. `Llm.GenerateAsync` does the same and also returns the usage and cost. An answer that isn’t valid JSON throws `StructuredOutputException`.

OneLLM is free and open source under MIT or Apache 2.0, with no native dependencies. The project’s [getting started guide](https://recursiveai.org/onellm/docs/getting-started/) puts it this way: “The schema comes from the type and is sent through the provider’s native structured output. Passing the source-generated type info keeps the call trim and NativeAOT safe.” The rest of this page answers the questions people ask next.

## What is LLM structured output?

It’s a request option that tells the model to answer with JSON matching a schema you give it, rather than free text. You describe the shape once (field names, types, which fields are required, allowed values) and read the answer as data. Without it, you ask for JSON in the prompt and hope, and a model may wrap the JSON in prose, rename a field or leave one out.

OpenAI’s, Anthropic’s and Gemini’s APIs each have their own form of this feature, with their own limits on which schema keywords they accept. OneLLM’s [tool schema notes](https://recursiveai.org/onellm/docs/tool-schemas/) link each vendor’s list of supported schemas. In C#, the schema has a natural source: the record you want the answer in.

## How does OneLLM turn a C# record into a JSON Schema?

From the record’s properties, by the rules in its [tool signature notes](https://recursiveai.org/onellm/docs/tool-schemas/). The full program above prints a real example. In short:

- `string` becomes `"string"`, `int` becomes `"integer"`, and `decimal` and `double` become `"number"`. `bool` becomes `"boolean"`, and lists and arrays become `"array"` with `items`.
- A nested record becomes a nested object, closed with `"additionalProperties": false`.
- A nullable type adds `"null"` (`DateOnly?` became `{"type":["string","null"],"format":"date"}`).
- An enum becomes its list of names when the context uses the string enum converter.
- Property names follow the context’s naming policy. With `JsonKnownNamingPolicy.CamelCase`, `UnitPrice` becomes `unitPrice`; with no policy, it stays `UnitPrice`.

The `T` you pass must be a JSON object type. The happy-path docs say so (“`T` must be a JSON object type”), so wrap a list in a record rather than asking for a bare array.

## How do you get JSON output from an LLM reliably?

Use the provider’s structured output rather than a prompt, and still validate what comes back. In OneLLM that means:

1. **Ask with a type.** `AskAsync<T>` sends the schema on every call. The request in the full program asked for strict mode (`strict: True`).
2. **Catch `StructuredOutputException`.** It’s thrown when the answer doesn’t parse as your type, for example when a model declines in plain words. The message names the type and the JSON error.
3. **Check values your code depends on.** OneLLM deserializes the answer with System.Text.Json. In our run, a scripted answer that left fields out parsed without an error, with those fields at their defaults. Strict mode asks the provider to follow the schema, but a check like `order.Lines.Count > 0` costs nothing.
4. **Pick a model that supports it.** OneLLM checks the catalog before sending. Its [pricing and request notes](https://recursiveai.org/onellm/docs/pricing-and-counting/) say it refuses with `UnsupportedParameterException` “a JSON schema for a model without structured output”, rather than sending a request that would be ignored.

## Which LLM is best for structured output?

That depends on your data, and the honest test is to run your own examples. OneLLM makes switching cheap: the same record and the same call work across providers, and only the model name changes (`gpt-6-luna`, `claude-sonnet`, `gemini-flash`, as in the variation above).

What we can say is what was tested. OneLLM’s [results](https://recursiveai.org/onellm/docs/results/) list structured output among the cases of a live smoke test at release that passed against OpenAI (8 of 8 cases) and Gemini (7 of 7). Anthropic, Azure OpenAI / Foundry, Amazon Bedrock and Vertex AI were not run live, and are covered by recorded fixtures only.

For a structured answer at the end of a multi-step job, an agent definition can carry an `outputSchema`, and a decider answers typed choice, score and yes/no questions. Both are in the [agents](https://recursiveai.org/onellm/docs/agents/) and [deciders](https://recursiveai.org/onellm/docs/deciders/) docs.
