Intent catalog
Code: src/IntentSharp.Catalog/ (package IntentSharp.Catalog, namespace IntentSharp.Catalog). The
contract types are in src/IntentSharp.Catalog/Model/. The core hooks the catalog builds on are in
src/IntentSharp: the runtime-shape overload of TransformAsync, AIRequestOptions.Examples and
AIRequestOptions.OutputSchema. Decision record: ADR 0011. Plan:
workplan-v4-behaviour-as-data.md.
The catalog moves behaviour out of the compiled program. An intent definition is a file that holds the intent text, the result shape, the rules the result must satisfy, worked examples and guardrails, with a version and an owner. Code names the intent it wants; the file says what the intent does. Edit the file and the next run uses the new version, with no rebuild and no restart.
The pieces
| Type | Role |
|---|---|
IntentDefinition |
One intent as data: name, version, intent text, shape, rules, validators, examples, guardrails, owner, tags. |
IntentShape, IntentShapeKind |
The result shape: text, primitive, clr or schema. |
IntentRules |
Declarative checks on the result. |
IIntentCatalog |
A source of definitions: the Current snapshot, GetChangeToken() and ReloadAsync. |
IntentCatalogSnapshot, CatalogProblem |
A view of the catalog that is never changed in place (see Snapshots), and one problem found while loading it. |
FileIntentCatalog, FileIntentCatalogOptions |
Loads a folder of *.intent.json, *.intent.yaml and *.intent.yml files and reloads it when files change. |
InMemoryIntentCatalog |
Definitions added in code, for tests and programmatic use. |
IntentDefinitionParser |
Parse(text, source, format): JSON or YAML text to definitions and problems. |
IntentRuleEngine |
Evaluate(IntentRules, JsonNode): one message per violated rule. |
IntentRuntime |
Runs a named intent through an IntentClient. |
IIntentValidatorRegistry, IntentValidatorRegistry |
The code validators that files may name. |
IIntentTypeRegistry, IntentTypeRegistry |
The .NET types that files may name. |
IntentScope, IntentExtensions |
The ambient runtime, and value.RunIntentAsync<T>(...). |
CatalogServiceCollectionExtensions |
services.AddIntentCatalog(folder, configure?) and services.AddIntentRuntime(). |
IntentNotFoundException, IntentDisabledException, IntentShapeMismatchException |
Programming errors. All derive from IntentSharpException. |
A first intent
A file in the application's intents folder:
name: Invoice.Extract
version: 1
owner: accounts-payable
intent: Extract the vendor, the currency, the total and the line items from this invoice email.
shape:
kind: schema
schema:
type: object
properties:
vendor: { type: string }
currency: { type: string }
total: { type: number }
lines:
type: array
items:
type: object
properties:
sku: { type: string }
quantity: { type: integer }
rules:
properties:
total: { minimum: 0.01 }
currency: { enum: ["USD", "EUR", "GBP"] }
lines: { minItems: 1 }
validators:
- Invoice.KnownVendor
Registration, at startup:
using System.Text.Json.Nodes;
using IntentSharp.Catalog;
using IntentSharp.DependencyInjection;
var vendors = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Northwind", "Contoso" };
builder.Services.AddIntentSharp(builder.Configuration); // registers IntentClient
builder.Services.AddIntentCatalog("intents"); // IIntentCatalog over the folder, watched
builder.Services.AddIntentRuntime(); // IntentRuntime and the registries
builder.Services.AddIntentValidator("Invoice.KnownVendor", value =>
value is JsonNode node && vendors.Contains((string?)node["vendor"] ?? "")
? Array.Empty<string>()
: new[] { "vendor must be one of: " + string.Join(", ", vendors) + "." });
Use, anywhere the runtime can be injected:
public sealed class InvoiceIntake(IntentRuntime intents)
{
public async Task<JsonNode?> ExtractAsync(string email)
{
var invoice = await intents.RunAsync("Invoice.Extract", email);
return invoice.Success ? invoice.Value : null;
}
}
The application names Invoice.Extract and nothing else. The file decides the wording, the shape, the
rules and which registered code checks apply.
The file format
Files
FileIntentCatalogloads every file named*.intent.json,*.intent.yamlor*.intent.ymlunder its folder, including subfolders unlessIncludeSubdirectoriesis off (see The file catalog).- A file holds one definition (an object) or a list of definitions (an array). A YAML file may also hold several
documents separated by
---, read as a list. JSON and YAML have the same meaning: YAML is converted to a JSON node and then goes through the same code as JSON. - JSON files may contain comments (
//and/* */) and trailing commas, because people edit these files. The sample's JSON files use//comments, so a strict JSON validator or another tool that expects plain JSON rejects them. - Property names match ignoring case:
Name,nameandNAMEare the same property. The camelCase spellings below are the convention. A property written twice under two spellings (intentandIntent) is reported as set twice. The same key written twice in one object, with the same spelling, is a format error that fails the whole file. - An unknown property is reported as a problem, not ignored, so a misspelt
maxOutputTokenshows up instead of silently doing nothing. - A number must fit a .NET
double(up to about 1.8e308 either way). A larger one, such as1e400, is a format error on its line that fails the whole file, in JSON and in YAML, rather than a value that breaks the catalog later. - Values nest at most 64 levels deep, in both formats. YAML anchors and aliases (
&name,*name) work, with three limits: an alias inside the node it names (a loop, such astags: &a [ *a ]) is refused, a mapping key must be a scalar, and aliases may not expand the file to more than 100,000 nodes. Each of these fails the whole file with its line. The YAML is checked event by event before YamlDotNet builds its node tree, because building that tree is recursive and a deep or looping file would otherwise overflow the stack and end the process. - Names are unique across the catalog and compared case-insensitively. When two files define the same name, the file loaded first keeps it and the later one is reported.
A complete annotated example
Every property a definition can carry, in YAML:
name: Orders.ClassifyUrgency # unique, dotted, case-insensitive; tag intent.name
version: 3 # raise on every change; tag intent.version
owner: order-desk # who answers for this behaviour (audit, listings)
description: >- # for people and listings; never sent to the model
Scores how urgent an incoming order email is, from 1 (routine) to 5 (drop everything).
enabled: true # false: the definition loads, but running it throws IntentDisabledException
intent: >- # sent to the model as the intent
Rate the urgency of this order email from 1 (routine) to 5 (critical). A delivery date within two
days, a stopped production line or a safety issue is at least 4.
shape: # the result shape; see Shapes below (shorthand: shape: int)
kind: primitive # text | primitive | clr | schema
primitive: int
rules: # declarative checks on the result; see Rules below
minimum: 1
maximum: 5
validators: # code checks the application registered by name, run after the rules
- Orders.UrgencyMatchesContract
examples: # worked examples, rendered in the prompt in this order
- input: "We need 10 units by next month, no rush."
output: '{"value": 1}' # or bare: output: 1
- input: "Line 3 is down until the bearings arrive. Ship today."
output: '{"value": 5}'
outputRequirements: Answer with the number only. # extra output requirements in prose
systemInstructions: You triage orders for a parts distributor. # appended to the system prompt
attempts: 3 # attempts for this intent
judge: false # true asks for the model judge on this intent
provider: Anthropic # a configured provider's Name; omit for the primary
model: claude-opus-5-5 # omit for the provider's default model
timeout: PT30S # ISO 8601 duration or hh:mm:ss (00:00:30); the whole run
maxOutputTokens: 64 # output token ceiling per model call
doNotRecord: false # true keeps this intent's inputs and outputs out of memory
tags: # added to every request and memory entry
domain: orders
sla: gold
The same definition in JSON:
{
"name": "Orders.ClassifyUrgency",
"version": 3,
"owner": "order-desk",
"description": "Scores how urgent an incoming order email is, from 1 (routine) to 5 (drop everything).",
"enabled": true,
"intent": "Rate the urgency of this order email from 1 (routine) to 5 (critical). A delivery date within two days, a stopped production line or a safety issue is at least 4.",
"shape": { "kind": "primitive", "primitive": "int" },
"rules": { "minimum": 1, "maximum": 5 },
"validators": [ "Orders.UrgencyMatchesContract" ],
"examples": [
{ "input": "We need 10 units by next month, no rush.", "output": "{\"value\": 1}" },
{ "input": "Line 3 is down until the bearings arrive. Ship today.", "output": "{\"value\": 5}" }
],
"outputRequirements": "Answer with the number only.",
"systemInstructions": "You triage orders for a parts distributor.",
"attempts": 3,
"judge": false,
"provider": "Anthropic",
"model": "claude-opus-5-5",
"timeout": "PT30S",
"maxOutputTokens": 64,
"doNotRecord": false,
"tags": { "domain": "orders", "sla": "gold" }
}
Every property
IntentRuntime turns a definition into AIRequestOptions on every run. Unset properties fall back to the
client's IntentClientOptions, exactly as for a request made in code.
| Property | In the file | Default | Effect |
|---|---|---|---|
name |
string | none | The key in the catalog, case-insensitive. Tag intent.name. |
version |
integer | 1 | Tag intent.version. Raise it on every change. |
intent |
string | none | Sent to the model as the intent. Required: a missing or empty intent is a load problem, and the definition is left out. |
description |
string | none | For people and listings. Never sent to the model. |
owner |
string | none | Who owns the behaviour, for audit and listings. |
enabled |
boolean | true | When false the definition loads, but running it throws IntentDisabledException. |
shape |
object, or the shorthand string text or a primitive name |
kind: text |
The result shape. See Shapes. |
rules |
object | none | Declarative checks on the result. See Rules. |
validators |
list of names | empty | Code validators registered in the application, applied after the rules. See Validators by name. |
examples |
list of input and output values |
empty | AIRequestOptions.Examples. See Examples. |
outputRequirements |
string | none | AIRequestOptions.ExpectedOutputDescription, shown as "Additional output requirements" in the prompt. |
systemInstructions |
string | none | AIRequestOptions.SystemInstructions, appended to the system prompt. |
attempts |
integer | IntentClientOptions.MaxAttempts (2) |
AIRequestOptions.MaxAttempts. |
judge |
boolean | false | AIRequestOptions.UseJudge. The judge runs when IntentClientOptions.JudgeMode is OnRequest or Always; under Always it runs whatever this says, under Never it never runs. |
provider |
string | the primary provider | AIRequestOptions.Provider: a configured provider's Name, such as Anthropic, OpenAI or a chat client's name. An unknown name throws ArgumentException when the intent runs. |
model |
string | the provider's default | AIRequestOptions.Model. |
timeout |
duration | IntentClientOptions.DefaultTimeout |
AIRequestOptions.Timeout: the budget for the whole run, all attempts included. ISO 8601 (PT30S) or hh:mm:ss (00:00:30). Greater than zero and at most 49.17:02:47.294 (4,294,967,294 ms, the longest delay CancellationTokenSource.CancelAfter accepts); anything else is a load problem. |
maxOutputTokens |
integer | IntentClientOptions.MaxOutputTokens (4096) |
AIRequestOptions.MaxOutputTokens. |
doNotRecord |
boolean | false | AIRequestOptions.DoNotRecord: the run is not recorded in memory. |
tags |
map of strings | empty | Added to the run's tags beside the intent.* tags. See Audit tags. |
IntentDefinition.Source is not a file property. The loader sets it to the file path, and it becomes the
intent.source tag.
Examples
Each example is an input and the output expected for it. Both become text: a string is used as written, and
any other value (a number, a list, an object) is read as compact JSON, so outputs can be written as JSON in
YAML. The core renders the examples after the output requirements, in order:
## Examples
Example 1 input:
We need 10 units by next month, no rush.
Example 1 output:
{"value": 1}
Example 2 input:
...
Apply the same judgement and format to the input above.
- For a
textshape, and aprimitiveofstring(notstring[]), the model answers in plain text, and each output is used exactly as written. - For every other shape the model answers in the envelope
{"value": ...}, andIntentRuntimeputs each output in that envelope before it reaches the prompt. Write outputs bare (5,["a", "b"],{"vendor": "Acme"}) or already enveloped ('{"value": 5}'); the two mean the same. A bare value is wrapped as compact JSON (5renders as{"value":5}). Text that is not JSON becomes a JSON string, sooutput: 2026-11-15for adateshape renders as{"value":"2026-11-15"}. An output that is already an envelope, an object whose only key isvalue, is kept as written. - A
schemashape tells the two apart for you: an object whose only key isvalueis wrapped when the object itself matches the schema and itsvaluedoes not, so a bare{"value": 3}for a schema whose result has onevalueproperty renders as{"value":{"value":3}}. For aclrshape the runtime cannot check this when it prepares the examples, so a result object whose only property isvaluemust be written enveloped:'{"value": {"value": 3}}'. - Examples steer format and judgement. They never replace verification: a reply that copies an example and breaks a rule still fails.
- Examples are not counted toward
IntentClientOptions.MaxInputChars, which limits the serialized input only.
Shapes
shape.kind decides which core call IntentRuntime makes and which result type you can ask for.
kind |
Also set | Core call | Read the result with |
|---|---|---|---|
text |
nothing | TransformAsync<string>: plain text, no envelope |
RunAsync<string> or RunAsync |
primitive |
primitive: string, int, long, double, decimal, bool, date, datetime or guid, or any of those followed by [] for a list |
the matching TransformAsync<T> |
RunAsync<T> with the matching .NET type, or RunAsync |
clr |
clrType: a type name registered in the type registry, simple or assembly-qualified |
TransformAsync<T> with the registered type |
RunAsync<T> with a type the registered type is assignable to, or RunAsync |
schema |
schema: a JSON schema for the result value |
TransformAsync(input, intent, schema, options) |
RunAsync |
Two shorter ways to write a shape:
- A string instead of an object:
shape: text, or a primitive name such asshape: intorshape: date[]. The sample'sOrders.ClassifyUrgency.intent.yamlusesshape: int. - An object without
kind, when exactly one ofprimitive,clrTypeorschemais set: the kind follows from it, soshape: { primitive: int }is aprimitiveshape. With none of them set the kind istext; with more than one,kindis required and only the matching property may stay.
The types RunAsync<T> accepts for each primitive:
primitive |
T |
|---|---|
string |
string |
int, long, double, decimal, bool |
the same .NET type |
date |
DateOnly or DateTime |
datetime |
DateTimeOffset or DateTime |
guid |
Guid |
any of those followed by [] |
a one-dimensional array, List<>, IList<>, IReadOnlyList<>, IEnumerable<>, ICollection<> or IReadOnlyCollection<> of an accepted element type |
Nullable forms (int?, DateOnly?) are accepted. So is any type the default .NET type can be assigned to, such
as object; the default is the first type in each row, and List<> of it for lists. Any shape can also be run
as JsonNode.
A date or datetime asked for as DateTime (or a list of DateTime) still runs as its default type,
DateOnly or DateTimeOffset. The model is asked for that format (date rather than date-time), and the
rules check that type's text, so a pattern written for 2026-11-15 holds whichever type the caller asks for.
The verified value is then written as JSON and read back as DateTime with the pipeline's serializer options: a
date becomes midnight with DateTimeKind.Unspecified, and a datetime becomes the same instant in local time
with DateTimeKind.Local.
RunAsync(no type argument) accepts every shape and returns the result as JSON in anAIResult<JsonNode>; typed shapes are converted.RunAsync<T>checks first that the shape is assignable toT. If it is not, it throwsIntentShapeMismatchExceptionnaming both, before any model call.- A
primitiveofstringruns exactly liketext, because astringtarget always uses the plain-text output contract. - A
clrshape uses the strict schema generated from the registered type, exactly asTransformAsync<T>does in code. Only registered types can be named: register them withservices.AddIntentType<T>(), orIntentTypeRegistry.Add<T>()andAdd(name, Type). A file cannot name an arbitrary type, and aclrTypethat is not registered is refused. - A
schemashape is a runtime-defined shape, described next.
Runtime-defined shapes
A runtime-defined shape is a result shape given as a JSON schema while the program runs, rather than as a compiled .NET type. The core supports it directly, so it works with or without the catalog:
public Task<AIResult<JsonNode>> TransformAsync(object? input, string intent, JsonNode outputSchema,
AIRequestOptions? options = null, CancellationToken cancellationToken = default);
What happens on a call:
- The overload clones the options you pass and sets
AIRequestOptions.OutputSchemaon the copy, so your options object is not changed. PromptBuilder.Planwraps a deep copy of the schema in the{"value": ...}envelope withJsonSchemaBuilder.BuildEnvelopeFromSchema. The copy loses$schema,$idand$anchorat its root, its$defsanddefinitionsmove to the envelope root, and root-relative$refpointers are rewritten to match (#becomes#/properties/value; external references are left alone). The whole envelope is then made strict the same way generated schemas are: keywords providers reject are removed, every object closesadditionalProperties, and every property is required, in named definitions too. The schema node you passed is not modified. The full rules are in prompting-and-schemas.md.- The request goes out with
ResponseFormat.JsonSchema. The output contract asks for "a JSON value that conforms to the provided JSON schema". DeterministicVerifierchecks that the reply is JSON and unwraps the envelope. A bare value is accepted as the value; an envelope that carries other keys besidevaluefails with an issue that names them; a null value fails. The result is a detached copy of the value (verification.md).- Validators in
AIRequestOptions.Validatorsreceive thatJsonNode. Their findings are fed back to the model on the next attempt.
The core checks the envelope, not the schema: it does not check the value against the schema itself. The
provider's structured-output mode does part of that work, and the catalog does the rest: for a schema shape, IntentRuntime adds a validator
built from the definition's schema with JsonSchema.Net, so a reply that breaks the schema is a Validator
issue fed back to the model. One consequence matters: the keywords that the strict copy removes (minimum,
maximum, minLength, maxLength, pattern, minItems, maxItems, uniqueItems and the others listed in
prompting-and-schemas.md) never reach the provider, but they still bind,
through the catalog's schema validator. rules can say the same things for every shape.
Without the catalog, add your own validators:
using System.Text.Json.Nodes;
var schema = JsonNode.Parse("""
{
"type": "object",
"properties": {
"vendor": { "type": "string" },
"total": { "type": "number" }
}
}
""")!;
var options = new AIRequestOptions().Validate<JsonNode>(v =>
v["total"]?.GetValue<double>() > 0 ? Array.Empty<string>() : new[] { "total must be greater than 0." });
var invoice = await client.TransformAsync(email, "Extract the vendor and the invoice total.", schema, options);
if (invoice.Success)
Console.WriteLine(invoice.Value!["total"]);
TransformAsync<JsonNode> without an OutputSchema asks for any JSON value in JSON-object mode.
Rules
IntentRuleEngine evaluates IntentRules on the result rendered as JSON. Typed results are serialized
first with JsonSchemaBuilder.SerializerOptions (web defaults: camelCase property names, enums as strings),
so one engine serves compiled types and runtime schemas. Write property names in rules as they appear in
JSON: deliverBy, not DeliverBy.
| Rule | Applies to | Passes when |
|---|---|---|
minimum, maximum |
numbers | the number is at least minimum and at most maximum, compared exactly as decimals where both fit (so a long above 2^53 is not rounded past its bound), otherwise as doubles |
minLength, maxLength |
strings | the length is within the bounds |
pattern |
strings | the regular expression matches; anchor it with ^ and $ when the whole value must match |
enum |
text, numbers, true/false | the value is one of the listed values: text compared case-insensitively, numbers by numeric value, true/false by name |
minItems, maxItems |
arrays | the element count is within the bounds |
uniqueItems |
arrays | no two elements are equal |
notEmpty |
strings, arrays, objects | the value is not empty |
required |
objects | each listed property is present and not null |
properties |
objects | each named property satisfies its own rules |
items |
arrays | every element satisfies these rules |
enum values may be written as text, numbers or true/false (enum: [CA, NY], enum: [1, 2], enum: [true]).
They are kept as text in IntentRules.Enum, and a number in the result matches a listed number by value, so
enum: [2] accepts a reply of 2.0.
Three behaviours to plan for:
- A null value, or a property that is missing, passes every rule except
notEmpty. To demand that a property is present and not null, list it inrequired;properties: { total: { minimum: 0.01 } }alone accepts"total": null. - For
textandprimitiveshapes the loader knows what kind of JSON the result is, and refuses a rule that cannot apply to it as a load problem that lists the rules that do, for example"rules.minLength" does not apply to an int result; the rules that apply are minimum, maximum, enum.For a list shape,rulestakes list rules andrules.itemsis checked against the element type.clrandschemashapes are not checked this way when the file loads. - At run time, a rule that meets a value of the wrong JSON kind is a violation that names the rule, for example
value.total is a string; the rule "minimum" applies to numbers.
Rules nest through properties and items:
rules:
required: [vendor, total, lines]
properties:
total: { minimum: 0.01 }
currency: { enum: ["USD", "EUR", "GBP"] }
lines:
minItems: 1
items:
required: [sku, quantity]
properties:
sku: { pattern: "^[A-Z]-\\d{4}$" }
quantity: { minimum: 1 }
Every violation becomes one specific message that names the path from value, for example
value.items[2].state is "XX"; allowed: CA, NY, TX. Each message is a Validator issue and goes back to the
model on the next attempt, so the model learns exactly what to fix. Rules run after the shape is satisfied
(the reply must parse first); named validators run after the rules.
ADR 0011 records why rules are a closed, declarative vocabulary rather than code.
Validators by name
A validator is code the application registers under a name, for checks that rules cannot express: a vendor
that must exist in a database, a total that must match the line items. A file refers to it by name in
validators; the file cannot carry code.
- Register with
services.AddIntentValidator(name, fn)orIntentValidatorRegistry.Add(name, fn), wherefnis aFunc<object?, IEnumerable<string>>. - The validator receives the parsed value: a
stringfortext, the .NET value forprimitiveandclrshapes, and aJsonNodeforschemashapes. Adateordatetimeshape passes aDateOnlyor aDateTimeOffseteven when the caller asks forDateTime, because the run uses the declared type (see Shapes). - It returns problem messages; an empty sequence passes. A validator that throws becomes the issue "Validator threw: ..." rather than an exception.
- Messages are fed back to the model, so write them as instructions: "vendor must be one of: Northwind, Contoso." rather than "invalid vendor".
Loading, problems and hot reload
The file catalog
public FileIntentCatalog(string folder, FileIntentCatalogOptions? options = null,
ILogger<FileIntentCatalog>? logger = null);
public string Folder { get; } // the full path definitions are loaded from
- A relative
folderresolves againstAppContext.BaseDirectory, where files copied to the output directory land. A folder that does not exist throwsDirectoryNotFoundException. - The constructor starts watching, then performs the first load, with synchronous file reads, so
Currentis ready when it returns. An edit made while that first load reads the folder is noticed by the watcher and loaded by one more reload once the constructor is done. Every later load, from the watcher orReloadAsync, is asynchronous, and loads never overlap. services.AddIntentCatalogbuilds the catalog when it is first resolved, so that first read of the folder happens there. ResolveIIntentCatalogat startup to keep it off the first request that needs it.
FileIntentCatalogOptions, passed to the constructor or set in AddIntentCatalog(folder, configure):
| Option | Default | Effect |
|---|---|---|
IncludeSubdirectories |
true |
Load definition files from subfolders too, and watch them. |
WatchForChanges |
true |
Watch the folder and reload when a definition file is created, changed, renamed or deleted. When false, only ReloadAsync reloads, which suits tests and read-only mounts. |
DebounceInterval |
250 ms | How long the folder must be quiet before a reload starts. From zero to one minute; any other value throws ArgumentOutOfRangeException when the catalog is built. |
builder.Services.AddIntentCatalog("intents", options => options.WatchForChanges = false);
Snapshots
IIntentCatalog.Current is an IntentCatalogSnapshot: a fixed set of definitions keyed by name
(case-insensitive), the Problems from the most recent load attempt, LoadedAt, and a Generation that
increases with every new snapshot: every load of a FileIntentCatalog, whether or not it reported problems,
and every change to an InMemoryIntentCatalog. A higher Generation does not mean an edit took; check
Problems for that. A snapshot is replaced atomically, never changed in place, so code that holds one sees
consistent definitions. Current is never null; it is IntentCatalogSnapshot.Empty before the first load.
ReloadAsync loads now and returns the new snapshot.
The IntentDefinition objects in a snapshot are the catalog's own, not copies, and their properties have
setters. Treat them as read-only. IntentRuntime checks each definition object the first time it runs it and
from then on runs a private copy, so changing an object in place after it has run changes nothing: not the
prompt, the rules, attempts, enabled or the intent.version tag. To change a definition, edit its file or
call InMemoryIntentCatalog.Replace; IntentRuntime.Describe(name) returns a copy you may change freely.
Loading never throws for content errors. Each problem is a CatalogProblem with the source, the line when
the parser can know it, and a message; it prints as file:line: message. Problems are listed in
Current.Problems and logged at Warning.
Hot reload
FileIntentCatalogwatches its folder with aFileSystemWatcher, unlessWatchForChangesis off.- A burst of changes (an editor saving, several files copied at once) is debounced: the reload runs once,
when the folder has been quiet for
DebounceInterval(250 ms by default). - The reload builds a new snapshot, swaps it in atomically and fires the change token.
IntentRuntimeresolves the definition fromCatalog.Currenton every call, so the next run uses the new version. A run already in progress finishes with the definition it started with.
When the watcher reports an error, the catalog does not stop watching for good. This happens when the folder is deleted or replaced (a deployment that deletes the folder and copies a new one), when a network share drops, or when more changes arrive than the watcher can buffer. The catalog:
- logs the error at Warning and reloads every file;
- one second later, drops the failed watcher and, once the folder exists again, starts a new one and reloads every file once more, so an edit made while nothing was watching still loads;
- while the folder is missing, checks for it every second. A load in that time keeps every previous
definition and reports one problem, whose source is the folder's full path: "The intent catalog folder does
not exist (it was deleted or renamed, or is unreachable). The previous definitions stay in effect, and the
catalog loads the folder again when it is back."
ReloadAsyncreports the missing folder the same way instead of throwing.
Dispose stops watching.
When an edit is bad
- A file in which some definitions are bad keeps the good definitions that parsed, and reports the problems.
- A file that fails entirely, for example a YAML syntax error, leaves its previous definitions in place and is reported.
- A file that is still on disk but that the listing no longer shows, because a folder on its path can no longer
be listed (an ACL change, a sync tool holding it), is treated the same way: it keeps its previous definitions
and is reported as
The folder "billing" could not be listed, so this file could not be read: .... A file that is really gone, or whose folder became hidden, takes its definitions with it. - A definition whose content changed while its
versionstayed the same, or whoseversionwent down, still loads, and the load reports it ("changed but is still version 3", "the version went from 3 to 2"). The comparison is with the previous load in the same process, so it is reported once, by the load that picks up the change, and a change made while the application was stopped is not detected.
A broken save never takes an intent away from a running application. Fix the file and the next reload picks it up.
Reacting to a reload
The change token fires once when Current is replaced; fetch a new token after it fires.
ChangeToken.OnChange from Microsoft.Extensions.Primitives does that for you:
using Microsoft.Extensions.Primitives;
ChangeToken.OnChange(catalog.GetChangeToken, () =>
logger.LogInformation("Intent catalog at generation {Generation}, {Problems} problems",
catalog.Current.Generation, catalog.Current.Problems.Count));
To see load problems at startup instead of waiting for a log line, resolve the catalog and print them.
Resolving a FileIntentCatalog performs its first load, so Current already holds the problems; calling
ReloadAsync here would only read the folder a second time:
var catalog = app.Services.GetRequiredService<IIntentCatalog>();
foreach (var problem in catalog.Current.Problems)
app.Logger.LogWarning("Intent catalog: {Problem}", problem);
In code and in tests
InMemoryIntentCatalog holds definitions added in code. Add, Remove and Replace swap in a new snapshot
and fire the change token, so tests can exercise reload without files. IntentDefinitionParser.Parse(text, source, format) parses JSON or YAML text without a catalog.
Running intents
public sealed class IntentRuntime
{
public IntentRuntime(IntentClient client, IIntentCatalog catalog, IIntentValidatorRegistry? validators = null,
IIntentTypeRegistry? types = null, ILogger<IntentRuntime>? logger = null);
public Task<AIResult<T>> RunAsync<T>(string name, object? input, CancellationToken ct = default);
public Task<AIResult<JsonNode>> RunAsync(string name, object? input, CancellationToken ct = default);
public IntentDefinition Describe(string name);
public IIntentCatalog Catalog { get; }
public IntentScope AsAmbient();
}
On every run, IntentRuntime:
- resolves the definition by name from
Catalog.Current; an unknown name throwsIntentNotFoundException(so doesDescribe); - the first time it sees that definition object, checks it with the same rules as a file and keeps a private
copy; an invalid definition (possible from a custom
IIntentCatalog) throwsIntentSharpException. Every later step reads the copy (see Snapshots); - throws
IntentDisabledExceptionwhen the definition hasenabled: false; - for
RunAsync<T>, checks that the shape is assignable toTand throwsIntentShapeMismatchExceptionotherwise; - builds
AIRequestOptionsfrom the definition (the table under Every property), with the rule validator, the schema validator forschemashapes, the named validators and the audit tags; - makes the core call the shape selects (the table under Shapes);
- logs one Information line with the intent's name, version and success.
The exceptions are programming errors and are thrown before any model call. Everything the model gets wrong
is an issue in an ordinary AIResult<T>, with every attempt recorded, as for any other call.
Audit tags
Every run carries these tags in its AIRequestOptions.Tags:
| Tag | Value |
|---|---|
intent.name |
the definition's name |
intent.version |
its version |
intent.source |
where it came from, the file path for FileIntentCatalog |
The definition's own tags are added beside them. The core copies request tags onto AIRequest.Tags for
every attempt (providers never send them) and onto MemoryEntry.Tags when the run is recorded in memory. A
memory entry therefore answers which intent, at which version, from which file, produced an output. With the
per-run Information log line, that is the audit trail.
What the trail does not cover:
AIResult<T>has no tags. In code, you know the name you asked for, andDescribe(name)returns the current definition, which may already be newer than the one a finished run used.- No memory entry is written for a run with
doNotRecord, for a client without memory or withIntentClientOptions.RecordInMemoryoff, or for a result served from the request cache. - Tags are not part of the request cache key. A version whose prompt is identical to the previous one (only rules, validators or tags changed) can be served a cached reply; that reply is verified against the current rules, and one that fails is discarded and the attempt re-run against the provider.
Ambient scope
Code can run intents and transforms without passing the runtime or the client, inside a scope:
| Scope | Opened with | Serves |
|---|---|---|
IntentClientScope |
client.AsAmbient() |
AmbientAIExtensions: value.AIAsync<T>("intent") and every collection method, without the client parameter |
IntentScope |
runtime.AsAmbient() |
value.RunIntentAsync<T>("Name") |
using var clientScope = client.AsAmbient();
using var intentScope = runtime.AsAmbient();
var order = await email.AIAsync<PurchaseRequest>("Extract the purchase request from this email.");
var urgency = await email.RunIntentAsync<int>("Orders.ClassifyUrgency");
IntentClientScope behaves as follows, and IntentScope works the same way for the runtime:
- It is
AsyncLocalstate. It flows through awaits and into tasks started inside the scope, and it is invisible to other requests, other threads and work started outside it. The value is held in astatic readonly AsyncLocalfield, which is per async flow and never process-wide. - Scopes nest. An inner scope wins until it is disposed, and disposing it restores the value that was ambient when it was opened.
Disposerestores that previous value only while this scope's value is still the ambient one. Disposing an outer scope ends every scope opened inside it, so code that disposes scopes out of order cannot bring back a client or runtime whose scope has already ended. Disposing a scope twice does nothing the second time.- There is no process-wide default. With no scope active, the ambient methods throw
InvalidOperationExceptionwith a message that names both remedies: pass the client explicitly, or open a scope. - Open the scope in the method that makes the calls, or in one of its callers. Changes to
AsyncLocalstate made inside an awaited async method do not flow back to its caller, so a helper that opens a scope and returns leaves its caller without one.
The explicit forms always work and need no scope: value.AIAsync<T>(client, intent) and
value.RunIntentAsync<T>(runtime, "Name").
In ASP.NET Core, one scope per request is a single middleware:
app.Use(async (context, next) =>
{
using var scope = context.RequestServices.GetRequiredService<IntentClient>().AsAmbient();
await next(context);
});
Dependency injection
| Call | Registers |
|---|---|
services.AddIntentCatalog(folder, configure?) |
FileIntentCatalog over the folder, also as IIntentCatalog; the optional configure sets its FileIntentCatalogOptions. Built when first resolved; the first registration wins. |
services.AddIntentRuntime() |
IntentRuntime as a singleton, and empty validator and type registries |
services.AddIntentValidator(name, fn) |
a named validator in the registry |
services.AddIntentType<T>() |
a type that clr shapes may name |
IntentRuntime needs an IntentClient, so register one too, for example with AddIntentSharp.
Operating a catalog
Behaviour now changes without a deployment, so the intent folder is production configuration.
- Keep intent files in source control and review changes as you review code.
- Raise
versionwith every change. The audit trail can only tell versions apart if the numbers differ. The running catalog reports a change that kept or lowered the version, but only against its previous load in the same process, so it cannot catch an edit made while the application was stopped. - Limit who can write to the folder: anyone who can write it changes what the application decides.
- Watch the Warning log, or
Current.Problems, after every change. A broken file keeps its last good definitions, andGenerationrises whether or not the load had problems, so nothing else signals that the edit did not take. - Switch an intent off with
enabled: falserather than deleting it; callers get a clearIntentDisabledExceptioninstead ofIntentNotFoundException.
Read next
- extensions-ai.md: running IntentSharp on any
IChatClient, and catalog intents as agent tools. - prompting-and-schemas.md: the prompt layout, the envelope and strict schemas.
- verification.md: what makes a result succeed or fail.
- ADR 0011: why behaviour lives in files and rules are data.