Documentation
Table of Contents

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

  • FileIntentCatalog loads every file named *.intent.json, *.intent.yaml or *.intent.yml under its folder, including subfolders unless IncludeSubdirectories is 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, name and NAME are the same property. The camelCase spellings below are the convention. A property written twice under two spellings (intent and Intent) 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 maxOutputToken shows up instead of silently doing nothing.
  • A number must fit a .NET double (up to about 1.8e308 either way). A larger one, such as 1e400, 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 as tags: &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 text shape, and a primitive of string (not string[]), 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": ...}, and IntentRuntime puts 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 (5 renders as {"value":5}). Text that is not JSON becomes a JSON string, so output: 2026-11-15 for a date shape renders as {"value":"2026-11-15"}. An output that is already an envelope, an object whose only key is value, is kept as written.
  • A schema shape tells the two apart for you: an object whose only key is value is wrapped when the object itself matches the schema and its value does not, so a bare {"value": 3} for a schema whose result has one value property renders as {"value":{"value":3}}. For a clr shape the runtime cannot check this when it prepares the examples, so a result object whose only property is value must 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 as shape: int or shape: date[]. The sample's Orders.ClassifyUrgency.intent.yaml uses shape: int.
  • An object without kind, when exactly one of primitive, clrType or schema is set: the kind follows from it, so shape: { primitive: int } is a primitive shape. With none of them set the kind is text; with more than one, kind is 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 an AIResult<JsonNode>; typed shapes are converted.
  • RunAsync<T> checks first that the shape is assignable to T. If it is not, it throws IntentShapeMismatchException naming both, before any model call.
  • A primitive of string runs exactly like text, because a string target always uses the plain-text output contract.
  • A clr shape uses the strict schema generated from the registered type, exactly as TransformAsync<T> does in code. Only registered types can be named: register them with services.AddIntentType<T>(), or IntentTypeRegistry.Add<T>() and Add(name, Type). A file cannot name an arbitrary type, and a clrType that is not registered is refused.
  • A schema shape 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:

  1. The overload clones the options you pass and sets AIRequestOptions.OutputSchema on the copy, so your options object is not changed.
  2. PromptBuilder.Plan wraps a deep copy of the schema in the {"value": ...} envelope with JsonSchemaBuilder.BuildEnvelopeFromSchema. The copy loses $schema, $id and $anchor at its root, its $defs and definitions move to the envelope root, and root-relative $ref pointers 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 closes additionalProperties, 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.
  3. The request goes out with ResponseFormat.JsonSchema. The output contract asks for "a JSON value that conforms to the provided JSON schema".
  4. DeterministicVerifier checks that the reply is JSON and unwraps the envelope. A bare value is accepted as the value; an envelope that carries other keys beside value fails with an issue that names them; a null value fails. The result is a detached copy of the value (verification.md).
  5. Validators in AIRequestOptions.Validators receive that JsonNode. 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 in required; properties: { total: { minimum: 0.01 } } alone accepts "total": null.
  • For text and primitive shapes 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, rules takes list rules and rules.items is checked against the element type. clr and schema shapes 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) or IntentValidatorRegistry.Add(name, fn), where fn is a Func<object?, IEnumerable<string>>.
  • The validator receives the parsed value: a string for text, the .NET value for primitive and clr shapes, and a JsonNode for schema shapes. A date or datetime shape passes a DateOnly or a DateTimeOffset even when the caller asks for DateTime, 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 folder resolves against AppContext.BaseDirectory, where files copied to the output directory land. A folder that does not exist throws DirectoryNotFoundException.
  • The constructor starts watching, then performs the first load, with synchronous file reads, so Current is 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 or ReloadAsync, is asynchronous, and loads never overlap.
  • services.AddIntentCatalog builds the catalog when it is first resolved, so that first read of the folder happens there. Resolve IIntentCatalog at 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

  1. FileIntentCatalog watches its folder with a FileSystemWatcher, unless WatchForChanges is off.
  2. 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).
  3. The reload builds a new snapshot, swaps it in atomically and fires the change token.
  4. IntentRuntime resolves the definition from Catalog.Current on 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:

  1. logs the error at Warning and reloads every file;
  2. 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;
  3. 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." ReloadAsync reports 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 version stayed the same, or whose version went 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:

  1. resolves the definition by name from Catalog.Current; an unknown name throws IntentNotFoundException (so does Describe);
  2. 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) throws IntentSharpException. Every later step reads the copy (see Snapshots);
  3. throws IntentDisabledException when the definition has enabled: false;
  4. for RunAsync<T>, checks that the shape is assignable to T and throws IntentShapeMismatchException otherwise;
  5. builds AIRequestOptions from the definition (the table under Every property), with the rule validator, the schema validator for schema shapes, the named validators and the audit tags;
  6. makes the core call the shape selects (the table under Shapes);
  7. 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, and Describe(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 with IntentClientOptions.RecordInMemory off, 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 AsyncLocal state. 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 a static readonly AsyncLocal field, 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.
  • Dispose restores 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 InvalidOperationException with 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 AsyncLocal state 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 version with 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, and Generation rises whether or not the load had problems, so nothing else signals that the edit did not take.
  • Switch an intent off with enabled: false rather than deleting it; callers get a clear IntentDisabledException instead of IntentNotFoundException.
  • 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.