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
// 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
using var scope = intents.AsAmbient();var urgency = await email.RunIntentAsync<int>("Orders.ClassifyUrgency");
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
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
// 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
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
var invoice = await email.RunIntentAsync(intents, "Invoice.Extract");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
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
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");
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
// 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
var options = new ChatOptions { Tools = [.. intents.AsAIFunctions()] };var reply = await agent.GetResponseAsync(inbox, options);
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
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
var ranked = await tickets.AISortAsync(client, "by how badly the ticket blocks users, most blocking first");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
// 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
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.");
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
// 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
// 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));
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
// 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
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!);
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
Invoicerecord. - After
- One call: the fence, the parse and the retry are built in.
Before
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
var invoice = await client.TransformAsync<Invoice>(email, "Extract the vendor, invoice number, currency and total from this invoice email.");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
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
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);
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
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
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).");
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
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
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);
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.