Documentation
Table of Contents

Running intents from files

This page is the code side of intent files: loading a folder, running an intent by name, and wiring it into an application. What a file holds, live reload, the audit tags and how to look after the folder are in Intent files; every field and rule is in the intent file reference. The types are in the IntentSharp.Catalog package, namespace IntentSharp.Catalog.

Load a folder and run an intent by name

FileIntentCatalog loads every *.intent.yaml, *.intent.yml and *.intent.json file in a folder, and IntentRuntime runs its intents through your client:

using var catalog = new FileIntentCatalog("intents");
var runtime = new IntentRuntime(client, catalog);

AIResult<int> urgency = await runtime.RunAsync<int>("Orders.ClassifyUrgency", email);
AIResult<JsonNode> invoice = await runtime.RunAsync("Invoice.Extract", invoiceEmail);
  • A relative folder is read from AppContext.BaseDirectory, the build output, so copy the files there (the quick start shows the project setting). A folder that does not exist throws DirectoryNotFoundException.
  • The constructor loads the folder before it returns, so Current is ready, and watches the folder to load it again after a change.
  • RunAsync<T>(name, input) returns the result as T. RunAsync(name, input) returns any shape as a JsonNode. The same calls exist as extension methods on the input, RunIntentAsync<T> and RunIntentAsync, which take the runtime and the intent's name.
  • Each run reads the intent's current definition, so a saved edit applies on the next call. A run that has started finishes with the definition it started with.
  • Dispose the catalog to stop watching.

FileIntentCatalogOptions, the second constructor argument:

Option Default What it does
IncludeSubdirectories true Load, and watch, the files in subfolders too.
WatchForChanges true Reload after a file is created, changed, renamed or deleted. When false, only ReloadAsync reloads, which suits tests and read-only folders.
DebounceInterval 250 ms How long the folder must be quiet before a reload starts, so a burst of saves loads once. From zero to one minute.

What throws before any model call

A run throws, before it calls a model, when the code and the catalog do not fit. These are programming errors; everything the model gets wrong is an issue in an ordinary result.

Exception When
IntentNotFoundException No intent has that name. Describe(name) throws it too.
IntentDisabledException The intent has enabled: false.
IntentShapeMismatchException The intent's shape cannot produce the T you asked for. Any shape can be run as JsonNode.
IntentSharpException The intent names a check or a .NET type your application did not register.
ArgumentException The intent names a provider the client does not have.

runtime.Describe(name) returns a copy of the current definition, which you may change freely; changing it does not change the catalog. To change an intent, save its file.

Register the checks and types a file may name

A file can name two things that live in your code: checks (validators) and .NET result types (shape.clrType). It can name only those your application registered:

string[] knownVendors = ["Northwind", "Contoso", "Fabrikam"];
var validators = new IntentValidatorRegistry()
    .Add<JsonNode>("Invoices.KnownVendor", invoice =>
        knownVendors.Contains((string?)invoice["vendor"])
            ? []
            : [$"vendor must be one of: {string.Join(", ", knownVendors)}."]);

var types = new IntentTypeRegistry()
    .Add<OrderRequest>();

var runtime = new IntentRuntime(client, catalog, validators, types, loggers.CreateLogger<IntentRuntime>());

A check runs after the rules, on every reply that parses, and receives the result: a string for a text shape, the .NET value for a primitive or a .NET type, and a JsonNode for a schema shape. It returns the problems, and an empty list when the value is acceptable. Problems go back to the model on the next attempt, so write them as instructions ("vendor must be one of: ..." rather than "invalid vendor"). A check that throws becomes an issue, not an exception. Add<T> reports a result that is not a T as a problem.

A type is registered by its simple, full or assembly-qualified name; Add(name, type) gives it another name. Why adding a check or a type needs a release is in Intent files.

With dependency injection

string[] knownVendors = ["Northwind", "Contoso", "Fabrikam"];

services.AddIntentSharp(configuration);
services.AddIntentCatalog("intents", options => options.DebounceInterval = TimeSpan.FromSeconds(1));
services.AddIntentRuntime();
services.AddIntentValidator<JsonNode>("Invoices.KnownVendor", invoice =>
    knownVendors.Contains((string?)invoice["vendor"])
        ? []
        : [$"vendor must be one of: {string.Join(", ", knownVendors)}."]);
services.AddIntentType<OrderRequest>();
Call Registers
AddIntentCatalog(folder, configure) A FileIntentCatalog over the folder, also as IIntentCatalog, with its options. The first registration wins.
AddIntentRuntime() IntentRuntime, with the check and type registries. It needs an IntentClient, for example from AddIntentSharp.
AddIntentValidator(name, check), AddIntentValidator<T>(name, check) A named check.
AddIntentType<T>(), AddIntentType(name, type) A type clrType may name.

