One checked path for every model answer.

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 path of one checked call Your value (text, a record, a list, a DataTable or JSON) and an intent go to the model, unless the cache already holds an answer for the same request. A switched-off, unknown or mismatched intent stops with an exception before any model call. Every answer, cached or fresh, is checked in code. A failed check goes back to the model with the findings, within MaxAttempts, MaxModelCalls and Timeout; when the answer passes, or the attempts run out, the call returns an AIResult with Success, Value, Issues and Attempts. Your value text, a record, a list, a DataTable or JSON 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,within MaxAttempts, MaxModelCalls and Timeout out of attempts: Success = false, with the issues cache hit: no call for the answer, still checked switched off, unknownor wrong shape: throwsbefore any model call
The drawing: the path of one checked call
The path of one checked call Your value (text, a record, a list, a DataTable or JSON) and an intent go to the model, unless the cache already holds an answer for the same request. A switched-off, unknown or mismatched intent stops with an exception before any model call. Every answer, cached or fresh, is checked in code. A failed check goes back to the model with the findings, within MaxAttempts, MaxModelCalls and Timeout; when the answer passes, or the attempts run out, the call returns an AIResult with Success, Value, Issues and Attempts. Your value text, a record, a list, a DataTable or JSON 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 switched off, unknown or wrong shape: throws, no model call cache hit: no call for the answer, still checked failed a check: retry with the findings, within MaxAttempts, MaxModelCalls and Timeout passed out of attempts: Success = false, with the issues

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.

Read more: a collection, and each operation's check

A string in, your own type out
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.");
Output
Scripted model answer: {"value": {"vendor": "Contoso Supplies", "invoiceNumber": "INV-2291", "currency": "USD", "total": 1240.00}}Success: trueValue: Invoice { Vendor = Contoso Supplies, InvoiceNumber = INV-2291, Currency = USD, Total = 1240.00 }Issues: none

The intent: what you want done.

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)]
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
Intent file fieldOn a method
intent[AIIntent("...")]
shapeThe T of ExecuteIntentAsync<T>
rules, validatorsCustomValidator
outputRequirementsExpectedOutput on [AIIntent], or OutputRequirement
systemInstructionsDescription on [AIIntent]
attemptsMaxAttempts
timeoutMaxExecutionTimeMs
judgeRequireJudge
examples, maxOutputTokens, provider, model, doNotRecord, tagsNone

The shape: what comes back.

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.
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();
One fallback, for transient errors
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 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.

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 = 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

Attempts: retry with the findings.

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 = 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}
A ceiling on model calls
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 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}");
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.
Provider, ModelThe provider that answered, and the model name its reply reported.
UsageTokens for the whole call, judge calls included.
RawOutputThe model's final answer as text, before parsing.
FromCacheTrue when the answer came from the cache. A judge, if on, still makes one call.
FailureSummaryThe distinct issue messages on one line, on failure.
WarningsAdvice 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.

Your tickets, sorted
List<Ticket> tickets = OpenTickets();var sorted = await tickets.AISortAsync(client, "most urgent first");
Output
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
OperationIts check
AISortAsyncReturns every object exactly once, in the new order.
AIFilterAsyncReturns the matching objects in their original order.
AIGroupAsyncPlaces each object in exactly one group.
AIMapAsyncReturns one output per input, with the count checked.
AIMapValuesAsyncKeeps your dictionary keys, and fails on a missing, extra or repeated key.
AIReduceAsyncReturns one typed answer about the whole list.
AIClassifyAsyncReturns a value of your enum, read by name, never a number.
AIConvertAsyncConverts 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.

