How it works

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.

The pipeline

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 path of one call Your value and an intent go to the model. Every answer is checked. A failed check goes back to the model with the findings, up to MaxAttempts; when the answer passes, or the attempts run out, the call returns an AIResult with Success, Value, Issues and Attempts. Your value a string, a record or a list Intent wording, shape and rules, in code or in a file Model OpenAI, Anthropic or any IChatClient; fallback if transient Checks refusal, truncation, JSON, members, nulls and your rules AIResult<T> Success, Value, Issues, Attempts passed failed a check: retry with the findings, up to MaxAttempts out of attempts: Success = false, with the issues The path of one call Your value and an intent go to the model. Every answer is checked. A failed check goes back to the model with the findings, up to MaxAttempts; when the answer passes, or the attempts run out, the call returns an AIResult with Success, Value, Issues and Attempts. Your value a string, a record or a list Intent wording, shape and rules, in code or in a file Model OpenAI, Anthropic or any IChatClient, with fallback Checks refusal, truncation, JSON, members, nulls, your rules AIResult<T> Success, Value, Issues, Attempts failed a check: retry with the findings passed out of attempts: Success = false, with the issues
Intent

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.

Two ways to state an intent
// 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");
An intent on a method
[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);
Output
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: none
Shape

The 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 type, or a schema at run time
// 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);
Rules

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.

Checks in code
// 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);
Invoice.Extract.intent.yaml (rules)Intent file
rules:
  properties:
    currency:
      enum: [USD, EUR, GBP]
    total:
      minimum: 0
Model

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

Providers
// 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();
The request cache
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);
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.
Attempts

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.

Limits and the attempt log
// 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)))}");
A retry, from the invoice example
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}
Result

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.

Reading the result
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
MemberMeaning
SuccessTrue when the value passed every check.
ValueThe checked value on success; the type's default otherwise.
IssuesEach problem found, as a ResultIssue with its source (Provider, Verification, Validator, Judge or Guardrail), a message and the attempt number.
AttemptsOne 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.