Every registration is a singleton, and the order of the calls does not matter. Inject IntentRuntime where you run intents:

/// <summary>A service that runs a catalog intent; the runtime comes from dependency injection.</summary>
public sealed class CatalogOrderTriage(IntentRuntime intents)
{
    public async Task<int?> UrgencyAsync(string email, CancellationToken cancellationToken)
    {
        var result = await intents.RunAsync<int>("Orders.ClassifyUrgency", email, cancellationToken);
        return result.Success ? result.Value : null;
    }
}

The catalog loads the folder when it is first resolved. Resolve it at startup, so the first request does not wait for it, and log what the load found:

// Resolving the catalog loads the folder, so the first request does not wait for it.
var catalog = services.GetRequiredService<IIntentCatalog>();
foreach (var problem in catalog.Current.Problems)
    logger.LogWarning("Intent catalog: {Problem}", problem);

// After every reload, whether or not it had problems.
ChangeToken.OnChange(catalog.GetChangeToken, () =>
    logger.LogInformation("Intent catalog at generation {Generation}, {Problems} problems",
        catalog.Current.Generation, catalog.Current.Problems.Count));

Reloads report problems; they never throw

A load never throws for a bad file. It reports each problem, with the file and the line when it is known, in Current.Problems and in a Warning log line, and the intents that loaded stay in force. How a bad save keeps the last good version is in Intent files.

What Current holds:

Member What it is
Definitions The loaded intents by name, ignoring case. TryGet(name, out definition) looks one up.
Problems The problems of the latest load, each a CatalogProblem that prints as file:line: message, or file: message when no line is known. For a missing folder, the file is the folder's full path.
Generation A number that rises with every load, whether or not it had problems.
LoadedAt When the snapshot was made.

A snapshot never changes: a reload swaps in a new one, so code that holds one sees consistent definitions. GetChangeToken() fires once when Current is replaced; ChangeToken.OnChange, as in the startup code above, renews it for you. ReloadAsync() loads the folder now and returns the new snapshot.

A rising Generation does not mean an edit took effect. Check Problems for that.

If the folder is deleted or replaced, or a network share drops, the catalog keeps the definitions it has, reports one problem naming the folder, and loads the folder again when it is back.

Check the files in CI

Load the folder once, without watching, and fail the build when any file has a problem:

/// <summary>Loads every intent file in the folder once and returns 1 when any of them has a problem.</summary>
public static int CheckIntentFiles(string folder)
{
    using var catalog = new FileIntentCatalog(folder, new FileIntentCatalogOptions { WatchForChanges = false });
    foreach (var problem in catalog.Current.Problems)
        Console.Error.WriteLine(problem);
    Console.WriteLine($"{catalog.Current.Definitions.Count} intents, {catalog.Current.Problems.Count} problems.");
    return catalog.Current.Problems.Count == 0 ? 0 : 1;
}

This catches what a load can see: syntax, unknown fields, a missing intent, a rule that cannot apply to a text or primitive shape, a bad duration, two intents with one name. The names of checks, .NET types and providers are checked only when an intent runs (What throws before any model call).

Intents defined in code

InMemoryIntentCatalog holds definitions you create in code, for tests or for intents your program builds:

var catalog = new InMemoryIntentCatalog().Add(new IntentDefinition
{
    Name = "Orders.ClassifyUrgency",
    Version = 1,
    Intent = "Rate the urgency of this order email from 1 (routine) to 5 (critical).",
    Shape = new IntentShape { Kind = IntentShapeKind.Primitive, Primitive = "int" },
    Rules = new IntentRules { Minimum = 1, Maximum = 5 },
});
var runtime = new IntentRuntime(client, catalog);

Add throws for an invalid definition or a name already present; Replace adds or replaces; Remove removes. Each change swaps in a new snapshot and fires the change token, as a reload does. Remove returns false and changes nothing when the name is absent. The definition's properties are the file's fields.

Without passing the runtime

Inside runtime.AsAmbient(), RunIntentAsync works without the runtime argument:

using var scope = runtime.AsAmbient();

var urgency = await email.RunIntentAsync<int>("Orders.ClassifyUrgency");

The scope follows the same rules as the client's (Collections). With no scope open, the call throws InvalidOperationException.

Audit

Each run is tagged with intent.name, intent.version and intent.source (the file, or memory for a definition added in code), plus the file's own tags, and IntentRuntime writes one Information log line per run with the name, the version, whether it succeeded and the attempts. Pass a logger to the runtime, or let dependency injection supply one. Where the tags go is in Intent files; a provider that logs every request sees them on each request (Seeing every request).

Limits

  • The result does not carry the tags. Describe(name) returns the current definition, which may be newer than the one a finished run used.
  • A run refused because the intent is disabled writes no log line, since it never starts.
  • The rest of the limits of intent files, versions and reload are in Intent files.