Where the rules live on one call Two lanes and the path between them. In your code, where a change needs a release: the call by name with RunAsync, your result types registered with IntentTypeRegistry, your named code checks, and the AIResult your code receives. In the intent file, as data that a save changes: the name, version, owner and enabled flag; the instruction in plain words, the examples and the shape; the model, provider, attempts and timeout; and the rules with the names of the code checks to run. The runtime joins them. It resolves the file, and an unknown intent, enabled: false or a wrong shape stops with an exception before any model call. It builds the request and, with a cache set by WithCache, a SHA-256 key: a cache hit makes no call for the answer, and the answer is still checked. Otherwise the model is called, and a retry is one more call. The answer is checked against its type, the file's rules and your code checks with no model call. A judge, if on, is one more model call after the checks, on a cached answer too. The result goes back to your code, passed or not. A saved file reloads with no build, and a bad save keeps the last good version. In your code: a change needs a release The runtime, in your process In the intent file, as data: a save changes it, no release Call by name RunAsync<T> with the intent's name and your value Your types result types, registered with IntentTypeRegistry Code checks C# validators, registered in code, named in the file AIResult<T> Success, Value, Issues, Attempts, back to your code Resolve finds the file by name; checks shape and enabled no model call Build request wording, examples, schema and limits; with a cache, its key no model call Model call on a cache miss; a retry is one more call calls the model Checks type and JSON, the file's rules, then your code checks no model call a judge is one call Identity name, version, owner, enabled Instruction the intent in plain words, examples, system text, shape Model choice model, provider, attempts, timeout, judge on or off Rules as data 13 kinds of limit; names of code checks to run On save reloads, no build; a bad save keeps the last good one An exception, no model call, for an unknown intent, enabled: false or a wrong shape with WithCache, a hit: no call for the answer, still checked passed, or out of attempts, with the issues
The drawing: where the rules live on one call
Where the rules live on one call Two lanes and the path between them. In your code, where a change needs a release: the call by name with RunAsync, your result types registered with IntentTypeRegistry, your named code checks, and the AIResult your code receives. In the intent file, as data that a save changes: the name, version, owner and enabled flag; the instruction in plain words, the examples and the shape; the model, provider, attempts and timeout; and the rules with the names of the code checks to run. The runtime joins them. It resolves the file, and an unknown intent, enabled: false or a wrong shape stops with an exception before any model call. It builds the request and, with a cache set by WithCache, a SHA-256 key: a cache hit makes no call for the answer, and the answer is still checked. Otherwise the model is called, and a retry is one more call. The answer is checked against its type, the file's rules and your code checks with no model call. A judge, if on, is one more model call after the checks, on a cached answer too. The result goes back to your code, passed or not. A saved file reloads with no build, and a bad save keeps the last good version. In the file: a save changes it. In your code: a change needs a release. The runtime joins them, in your process. In the file name, version, owner, enabled In your code RunAsync<T> by name Resolve finds the file by name; an unknown, disabled or wrong shape stops here. no model call In the file wording, examples, the shape In your code result types, registered Build the request wording, examples, schema, limits; with a cache, its key a hit: no call for the answer no model call In the file model and provider, attempts and timeout Model call on a cache miss; a retry is one more call In the file rules as data, check names In your code the named code checks Checks type and JSON, the file's rules, then your code checks no model call a judge, if on, is one call passed, or out of attempts In your code AIResult<T> Success, Value, Issues In the file On save reloads with no build; a bad save keeps the last good one

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");

Read more: the intent

Your types: result types registered in code.

Lane
Your code
Model call
None
A change needs
A release

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.

Read more: the shape

Code checks: C# validators, named in the file.

Lane
Your code
Model call
None
A change needs
A release

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.

Read more: the checks

AIResult<T>: the result, back to your code.

Lane
Your code
Model call
None
A change 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.

Read more: the result

Resolve: find the file by name.

Lane
The runtime
Model call
None
A change needs
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.

Read more: what does not call the model

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.

Read more: the value

Model call: the one step that calls a model.

Lane
The runtime
Model call
Yes, unless the cache answers
A change needs
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.

Read more: the model

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.

Read more: the checks

Identity: what the code calls, and who owns it.

Lane
The intent file
Model call
None, it is data
A change needs
A save, no release

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.

Orders.ClassifyUrgency.intent.yaml (name, version, owner)Intent file
name: Orders.ClassifyUrgency
version: 1
owner: order-desk

Read more: the file, field by field

Instruction: the intent in plain words.

Lane
The intent file
Model call
None, it is data
A change needs
A save, no release

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
Orders.ClassifyUrgency.intent.yaml (examples)Intent file
examples:
  - input: "Line 3 is down until the bearings arrive. Ship today."
    output: 5
  - input: "Please restock these when convenient, no rush."
    output: 1

Read more: judgement in plain words

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.

Read more: the model

Rules as data: the limits every answer must pass.

Lane
The intent file
Model call
None, it is data
A change needs
A save, no release

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.

Orders.ClassifyUrgency.intent.yaml (rules)Intent file
rules:
  minimum: 1
  maximum: 5

Read more: the checks

On save: the next call uses it.

Lane
The intent file
Model call
None, it is data
A change needs
A save, no release

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: 1  maximum: 3examples:  - input: "Line 3 is down until the bearings arrive. Ship today."    output: 3  - input: "Please restock these when convenient, no rush."    output: 1
Output, the next call
Scripted model answer: {"value": 3}Success: trueValue: 3Issues: noneintent.name: Orders.ClassifyUrgencyintent.version: 2intent.source: Orders.ClassifyUrgency.intent.yaml

Read more: live reload

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
    var runtime = new IntentRuntime(client, catalog);var options = new ChatOptions { Tools = [.. runtime.AsAIFunctions()] };
    Output: two tools, no model call (8 lines)
    Output
    Tools: Invoice_Extract, Orders_ClassifyUrgency Description 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.