Documentation
Table of Contents

Use cases, before and after.

Most cases start where hand-written code usually starts, with Microsoft.Extensions.AI: a model call, a JSON parse, a check and a retry loop.

12 cases

The code you write by hand, and the IntentSharp version.

Each case puts the careful hand-written version beside the IntentSharp version, with the intent between them. Every line compiles with the site, and the outputs come from the demo program, on a scripted model.

Order triage

For line-of-business teams: rule changes without a release.

Rate how urgent each order email is. The scale, the wording and the examples change as the business learns, and today each change is a build and a deployment. If the scale never changes, the same check works inline with AIAsync<int> and a Require<int> rule; the file is for when it does change. To stop the triage without a release, set enabled: false in the file: the next call throws IntentDisabledException before any model call.

Before
The wording and the scale compiled in, with your own check and retry.
The intent
Orders.ClassifyUrgency, in a file: rate the urgency of an order email from 1 (routine) to 5 (critical).
After
A call by name: the wording, the scale and the rules move into the file.

Before

OrderTriageBefore.cs
// The wording, the examples and the scale are compiled in: changing them means a build and a deployment.private const string Instructions =    "Rate the urgency of this order email from 1 (routine) to 5 (critical). Answer with the number only. " +    "Examples: \"Line 3 is down until the bearings arrive. Ship today.\" is 5; " +    "\"Please restock these when convenient, no rush.\" is 1.";private const int Minimum = 1, Maximum = 5, MaxAttempts = 2; public static async Task<int?> ClassifyUrgencyAsync(IChatClient chat, string email, CancellationToken ct = default){    List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, email)];    for (var attempt = 1; attempt <= MaxAttempts; attempt++)    {        var response = await chat.GetResponseAsync<int>(messages, cancellationToken: ct);        string problem;        if (!response.TryGetResult(out var urgency)) problem = "The answer was not a whole number.";        else if (urgency is < Minimum or > Maximum) problem = $"{urgency} is outside {Minimum} to {Maximum}.";        else return urgency;         // Retry with the finding.        messages.AddMessages(response);        messages.Add(new(ChatRole.User, $"{problem} Answer again with a whole number from {Minimum} to {Maximum}."));    }    return null;   // no valid answer after MaxAttempts}

After

Program.cs
using var scope = intents.AsAmbient();var urgency = await email.RunIntentAsync<int>("Orders.ClassifyUrgency");
Orders.ClassifyUrgency.intent.yamlIntent file
name: Orders.ClassifyUrgencyversion: 1owner: order-deskintent: Rate the urgency of this order email from 1 (routine) to 5 (critical).shape: intrules:  minimum: 1  maximum: 5examples:  - input: "Line 3 is down until the bearings arrive. Ship today."    output: 5  - input: "Please restock these when convenient, no rush."    output: 1
Output
Scripted model answer: {"value": 4}Success: trueValue: 4Issues: none

Invoice intake

For finance and operations apps: rules the model must meet.

Pull the vendor, number, currency and total out of invoice emails, and refuse anything outside the currencies you accept. A wrong currency goes back to the model as a concrete finding. The rule can live in the intent file, as here, or in code with Require<T>; either way the finding drives the next attempt.

Before
Required members, plus hand-written checks and a retry.
The intent
Invoice.Extract, in a file: the vendor, invoice number, currency and total, in USD, EUR or GBP, with no negative total.
After
The rules move into the file; a broken rule is retried.

Before

InvoiceBefore.cs
// Required members: a key missing from the answer fails deserialization instead of becoming a default value.public sealed record Invoice{    public required string Vendor { get; init; }    public required string InvoiceNumber { get; init; }    public required string Currency { get; init; }    public required decimal Total { get; init; }} public static class InvoiceBefore{    private const string Instructions = "Extract the vendor, invoice number, currency and total from this invoice email.";    private static readonly string[] Currencies = ["USD", "EUR", "GBP"];     public static async Task<Invoice?> ExtractAsync(IChatClient chat, string email, CancellationToken ct = default)    {        List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, email)];        for (var attempt = 1; attempt <= 2; attempt++)        {            var response = await chat.GetResponseAsync<Invoice>(messages, cancellationToken: ct);            var problems = new List<string>();            if (!response.TryGetResult(out var invoice) || invoice is null)                problems.Add("The answer was not an invoice object with vendor, invoiceNumber, currency and total.");            else            {                if (!Currencies.Contains(invoice.Currency))                    problems.Add($"currency is \"{invoice.Currency}\"; allowed: {string.Join(", ", Currencies)}.");                if (invoice.Total < 0)                    problems.Add($"total is {invoice.Total}; the minimum is 0.");                if (problems.Count == 0) return invoice;            }             // Retry with the findings.            messages.AddMessages(response);            messages.Add(new(ChatRole.User, "Problems with your answer:\n" + string.Join("\n", problems)));        }        return null;    }}

