One path for every call.
Your value and an intent go to the model you choose. The answer is checked before your code sees it, and a failed check goes back to the model with the findings. Each stage below is named after the term you meet in the API, with code that compiles against the library on every build of this site.
From your value to a checked result.
Every operation ends in IntentClient.TransformAsync<T>. The extension methods, the intent catalog's runtime, the database runtime and ExecuteIntentAsync are thin layers over it. Select a stage to read about it.
The intent: what you want done.
A plain-language instruction applied to a value. Write it inline for a one-off, or give it a name and keep its wording, shape and rules in a file that the application loads, and reloads when the file changes.
An intent can also sit on a method. Mark it with [AIIntent("...")], optionally add [AIGuardrail] limits on attempts, time or model calls, and run it with client.ExecuteIntentAsync<T>(method, input). The attributes are the operation, so intelligence goes on functions as well as on types. The method body never runs.
// In code: the intent is the instruction, applied to any value.
var order = await email.AIAsync<PurchaseRequest>(client, "Extract the purchase request from this email.");
// In a file: the code names the intent; Orders.ClassifyUrgency.intent.yaml holds its wording, shape and rules.
var urgency = await email.RunIntentAsync<int>(intents, "Orders.ClassifyUrgency");[AIIntent("Write a one-sentence reply that confirms this purchase request to the customer.",
ExpectedOutput = "One sentence that names the quantity, the SKU and the delivery date.")]
[AIGuardrail(GuardrailType.MaxAttempts, 2)]
[AIGuardrail(GuardrailType.MaxExecutionTimeMs, 15_000)]
public static string ConfirmOrder(PurchaseRequest order)
=> throw new NotImplementedException("The attributes define this method; its body never runs.");
public static Task<AIResult<string>> ConfirmOrderAsync(IntentClient client, PurchaseRequest order)
=> client.ExecuteIntentAsync<string>(typeof(OrderReplies).GetMethod(nameof(ConfirmOrder))!, order);Scripted model answer: Thanks, Dana: 40 units of A-1192 will reach you by 2026-11-15.Intent (from [AIIntent]): Write a one-sentence reply that confirms this purchase request to the customer.Model calls: 1Success: trueValue: Thanks, Dana: 40 units of A-1192 will reach you by 2026-11-15.Issues: noneThe shape: what comes back.
A .NET type becomes a strict JSON schema that the model must answer in, inside a {"value": ...} envelope. A non-nullable member missing from the answer is reported, never filled with a default. A shape can also be a JSON schema chosen at run time, and the value then comes back as JSON.
// A .NET type: the reply must be JSON that matches a strict schema built from PurchaseRequest, and a member
// missing from the reply is an issue, never a default value.
AIResult<PurchaseRequest> order = await email.AIAsync<PurchaseRequest>(client, "Extract the purchase request from this email.");
// A shape defined at run time: a JSON schema, and the value comes back as JSON.
var schema = JsonNode.Parse("""{"type": "object", "properties": {"sku": {"type": "string"}, "quantity": {"type": "integer"}}}""")!;
AIResult<JsonNode> line = await client.TransformAsync(email, "Extract the SKU and the quantity.", schema);The rules: what a good answer looks like.
Every answer is checked without calling a model: refusal, truncation, malformed JSON, a reply that does not fit the type, missing members and nulls. Then your rules run.
Declare limits in the intent file (minimum, maximum, allowed values, patterns) or check the typed value in code. A broken rule becomes a finding with the exact path and value, such as value.currency is "US$"; allowed: USD, EUR, GBP.
// Your own checks on the typed value. A failed check is an issue, and the next attempt sends it to the model.
var options = new AIRequestOptions()
.Require<PurchaseRequest>(r => r.Quantity > 0, "Quantity must be a positive number.")
.Validate<PurchaseRequest>(r => r.DeliverBy < DateOnly.FromDateTime(DateTime.Today)
? [$"DeliverBy is {r.DeliverBy:yyyy-MM-dd}, which is in the past."]
: []);
var order = await email.AIAsync<PurchaseRequest>(client, "Extract the purchase request from this email.", options);rules:
properties:
currency:
enum: [USD, EUR, GBP]
total:
minimum: 0The model: yours to choose.
OpenAI and Anthropic over plain HTTP with structured output, and no vendor SDK, or any Microsoft.Extensions.AI IChatClient. A fallback provider is used only after a 429, a 5xx, a transport error or a timeout. Authentication, validation and other errors never fall back.
Fewer tokens with the request cache
Give the client a cache and every request is keyed by a SHA-256 hash of the provider, the model, the prompts, the messages, the result schema and the output settings. An identical request is answered from the cache with no model call and no tokens; a change to the wording or to the schema makes a new key and a fresh call. A cached answer is still checked against the request's rules.
// OpenAI and Anthropic over HTTP, with no vendor SDK. The fallback is called only after a 429, a 5xx, a transport
// error or a timeout.
using var client = new IntentClientBuilder()
.UseAnthropic(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!)
.UseOpenAIAsFallback(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!)
.Build();
// Or any Microsoft.Extensions.AI IChatClient.
using var onChatClient = new IntentClientBuilder().UseChatClient(chatClient).Build();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 model
var 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.Attempts: retry with the findings.
When a check fails, the next attempt sends the previous answer and the exact findings back to the model. Attempts and total model calls are capped, and a time budget can be set, so a request cannot run away: by default 2 attempts and 6 model calls.
An optional judge, a second model call, can review an answer that passed the checks. IntentClientOptions.JudgeMode decides: Never, OnRequest (the default, when a request sets UseJudge) or Always.
// Every attempt after the first sends the previous answer and the findings back to the model.
var options = new AIRequestOptions { MaxAttempts = 3, MaxModelCalls = 4, Timeout = TimeSpan.FromSeconds(30) };
var order = await email.AIAsync<PurchaseRequest>(client, "Extract the purchase request from this email.", options);
foreach (var attempt in order.Attempts)
Console.WriteLine($"Attempt {attempt.Number}: {(attempt.Passed ? "passed" : string.Join(" ", attempt.Issues.Select(i => i.Message)))}");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}The result: what was checked, not a confidence score.
AIResult<T> carries Success, the Value, every issue (where it was found, on which attempt, what is wrong) and every attempt. A failure is never hidden behind a default value.
if (order.Success)
Console.WriteLine($"{order.Value!.Quantity} x {order.Value.Sku} for {order.Value.Contact}");
else
foreach (var issue in order.Issues)
Console.WriteLine(issue); // where it was found, on which attempt, and what is wrong| Member | Meaning |
|---|---|
Success | True when the value passed every check. |
Value | The checked value on success; the type's default otherwise. |
Issues | Each problem found, as a ResultIssue with its source (Provider, Verification, Validator, Judge or Guardrail), a message and the attempt number. |
Attempts | One record per attempt: provider, model, token usage, time, raw output and that attempt's issues. |
GetValueOrThrow() | The value, or an IntentSharpException that lists every issue. |
GetValueOrDefault(fallback) | The value on success, otherwise your fallback. |