Any .NET value and an intent go to the model you choose, unless the cache already holds the answer or the call stops first on a setup mistake. The answer is checked by code and by your rules before your code sees it, a failed check goes back to the model with the findings, and limits you set stop a call that runs too long. 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 model call other than an embedding goes through IntentClient.TransformAsync<T>. The extension methods on values and collections, ExecuteIntentAsync for attributed methods, the database runtime's query planning, the intent catalog's runtime and the agent tools built from it are thin layers over it, with the same checks, retries and limits.
Click a box in the drawing, or pick a stage.
The drawing: the path of one checked call
The value: what you send.
The input is any object. Strings go as they are, and dates in ISO 8601 whatever the server culture. A DataTable goes as a JSON array of rows, and a DataRow as one JSON row. A JsonNode, JsonElement or JsonDocument goes as its own JSON, and anything else as compact JSON. GenerateAsync<T> runs an intent with no input.
Input longer than MaxInputChars (200,000 characters by default) is refused with an exception before any model call, never cut. A collection goes in one request, so the same limit caps its size. What is sent goes to the model as text in one message: no images, files or chat history.
var email = "Invoice INV-2291 from Contoso Supplies: 40 replacement belts at 31.00 each. Amount due: 1,240.00 US$ by 2026-11-01.";
var invoice = await email.AIAsync<Invoice>(client, "Extract the vendor, invoice number, currency and total from this invoice email.");
A plain-language instruction applied to a value. Inline, it needs no file: AIRequestOptions adds system instructions, an output requirement, worked examples, the model and the provider for that call. Or give it a name and keep the same settings, with its shape and rules, in a YAML or JSON 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] settings for attempts, time, model calls, a judge switch, an output requirement or a validator class, and run it with client.ExecuteIntentAsync<T>(method, input). There are six setting kinds: MaxAttempts, MaxExecutionTimeMs, MaxModelCalls, RequireJudge, OutputRequirement and CustomValidator. Settings on the class are the defaults and a setting on the method overrides them; validator classes from both run. The attributes are the operation, so intelligence goes on methods 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)]
publicstaticstring ConfirmOrder(PurchaseRequest order)
=> thrownewNotImplementedException("The attributes define this method; its body never runs.");
publicstaticTask<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
Intent file field
On a method
intent
[AIIntent("...")]
shape
The T of ExecuteIntentAsync<T>
rules, validators
CustomValidator
outputRequirements
ExpectedOutput on [AIIntent], or OutputRequirement
A .NET type becomes a strict JSON schema, and the model answers inside a {"value": ...} envelope. An int, an enum, a record with nested lists or a List<T> all work. A dictionary or a recursive type is described in the prompt instead. A required member missing from the answer is reported, not filled with a default. A member is required when it is non-nullable and is settable, or set by a constructor parameter with no default value. A reference type counts as non-nullable only in code with nullable annotations. Enums are read by name, and only a nullable value type such as int? can answer null. Ranges, lengths and patterns are not in the schema the model receives, so they belong in your rules.
A shape can also be a JSON schema chosen at run time, from config or a database, and the value then comes back as JSON. The core checks only that it is JSON in the envelope and not null; your validators or an intent file's schema shape check the rest.
A type, or a schema at run time
// A .NET type: the model is sent a strict schema built from PurchaseRequest, and the reply must parse into it.// A required member missing from the reply is an issue, not 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 model: yours to choose.
OpenAI and Anthropic over plain HTTP with structured output, and no vendor SDK, or any Microsoft.Extensions.AI IChatClient, or your own IAIProvider. A fallback provider is used only on a transient error: a 429, a 5xx, a transport error, an unreadable reply or a timeout. Authentication, validation and other errors, and answers that fail a check, never fall back.
A request or an intent file can name its model and its provider; naming the provider turns fallback off for that request. A named model is sent to the fallback too, so name one that both providers accept. WithRateLimiter keeps each provider under a request rate in this process.
Fewer tokens with the request cache
Give the client a cache with WithCache(new MemoryRequestCache()) and every request is keyed by a SHA-256 hash of the provider, any model the request names, the prompts, the messages, the result schema and the output settings. An identical request is answered from the cache with no model call for the answer; a judge, when one is on, still makes its own call; a change to the wording or to the schema makes a new key and a fresh call. The cache lives in your process, and a cached answer is still checked against the current 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.usingvar client = newIntentClientBuilder()
.UseAnthropic(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!)
.UseOpenAIAsFallback(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!)
.Build();
// Or any Microsoft.Extensions.AI IChatClient.usingvar onChatClient = newIntentClientBuilder().UseChatClient(chatClient).Build();
One fallback, for transient errors
usingvar client = newIntentClientBuilder()
.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 request cache
usingvar client = newIntentClientBuilder()
.UseProvider(provider)
.WithCache(newMemoryRequestCache())
.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.
The checks: 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, nulls and enum names. Then your rules run.
Declare limits in the intent file (minimum, maximum, allowed values, patterns) or check the typed value in code with Require<T> and Validate<T>; a check that throws becomes a finding too. A broken rule becomes a finding with the exact path and value, such as value.currency is "US$"; allowed: USD, EUR, GBP.
A pass means these checks passed. Checks narrow the answers; they cannot prove an answer right.
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 = newAIRequestOptions()
.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);
When a check fails, the next attempt sends the previous answer (up to 6,000 characters) and the exact findings back to the model. A refusal is not retried. A reply cut off by the output-token limit fails its attempt and is retried with that finding.
Four things are capped, on the client or per call: attempts, total model calls (judge calls included, cache hits not), output tokens per call and the time for the whole request. By default: 2 attempts, 6 model calls, 4,096 output tokens and no time budget. The limits count calls, tokens and time, not money. Hitting the model-call ceiling or the time budget ends the call with Success = false and a Guardrail issue that names the limit. Running out of attempts ends it with Success = false and the findings of each attempt.
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. The default judge uses the primary provider, with JudgeModel if you set one, and WithJudge takes your own IResultJudge. Its objections drive the next attempt like any other finding.
Limits and the attempt log
// Every attempt after the first sends the previous answer and the findings back to the model.var options = newAIRequestOptions { 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}
A ceiling on model calls
var options = newAIRequestOptions { 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 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 refusal, a bad answer, an error from a built-in provider or a hit limit on calls or time becomes an issue, not an exception, and a failure is never hidden behind a default value.
IntentClient.TotalUsage and TotalModelCalls keep running totals, and each call starts an Activity named IntentSharp.Transform for your tracing.
Reading the result
if (order.Success)
Console.WriteLine($"{order.Value!.Quantity} x {order.Value.Sku} for {order.Value.Contact}");
elseforeach (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.
Provider, Model
The provider that answered, and the model name its reply reported.
Usage
Tokens for the whole call, judge calls included.
RawOutput
The model's final answer as text, before parsing.
FromCache
True when the answer came from the cache. A judge, if on, still makes one call.
FailureSummary
The distinct issue messages on one line, on failure.
Warnings
Advice about the intent text. It never changes Success.
GetValueOrThrow()
The value, or an IntentSharpException that lists every issue.
GetValueOrDefault(fallback)
The value on success, otherwise your fallback.
A collection: each operation has its own check.
Filter, sort and group hand back your own objects: the model only sends indices, and every index is checked.
Scripted model answer: {"value": [0, 2, 3, 1]}Success: trueValue, in the new order: 101 Login page returns 500 after password reset (Critical) 103 Export to CSV drops the last row (High) 104 Let admins invite users in bulk (Medium) 102 Add dark mode (Low)Every sorted Ticket is the object from tickets, not a copy: trueIssues: none
Operation
Its check
AISortAsync
Returns every object exactly once, in the new order.
AIFilterAsync
Returns the matching objects in their original order.
AIGroupAsync
Places each object in exactly one group.
AIMapAsync
Returns one output per input, with the count checked.
AIMapValuesAsync
Keeps your dictionary keys, and fails on a missing, extra or repeated key.
AIReduceAsync
Returns one typed answer about the whole list.
AIClassifyAsync
Returns a value of your enum, read by name, never a number.
AIConvertAsync
Converts a value to your type, with no instruction to write.
Where the rules live
Rules move out of the code. Most steps call no model.
The business rules of a call live in an intent file, as data, and your code keeps what needs code: the types, the call by name, the named code checks and what to do with the result. The runtime joins the two on every call, and one step on that path calls a model; the optional judge is one more.
Click a box, or pick a step.
The drawing: where the rules live on one call
Call by name: your code names the intent.
Lane
Your code
Model call
None at this step
A change needs
A release
Your code names the intent and passes the value, with RunAsync<T> on the runtime or value.RunIntentAsync<T>; it names nothing else about the intent. The file decides the wording, the shape, the rules and which registered code checks apply.
Code that calls a new intent by name needs a release.
The call, still in your code
var priority = await email.RunIntentAsync<string>(intents, "Orders.ClassifyPriority");
The type you ask for in RunAsync<T> must fit the shape the file declares, or the call stops before any model call.
A file can name one of your own .NET types as its shape, but only a type your application registered by name with IntentTypeRegistry; a file cannot name an arbitrary type. The model is asked for a registered type exactly as in a call made in code. Type names are checked only when an intent runs, and a new .NET result type needs a release, since it is registered in code.
For checks that rules cannot express, such as a vendor that must exist in a database or a total that must match the line items, your application registers a C# validator under a name. A file switches it on by listing that name under validators; the file cannot carry code.
Named checks run in code, after the file's rules. Their messages go back to the model on the next attempt, so write them as instructions. A check that throws becomes a finding, not an exception. The names are checked only when an intent runs, and a new named code check needs a release.
The result goes back to your code, passed or not: Success, the Value, every issue and every attempt. Everything the model gets wrong is an issue in an ordinary AIResult<T>, with every attempt recorded. The runtime's own exceptions are setup mistakes, thrown before any model call.
The returned result does not carry the intent's tags. When the run is recorded in the operation log, its entry carries them, and the one log line written per run names the intent and its version.
A save for the name, enabled flag or shape; a release for the type you ask for
The runtime finds the file. It looks the name up in the catalog's current definitions on every call, so the next call after a save uses the new version, and a call already running finishes on the one it started with.
It refuses an unknown intent, a switched-off one or a wrong shape: IntentNotFoundException, IntentDisabledException for enabled: false, and IntentShapeMismatchException when the file's shape does not fit the type your code asks for. Each is thrown before any model call.
Build request: the same call as a request in code.
Lane
The runtime
Model call
None
A change needs
A save for what the file sets; a change to the client for the rest
It builds the request and, with a cache, its SHA-256 key. The runtime makes the same call a request made in code would: the file supplies the instruction, the examples, the system instructions, the output requirements and the limits on attempts, time and output tokens.
The run is tagged with the intent's name, version and source file. Input longer than the client's MaxInputChars is refused with an exception before any model call, never cut.
A save for what the file sets; a change to the client for the rest
The one step on the path that calls a model: the provider and the model the file names, or else the client's primary provider and that provider's default model. With WithCache, an identical request is answered from the cache with no model call for the answer.
When a check fails, the next attempt sends the findings back to the model, so a retry is one more call, within the attempts, model calls and time you set. A fallback provider is used only on a transient error, and naming a provider in the file turns fallback off for that intent.
Checks: the answer against its type and your rules.
Lane
The runtime
Model call
None; a judge is one
A change needs
A save for the rules; a release for a new code check
It checks the answer against its type, the file's rules and your code checks. First come the checks every answer gets: refusal, truncation, malformed JSON, a reply that does not fit the type, missing members, nulls and enum names. Then, for a schema shape, the file's full schema runs, then the file's rules, then the code checks it names. A cached answer is checked against the current rules too.
A broken rule becomes a finding with the exact path and value, and the next attempt sends it to the model. Only the optional judge, after the checks, calls a model.
A finding from the invoice file's currency rule
[Validator] attempt 1: value.currency is "US$"; allowed: USD, EUR, GBP.
name is what the code calls, unique in the catalog. Raise version with every change, so the audit trail can tell versions apart; each run is tagged with the intent's name, version and source file. owner says who answers for the intent, for audit and listings.
enabled: false switches the intent off: it still loads, and a call throws IntentDisabledException before any model call. AIRequestOptions, which a call made in code uses, has nothing for these fields.
intent is the instruction the model receives, in plain words, which its owner can read and change without reading C#. shape says what comes back: here a whole number; also text, a yes or no, a date, a list, a .NET type registered in code or a JSON schema.
examples are inputs and the outputs you expect, shown to the model on every attempt. They steer the format and the judgement, but a reply that copies an example and breaks a rule still fails. systemInstructions adds to the system prompt, and outputRequirements says what a good answer looks like, in prose.
Orders.ClassifyUrgency.intent.yaml (intent and shape)Intent file
intent: Rate the urgency of this order email from 1 (routine) to 5 (critical).
shape: int
Model choice: the model, the provider and the limits.
Lane
The intent file
Model call
None, it is data
A change needs
A save, no release
model names the model to ask. It is sent to whichever provider answers, the fallback included, so name one that both providers accept. provider picks a configured provider by name, and naming one turns fallback off for this intent. Provider names are checked only when an intent runs.
attempts, timeout and maxOutputTokens set the attempts this intent may make, the time budget for the whole run and the output-token limit for each model call, and judge: true asks for the model judge when the client's JudgeMode allows it. A field the file leaves out falls back to the client's options. A file has no field for Temperature, MaxModelCalls or BypassCache, which only code sets.
rules are the limits every answer must pass before your code sees it: 13 kinds of check, from ranges and patterns to allowed values and required members, run in code on every answer, with no model call. validators lists the names of checks written in C# and registered in your application, run after the rules.
A check named under validators, like a .NET type named under shape, must be one your application registered by name; nothing in a file can load code.
A save to the file changes the wording, the limits, the examples or the model on the next call, with no build and no deployment. The folder is watched: when a file changes, the catalog reloads it and swaps in the new definitions at once, and calls already running finish on the old one.
While the application runs, a bad save keeps the last good version, and the problem is reported with the file and the line. With the request cache on, a saved change to the rules alone can be answered from the cache, but the cached answer is checked against the new rules first.
Orders.ClassifyUrgency.intent.yaml, version 2Intent file
name: Orders.ClassifyUrgencyversion: 2owner: order-deskintent: Rate the urgency of this order email from 1 (routine) to 3 (critical).shape: intrules:minimum: 1maximum: 3examples: - input: "Line 3 is down until the bearings arrive. Ship today."output: 3 - input: "Please restock these when convenient, no rush."output: 1
A pass means the checks passed, not that the answer is right. The limits count calls, tokens and time, not money.
What does not call the model.
The model is asked for judgement. The checks, the limits and the wiring around it run in your code, and some calls end with no model call at all.
A repeated request. With WithCache, a request with the same provider, model, prompts, messages, schema and output settings is answered from the cache while the entry lasts (one hour by default): no call for the answer, and FromCache is true. The answer is still checked against the current rules before it is used. If the judge is on, it still reviews the cached answer, which is one model call. A new wording, a new shape or a new input makes a new key and a fresh call.
An intent switched off.enabled: false in its file makes a call throw IntentDisabledException before any model call.
A setup mistake. An unknown intent name, a shape that does not fit the type you ask for, input over MaxInputChars or a limit out of range throws before any model call.
Every check. Refusal, truncation, JSON, required members, enum names, your Require and Validate rules and an intent file's rules run in code. Of the checks, only the optional judge calls a model.
An approved database plan.PlanAsync asks the model for one parameterized SQL statement and does not run it. Once a person approves the plan, ExecutePlanAsync runs it on your DbConnection through ADO.NET with no model call, each time you call it, and the safety check runs again each time. Tested on SQLite only; the check is a guard, not a proof.
Your own objects back. Sort, filter and group map the model's indices back to your objects in code; the model never writes your objects.
Agent tools.AsAIFunctions builds an agent's tool list from your intents with no model call. Each tool call is one checked run.
The tools, built in a compiled demo (2 lines)Intents as agent tools
Tools: Invoice_Extract, Orders_ClassifyUrgencyDescription of Orders_ClassifyUrgency:Intent: Rate the urgency of this order email from 1 (routine) to 5 (critical).Result: an int value. Returns {"success": true, "intent": "Orders.ClassifyUrgency", "value": <result>} when the result passed verification, or {"success": false, "intent": "Orders.ClassifyUrgency", "error": "..."} when it did not.Model calls while building the tools: 0
CI and tests.FileIntentCatalog checks a folder of intent files in CI with no key, and ScriptedProvider stands in for the model in unit tests.
Around the call
Log, host and tests.
The same call runs in your host and in CI, with a record of what the model answered.
A log of model calls
WithMemory records each call a model answered, failed ones too, in memory or in a SQLite file you can query, with the intent, an input preview, the output, the model, the tokens and your tags. DoNotRecord keeps a call out, and the log is never fed back into prompts.
In your host
AddIntentSharp(configuration) registers one client from appsettings and builds it when the host starts, so a missing key or a refused license stops the host before the first request.
In your tests
ScriptedProvider stands in for the model in unit tests, with no network and no key, and records every request, so a test can check that a retry carried the findings. The outputs on this page come from it.