After

Invoice.Extract.intent.yamlIntent file
name: Invoice.Extractversion: 1owner: accounts-payableintent: Extract the vendor, invoice number, currency and total from this invoice email.shape:  kind: schema  schema:    type: object    properties:      vendor: { type: string }      invoiceNumber: { type: string }      currency: { type: string }      total: { type: number }    required: [vendor, invoiceNumber, currency, total]rules:  properties:    currency:      enum: [USD, EUR, GBP]    total:      minimum: 0
Program.cs
var invoice = await email.RunIntentAsync(intents, "Invoice.Extract");
Output
Scripted model answer (attempt 1, illustrative): {"value": {"vendor": "Contoso Supplies", "invoiceNumber": "INV-2291", "currency": "US$", "total": 1240.00}}[Validator] attempt 1: value.currency is "US$"; allowed: USD, EUR, GBP. The prompt of attempt 2 ends with:## Problems with the previous attempt- value.currency is "US$"; allowed: USD, EUR, GBP.Produce a corrected response that fixes every listed problem while still satisfying the intent. Scripted model answer (attempt 2): {"value": {"vendor": "Contoso Supplies", "invoiceNumber": "INV-2291", "currency": "USD", "total": 1240.00}}Success: true, after 2 attemptsValue: {"vendor":"Contoso Supplies","invoiceNumber":"INV-2291","currency":"USD","total":1240.00}

Ticket grouping

For support and product tools: your objects, not copies.

Sort a backlog into defects and feature requests. The model only answers with indices, the indices are checked, and you get your own Ticket objects back. AISortAsync and AIFilterAsync use the same exchange: a sort must return every ticket exactly once, and a filter returns the matching tickets in their original order.

Before
Number the items, map the indices back, check every index.
The intent
Group the tickets by whether each reports a defect or asks for a feature.
After
One call: the numbering and the index check are built in.

Before

TicketGroupingBefore.cs
public sealed record TicketGroup{    public required string Label { get; init; }    public required int[] Indices { get; init; }} public static class TicketGroupingBefore{    private const string Instructions =        "Group these tickets by whether each reports a defect or asks for a feature. Each ticket starts with its " +        "zero-based index. Return groups with a short label and the indices in them; every index in exactly one group.";     public static async Task<Dictionary<string, List<Ticket>>?> GroupAsync(IChatClient chat, IReadOnlyList<Ticket> tickets, CancellationToken ct = default)    {        // The model sees indices, so the answer can be mapped back to the original objects.        var numbered = string.Join("\n", tickets.Select((t, i) => $"{i}: {t.Title} ({t.Severity})"));        List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, numbered)];        for (var attempt = 1; attempt <= 2; attempt++)        {            var response = await chat.GetResponseAsync<List<TicketGroup>>(messages, cancellationToken: ct);            var problems = new List<string>();            if (!response.TryGetResult(out var groups) || groups is null)                problems.Add("The answer was not a list of groups with a label and indices.");            else            {                var indices = groups.SelectMany(g => g.Indices).ToList();                problems.AddRange(indices.Where(i => i < 0 || i >= tickets.Count).Distinct()                    .Select(i => $"Index {i} is out of range 0..{tickets.Count - 1}."));                if (indices.Count != tickets.Count || indices.Distinct().Count() != tickets.Count)                    problems.Add($"Every index from 0 to {tickets.Count - 1} must appear in exactly one group.");                if (groups.Select(g => g.Label).Distinct(StringComparer.OrdinalIgnoreCase).Count() != groups.Count)                    problems.Add("Group labels must be unique.");                if (problems.Count == 0)                    return groups.ToDictionary(g => g.Label, g => g.Indices.Select(i => tickets[i]).ToList(), StringComparer.OrdinalIgnoreCase);            }             // Retry with the findings.            messages.AddMessages(response);            messages.Add(new(ChatRole.User, "Problems with your answer:\n" + string.Join("\n", problems)));        }        return null;    }}

After

Program.cs
var tickets = new List<Ticket>{    new(101, "Login page returns 500 after password reset", "Critical"),    new(102, "Add dark mode", "Low"),    new(103, "Export to CSV drops the last row", "High"),    new(104, "Let admins invite users in bulk", "Medium"),};var groups = await tickets.AIGroupAsync(client, "by whether the ticket reports a defect or asks for a feature");
Output
Scripted model answer: {"value": [{"label": "Defect", "indices": [0, 2]}, {"label": "Feature request", "indices": [1, 3]}]}Success: trueDefect: 101, 103Feature request: 102, 104Every grouped Ticket is the object from tickets, not a copy: trueIssues: none

Agent tools

For teams adopting Microsoft Agent Framework: checked tools.

Give an agent work it can delegate. AsAIFunctions() turns every enabled intent into a standard AIFunction tool, with no model call. The model is asked only when the agent runs a tool. Each answer is checked against the file's rules, and a failure comes back to the agent as JSON instead of an exception. A saved edit to an intent file changes the rules the next tool call is checked against; the tool's name and description refresh when you build the tools again.

Before
Wrap your own checked call in a tool by hand.
The intent
Orders.ClassifyUrgency, as a tool the agent calls: rate each order email from 1 (routine) to 5 (critical).
After
One call makes every enabled intent a checked tool.

Before

AgentToolBefore.cs
// A tool must not throw into the agent loop, so every failure becomes JSON the agent can read.public static AIFunction UrgencyTool(IChatClient model) => AIFunctionFactory.Create(    async (string input, CancellationToken ct) =>    {        try        {            return await OrderTriageBefore.ClassifyUrgencyAsync(model, input, ct) is { } urgency                ? JsonSerializer.SerializeToElement(new { success = true, value = urgency })                : JsonSerializer.SerializeToElement(new { success = false, error = "No urgency from 1 to 5 after 2 attempts." });        }        catch (Exception ex) when (ex is not OperationCanceledException)        {            return JsonSerializer.SerializeToElement(new { success = false, error = ex.Message });        }    },    name: "Orders_ClassifyUrgency",    description: "Rates the urgency of an order email from 1 (routine) to 5 (critical)."); public static Task<ChatResponse> AskAsync(IChatClient agent, IChatClient model, string inbox){    var options = new ChatOptions { Tools = [UrgencyTool(model)] };    return agent.GetResponseAsync(inbox, options);}

After

Program.cs
var options = new ChatOptions { Tools = [.. intents.AsAIFunctions()] };var reply = await agent.GetResponseAsync(inbox, options);
Tool results
The agent's scripted model calls Orders_ClassifyUrgency once per email. Tool call 1, input: Our packaging line stops on Thursday unless the 40 replacement belts arrive. Can you expedite?Scripted model answer: {"value": 4}Tool result: {"success":true,"intent":"Orders.ClassifyUrgency","value":4} Tool call 2, input: URGENT!!! On a scale of 1 to 10 this is a 9. Please send the replacement filters by the end of the month.Scripted model answer (2 attempts): {"value": 9}, then {"value": 9}Tool result: {"success":false,"intent":"Orders.ClassifyUrgency","error":"value is 9; the maximum is 5."}

Ranking by judgement without losing an item

For backlogs and lead lists: your own objects, every one exactly once.

The model answers with indices only. A missing or repeated index fails the attempt and goes back to the model as a finding, and a full permutation returns your own Ticket objects in the new order, each exactly once. The whole list goes in one request, so every public property of each item is sent and MaxInputChars (200,000 characters by default) caps its size.

Before
Number the tickets, check the permutation and retry by hand.
The intent
Rank the tickets by how badly each blocks users, most blocking first.
After
One call: the permutation check and the retry are built in.

Before

SortBefore.cs
private const string Instructions =    "Order these tickets by how badly they block users, most blocking first. Each ticket starts with its zero-based " +    "index. Return the indices in the new order, every index exactly once."; public static async Task<List<Ticket>?> SortAsync(IChatClient chat, IReadOnlyList<Ticket> tickets, CancellationToken ct = default){    // The model sees indices, so the answer can be mapped back to the original objects.    var numbered = string.Join("\n", tickets.Select((t, i) => $"{i}: {t.Title} ({t.Severity})"));    List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, numbered)];    for (var attempt = 1; attempt <= 2; attempt++)    {        var response = await chat.GetResponseAsync<List<int>>(messages, cancellationToken: ct);        var problems = new List<string>();        if (!response.TryGetResult(out var order) || order is null)            problems.Add("The answer was not a list of indices.");        else        {            if (order.Count != tickets.Count) problems.Add($"Return exactly {tickets.Count} indices.");            if (order.Distinct().Count() != order.Count) problems.Add("Indices must not repeat.");            if (order.Any(i => i < 0 || i >= tickets.Count)) problems.Add($"Every index must be between 0 and {tickets.Count - 1}.");            if (problems.Count == 0) return order.Select(i => tickets[i]).ToList();        }         // Retry with the findings.        messages.AddMessages(response);        messages.Add(new(ChatRole.User, "Problems with your answer:\n" + string.Join("\n", problems)));    }    return null;}

After

Program.cs
var ranked = await tickets.AISortAsync(client, "by how badly the ticket blocks users, most blocking first");
Output
Scripted model answer (attempt 1): {"value": [0, 2, 2, 1]}[Validator] attempt 1: Indices must not repeat.Scripted model answer (attempt 2): {"value": [0, 2, 3, 1]}Success: true, after 2 attempts1. 101 Login page returns 500 after password reset (Critical)2. 103 Export to CSV drops the last row (High)3. 104 Let admins invite users in bulk (Medium)4. 102 Add dark mode (Low)Every ticket appears exactly once, as the object from tickets: true

Free-text prices into numbers, keyed by SKU

For catalogues and price lists: every key checked.

Turn a dictionary of SKU to price text into SKU to decimal? in one request. A missing, extra or repeated key fails the attempt and goes back to the model as a finding, and the nullable result lets the model answer null where the text gives no price. The keys stay yours: the call cannot rename or add one, and the whole dictionary must fit in MaxInputChars.

Before
One call per key, each with its own check and retry.
The intent
Read each unit price in US dollars as a number, or null when no price is given.
After
One request for all the keys, checked and retried.

Before

MapValuesBefore.cs
// A record with a nullable member: GetResponseAsync<decimal?> reads a null answer as no result at all.public sealed record UnitPrice(decimal? Usd); private const string Instructions = "Read the unit price in US dollars from this text as a number. Use null when no price is given."; public static async Task<Dictionary<string, decimal?>?> ReadPricesAsync(IChatClient chat, IReadOnlyDictionary<string, string> prices, CancellationToken ct = default){    // One call per key, so each answer is tied to its key by the loop, not by the model.    var result = new Dictionary<string, decimal?>();    foreach (var (sku, text) in prices)    {        List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, text)];        for (var attempt = 1; ; attempt++)        {            var response = await chat.GetResponseAsync<UnitPrice>(messages, cancellationToken: ct);            if (response.TryGetResult(out var price) && price is not null)            {                result[sku] = price.Usd;                break;            }            if (attempt == 2) return null;   // one key failed, so the whole map has failed             // Retry with the finding.            messages.AddMessages(response);            messages.Add(new(ChatRole.User, "The answer was not an object with usd as a number or null. Answer again."));        }    }    return result;}

After

Program.cs
var unitPrices = await prices.AIMapValuesAsync<string, string, decimal?>(client,    "Read the unit price in US dollars as a number. Use null when no price is given.");
Output
Scripted model answer (attempt 1): {"value": [{"key": "A-1192", "value": 31.00}, {"key": "B-2040", "value": 12.50}]}[Validator] attempt 1: Missing keys: C-3301.Scripted model answer (attempt 2): {"value": [{"key": "A-1192", "value": 31.00}, {"key": "B-2040", "value": 12.50}, {"key": "C-3301", "value": null}]}Success: true, after 2 attemptsA-1192: "31.00 each" -> 31.00B-2040: "12.50 dollars per box" -> 12.50C-3301: "call for a quote" -> nullModel calls (ScriptedProvider.CallCount): 2 for 3 keys

Different fields per customer

For multi-tenant apps: a shape chosen at run time.

Each customer's field list is a JSON schema kept in config or a database. TransformAsync(input, intent, schema) asks the model for JSON in that shape, with no class to compile when a field changes. The core checks only that the reply is JSON in the {"value": ...} envelope and not null, so the schema's own rules need your validators, such as the required-fields check here, or an intent file's schema shape.

Before
A class per tenant and a switch, so a new field is a release.
The intent
Extract the fields of each customer's JSON schema from the invoice email.
After
The schema passed as data, plus your required-fields check.

Before

TenantSchemaBefore.cs
// A class per tenant: a new tenant, or a new field for one, means a code change, a build and a deployment.public sealed record TenantAInvoice{    public required string Vendor { get; init; }    public required decimal Total { get; init; }} public sealed record TenantBInvoice{    public required string Supplier { get; init; }    public required string InvoiceNumber { get; init; }    public required DateOnly DueDate { get; init; }} private const string Instructions = "Extract these fields from the invoice email."; public static async Task<object?> ExtractAsync(IChatClient chat, string tenant, string email, CancellationToken ct = default) => tenant switch{    "tenant-a" => await ExtractAsync<TenantAInvoice>(chat, email, ct),    "tenant-b" => await ExtractAsync<TenantBInvoice>(chat, email, ct),    _ => throw new ArgumentException($"No invoice class for tenant \"{tenant}\".", nameof(tenant)),}; private static async Task<T?> ExtractAsync<T>(IChatClient chat, string email, CancellationToken ct) where T : class{    List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, email)];    for (var attempt = 1; attempt <= 2; attempt++)    {        var response = await chat.GetResponseAsync<T>(messages, cancellationToken: ct);        if (response.TryGetResult(out var fields) && fields is not null) return fields;         // Retry with the finding.        messages.AddMessages(response);        messages.Add(new(ChatRole.User, $"The answer was not a {typeof(T).Name} object with every field. Answer again."));    }    return null;}

After

Program.cs
// The core checks that the reply is JSON in the {"value": ...} envelope and not null. The schema's own rules// are for your validators: this one checks that every required field is present.static AIRequestOptions RequiredFields(JsonNode schema) => new AIRequestOptions().Validate<JsonNode>(value =>    schema["required"]!.AsArray().Select(name => name!.GetValue<string>())        .Where(name => value[name] is null)        .Select(name => $"{name} is required.")); var results = new Dictionary<string, AIResult<JsonNode>>();foreach (var (tenant, schema) in schemas)    results[tenant] = await client.TransformAsync(email, "Extract these fields from the invoice email.", schema, RequiredFields(schema));
Output
tenant-a, schema fields: vendor, totalScripted model answer: {"value": {"vendor": "Contoso Supplies", "total": 1240.00}}Success: trueValue: {"vendor":"Contoso Supplies","total":1240.00}Issues: none tenant-b, schema fields: supplier, invoiceNumber, dueDateScripted model answer: {"value": {"supplier": "Contoso Supplies", "invoiceNumber": "INV-2291", "dueDate": "2026-11-01"}}Success: trueValue: {"supplier":"Contoso Supplies","invoiceNumber":"INV-2291","dueDate":"2026-11-01"}Issues: none The core checked each reply for JSON in the {"value": ...} envelope and not null; the RequiredFieldsvalidator checked the required fields. The core does not check other schema rules: add validators, oruse an intent file's schema shape.

Questions over the database you have

For internal tools and reporting: plain words in, rows out.

Ask for customers in California with a total above 1000, and PlanAsync returns one SQL statement and its parameters, checked against a table allowlist and for read-only use unless you set AllowWrites, without running it. Once a person approves the plan, ExecutePlanAsync runs the same check again, then the statement, on your DbConnection, with no model call. Keep the approved plan and run it again the same way: the model is asked only when you plan; the check runs every time you execute. Tested on SQLite only, and the check is a guard, not a proof, so the database account's own permissions still matter.

Before
The same SELECT written by hand in ADO.NET, one method per question, no model.
The intent
A question in plain words: customers in California with a total above 1000.
After
The question in plain words: a checked plan for a person to approve, then run on your connection.

Before

DatabaseBefore.cs
// One method per question: a new question from a user means new SQL, a build and a deployment.public static async Task<List<(long Id, string FullName, string State, double Total)>> CustomersAboveAsync(    DbConnection connection, string state, double minTotal, CancellationToken ct = default){    await using var cmd = connection.CreateCommand();    cmd.CommandText = "SELECT Id, FullName, State, Total FROM Customers WHERE State = @state AND Total > @minTotal ORDER BY Total DESC";    AddParameter(cmd, "@state", state);    AddParameter(cmd, "@minTotal", minTotal);     var rows = new List<(long, string, string, double)>();    await using var reader = await cmd.ExecuteReaderAsync(ct);    while (await reader.ReadAsync(ct))        rows.Add((reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetDouble(3)));    return rows;} private static void AddParameter(DbCommand cmd, string name, object value){    var parameter = cmd.CreateParameter();    parameter.ParameterName = name;    parameter.Value = value;    cmd.Parameters.Add(parameter);}

After

Program.cs
var db = new SemanticDatabase(client, connection, new DatabaseOptions { AllowedTables = ["Customers"] }); // Plan only: the SQL and its parameters come back checked, and nothing runs.var (plan, safety) = await db.PlanAsync("Customers in California with a total above 1000"); // After a person approves the plan, run it. The safety check runs again first.var result = await db.ExecutePlanAsync(plan.Value!);
Output
Question: Customers in California with a total above 1000Scripted model answer: {"value": {"sql": "SELECT Id, FullName, State, Total FROM Customers WHERE State = @state AND Total > @minTotal ORDER BY Total DESC", "parameters": {"state": "CA", "minTotal": 1000}, "explanation": "Customers whose state is CA and whose total is above 1000, largest first.", "tablesUsed": ["Customers"]}}Plan: Success: trueSQL: SELECT Id, FullName, State, Total FROM Customers WHERE State = @state AND Total > @minTotal ORDER BY Total DESCParameters: @state = "CA", @minTotal = 1000Safety: allowed true, statement SELECT, tables CustomersThe table PaymentCards was not in the prompt (AllowedTables). ExecutePlanAsync: executed true, 2 rowsId: 3, FullName: Chen Wu, State: CA, Total: 3000Id: 1, FullName: Alice Moreno, State: CA, Total: 1500

The model's JSON almost parses

For every model call: checked, then retried with the finding.

Ask for your own record type, and every reply is checked by code before your code sees it. A code fence or a number sent as a string is still read. Malformed JSON, a missing required member or a name that is not an enum member fails the attempt. The next attempt shows the model its answer and the finding, within the attempts you allow. The type sets the shape, not ranges or patterns, which go in your own rules.

Before
Strip the fence, parse, catch the error and retry by hand.
The intent
Extract the vendor, invoice number, currency and total into your Invoice record.
After
One call: the fence, the parse and the retry are built in.

Before

JsonParseBefore.cs
private const string Instructions = "Extract the vendor, invoice number, currency and total from this invoice email.";private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); public static async Task<Invoice?> ExtractAsync(IChatClient chat, string email, CancellationToken ct = default){    List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, email)];    for (var attempt = 1; attempt <= 2; attempt++)    {        var response = await chat.GetResponseAsync<Invoice>(messages, cancellationToken: ct);         // GetResponseAsync<T> parses the reply text as it is, and a reply wrapped in a code fence does not parse.        var json = StripCodeFence(response.Text);        string problem;        try        {            // Invoice's members are required, so a missing one throws instead of becoming a default value.            if (JsonSerializer.Deserialize<Invoice>(json, Json) is { } invoice) return invoice;            problem = "The answer was null.";        }        catch (JsonException ex)        {            problem = ex.Message;        }         // Retry with the finding.        messages.AddMessages(response);        messages.Add(new(ChatRole.User, $"Problem with your answer: {problem} Answer again with the JSON object only."));    }    return null;} private static string StripCodeFence(string text){    var trimmed = text.Trim();    if (!trimmed.StartsWith("```", StringComparison.Ordinal)) return trimmed;    var firstLineEnd = trimmed.IndexOf('\n');    var close = trimmed.LastIndexOf("```", StringComparison.Ordinal);    return firstLineEnd < 0 || close <= firstLineEnd ? trimmed : trimmed[(firstLineEnd + 1)..close].Trim();}

After

Program.cs
var invoice = await client.TransformAsync<Invoice>(email, "Extract the vendor, invoice number, currency and total from this invoice email.");
Output
Scripted model answer (attempt 1): ```json{"value": {"vendor": "Contoso Supplies", "invoiceNumber": "INV-2291", "currency": "USD"}}```[Verification] attempt 1: The JSON did not match the expected type Invoice  where Invoice { string Vendor; string InvoiceNumber; string Currency; decimal Total }: JSON deserialization for type 'QuickStart.Before.Invoice' was missing required properties including: 'total'.The code fence was removed before the parse; the one finding is the missing member. The prompt of attempt 2 ends with:## Problems with the previous attempt- The JSON did not match the expected type Invoice  where Invoice { string Vendor; string InvoiceNumber; string Currency; decimal Total }: JSON deserialization for type 'QuickStart.Before.Invoice' was missing required properties including: 'total'.Produce a corrected response that fixes every listed problem while still satisfying the intent. Scripted model answer (attempt 2): {"value": {"vendor": "Contoso Supplies", "invoiceNumber": "INV-2291", "currency": "USD", "total": 1240.00}}Success: true, after 2 attemptsValue: {"vendor":"Contoso Supplies","invoiceNumber":"INV-2291","currency":"USD","total":1240.00}

A ceiling on one call

For any web request: limits you set.

Cap the model calls (judge calls included) and the wall-clock time of one request with MaxModelCalls and Timeout. When either limit is hit, the request ends with Success = false and an issue that names the limit, not a hung request or an exception. The limits count attempts, model calls, output tokens and time, not money.

Before
A call counter, a time budget and a retry, by hand.
The intent
Extract the purchase request, within 3 model calls and 10 seconds.
After
The limits as options on the call, reported on the result.

Before

CeilingBefore.cs
private const string Instructions = "Extract the purchase request from this email.";private const int MaxModelCalls = 3;private static readonly TimeSpan TimeBudget = TimeSpan.FromSeconds(10); public static async Task<(PurchaseRequest? Order, string? StoppedBy)> ExtractAsync(IChatClient chat, string email, CancellationToken ct = default){    // The budget's own token, so a cancel from the caller is told apart from the time running out.    using var budget = CancellationTokenSource.CreateLinkedTokenSource(ct);    budget.CancelAfter(TimeBudget);    List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, email)];    var calls = 0;    try    {        while (true)        {            if (calls == MaxModelCalls) return (null, $"Model call ceiling of {MaxModelCalls} reached.");            calls++;            var response = await chat.GetResponseAsync<PurchaseRequest>(messages, cancellationToken: budget.Token);             string problem;            if (!response.TryGetResult(out var order) || order is null) problem = "The answer was not a purchase request.";            else if (order.Quantity <= 0) problem = "Quantity must be a positive number.";            else return (order, null);             // Retry with the finding.            messages.AddMessages(response);            messages.Add(new(ChatRole.User, problem));        }    }    catch (OperationCanceledException) when (!ct.IsCancellationRequested && budget.IsCancellationRequested)    {        return (null, $"Time budget of {TimeBudget.TotalSeconds} seconds used up after {calls} model calls.");    }}

After

Program.cs
var options = new AIRequestOptions { MaxAttempts = 5, MaxModelCalls = 3, Timeout = TimeSpan.FromSeconds(10) }    .Require<PurchaseRequest>(r => r.Quantity > 0, "Quantity must be a positive number."); var order = await email.AIAsync<PurchaseRequest>(client, "Extract the purchase request from this email.", options);
Output
Scripted model answer (every attempt): {"value": {"contact": "Dana", "sku": "A-1192", "quantity": 0, "deliverBy": "2026-11-15"}}Success: false, after 3 attemptsIssues:  [Validator] attempt 1: Quantity must be a positive number.  [Validator] attempt 2: Quantity must be a positive number.  [Validator] attempt 3: Quantity must be a positive number.  [Guardrail] attempt 4: Model call ceiling of 3 reached before a verified result was produced.Model calls (ScriptedProvider.CallCount): 3 of the 5 answers queued

The vendor is down or rate-limiting you

For anything that must keep answering: one fallback, only on transient errors.

Name one fallback provider, which can also be a local model behind an IChatClient. It is called only on a transient error: a 429, a 5xx, a transport failure, an unreadable reply or a timeout. It is never called on an authentication error, a bad request or a wrong answer, and the result says which provider answered. A model name set on a call goes to the fallback too, so set one only when both providers accept it.

Before
A try/catch around two chat clients, with your own transient-error test.
The intent
Rate the urgency of an order email from 1 to 5, from the primary provider or, on a transient error, the fallback.
After
One builder line: transient errors only; the result names the provider.

Before

FallbackBefore.cs
public static async Task<(int? Urgency, string AnsweredBy)> ClassifyUrgencyAsync(    IChatClient primary, IChatClient fallback, string email, CancellationToken ct = default){    try    {        return (await OrderTriageBefore.ClassifyUrgencyAsync(primary, email, ct), "primary");    }    catch (Exception ex) when (IsTransient(ex, ct))    {        return (await OrderTriageBefore.ClassifyUrgencyAsync(fallback, email, ct), "fallback");    }} // Only a failure that may pass on another try moves the call: a 429, a 5xx, a transport error or a timeout.// An authentication error, a bad request or a wrong answer stays with the primary.private static bool IsTransient(Exception ex, CancellationToken ct) => ex switch{    HttpRequestException { StatusCode: null } => true,    HttpRequestException { StatusCode: { } status } => status == HttpStatusCode.TooManyRequests || (int)status >= 500,    TaskCanceledException => !ct.IsCancellationRequested,    _ => false,};

After

Program.cs
using var client = new IntentClientBuilder()    .UseProvider(primary)    .UseFallbackProvider(fallback)   // called only on a transient error: 429, 5xx, transport, unreadable reply, timeout    .Build(); var urgency = await email.AIAsync<int>(client, "Rate the urgency of this order email from 1 (routine) to 5 (critical).");
Output
Primary (scripted): threw AIProviderException "Service unavailable (503).", IsTransient = true, on 1 callScripted model answer (Fallback): {"value": 4}Success: trueValue: 4Issues: noneProvider: FallbackAttempts: 1, answered by Fallback

The same question, answered from the cache

For anything you pay for twice: re-checked before reuse.

With WithCache(new MemoryRequestCache()), an identical request is answered from an in-process cache with no call for the answer, while the entry lasts (one hour by default). A judge, if on, still reviews it with one call. A cached answer is checked against the current request's rules before it is reused, and replaced if it fails them. So an answer cached under older rules is not served unchecked. The cache lives in this process only, and a new wording or a new result shape is a new key.

Before
A dictionary cache, checked once when the answer is stored.
The intent
Rate the urgency of an order email from 1 to 5; the same request again is answered from the cache.
After
One builder line: a hit is checked again before reuse.

Before

CacheBefore.cs
public sealed class CachedUrgency(IChatClient chat){    private const string Instructions = "Rate the urgency of this order email from 1 (routine) to 5 (critical).";    private const int Minimum = 1, Maximum = 5;     // In-process, keyed by everything that changes the answer: the instructions and the email.    private readonly Dictionary<string, int> _cache = new();     public async Task<(int? Urgency, bool FromCache)> ClassifyAsync(string email, CancellationToken ct = default)    {        var key = Instructions + "\n" + email;        if (_cache.TryGetValue(key, out var cached)) return (cached, true);   // returned as stored, not checked again         List<ChatMessage> messages = [new(ChatRole.System, Instructions), new(ChatRole.User, email)];        for (var attempt = 1; attempt <= 2; attempt++)        {            var response = await chat.GetResponseAsync<int>(messages, cancellationToken: ct);            string problem;            if (!response.TryGetResult(out var urgency)) problem = "The answer was not a whole number.";            else if (urgency is < Minimum or > Maximum) problem = $"{urgency} is outside {Minimum} to {Maximum}.";            else            {                _cache[key] = urgency;                return (urgency, false);            }             // Retry with the finding.            messages.AddMessages(response);            messages.Add(new(ChatRole.User, $"{problem} Answer again with a whole number from {Minimum} to {Maximum}."));        }        return (null, false);    }}

After

Program.cs
using var client = new IntentClientBuilder()    .UseProvider(provider)    .WithCache(new MemoryRequestCache())    .Build(); var intent = "Rate the urgency of this order email from 1 (routine) to 5 (critical).";var first = await email.AIAsync<int>(client, intent);   // calls the modelvar again = await email.AIAsync<int>(client, intent);   // the same request: answered from the cache, no model call // A different request has a different key and calls the model: a new wording, or a new result shape (text).var reworded = await email.AIAsync<int>(client, intent + " A stopped production line is critical.");var asText = await email.AIAsync(client, intent);
Output
Scripted model answers, in the order the model was called: {"value": 4}, then {"value": 5}, then 4first: Success: true, Value: 4, FromCache: falseagain: Success: true, Value: 4, FromCache: true, Usage: 0 input tokens, 0 output tokensreworded: Success: true, Value: 5, FromCache: falseasText: Success: true, Value: 4, FromCache: falseModel calls (ScriptedProvider.CallCount): 3 for 4 requests; "again" made none.

Try one of them on your own code.