Per-customer rules

Each customer's rules become another branch in the code.

When customers need different required fields and categories, the differences end up as conditions on the customer spread through the intake code. With IntentSharp each customer's rules live in its own intent file, chosen by name, and one call returns the same .NET type for every customer, checked against that customer's rules.

What it costs today

Software that serves many customers collects exceptions. One customer quotes a purchase order on every request, another charges everything to a cost center, a third sorts its work into categories nobody else uses. Each difference becomes a condition on the customer, in the form handling, the validation and the reporting, and every new customer adds another round of them.

The branches are hard to see in one place and easy to break when another customer changes. Every change waits for a release, even when a customer only asked for one field to become required.

How IntentSharp handles it

Northwind, Contoso and Fabrikam each have a file with their own required fields and categories. The one call, IntakeAsync, picks the file by the customer's name and returns an IntakeRequest. Contoso's file requires a cost center: the first answer leaves it out, the rule fails, and the second attempt carries the finding back.

Northwind's fileIntent file
name: Intake.Northwindversion: 3owner: northwind-accountintent: >-  Fill in the intake form  from this request from  Northwind. It quotes its  purchase order, like  PO-4471.shape:  clrType: IntakeRequestrules:  required:    - purchaseOrder  properties:    category:      enum:        - hardware        - software        - cloud    purchaseOrder:      pattern: '^PO-\d{4}$'
Contoso's fileIntent file
name: Intake.Contosoversion: 1owner: contoso-accountintent: >-  Fill in the intake form  from this request from  Contoso. It charges each  request to a cost center,  as CC-1234, and gives a  due date.shape:  clrType: IntakeRequestrules:  required:    - costCenter    - neededBy  properties:    category:      enum:        - hardware        - software    costCenter:      pattern: '^CC-\d{4}$'
Fabrikam's fileIntent file
name: Intake.Fabrikamversion: 2owner: fabrikam-accountintent: >-  Fill in the intake form  from this request from  Fabrikam. It sends field  service work: a repair,  an install or a training  visit.shape:  clrType: IntakeRequestrules:  properties:    category:      enum:        - repair        - install        - training
Program.cs
using var catalog = new FileIntentCatalog(intentsFolder); // IntakeRequest is the one result type the files may name; a file// cannot load a type of its own.var types = new IntentTypeRegistry().Add<IntakeRequest>();var intents = new IntentRuntime(client, catalog, types: types); // One intake call for every customer. Each customer's file holds// its required fields and its categories.Task<AIResult<IntakeRequest>> IntakeAsync(string customer, string text){    var intent = $"Intake.{customer}";    return text.RunIntentAsync<IntakeRequest>(intents, intent);}
Output: one call, three customers
Northwind: Intake.Northwind version 3  Success: true, after 1 attempt  Value: category hardware, purchaseOrder PO-4471Contoso: Intake.Contoso version 1  [Validator] attempt 1: value.costCenter is null; it is required.  Success: true, after 2 attempts  Value: category software, costCenter CC-2210, neededBy 2026-11-30Fabrikam: Intake.Fabrikam version 2  Success: true, after 1 attempt  Value: category repair

IntakeRequest is one record: Category, Summary, PurchaseOrder, CostCenter and NeededBy, the last three nullable; the output shows each value's category and the members that are not null. Each file is Intake.<customer>.intent.yaml. Compiled and run with every build of this site; the model's answers are scripted, and a pass means the answer met the checks, not that it is right.

What stays in your control

The files hold what differs between customers. The type, the call and the checks that need code stay in your code.

  • One result type. IntakeRequest is registered in code with IntentTypeRegistry. A file can name only a type your application registered, and nothing in a file can load code. A new type, or a new member on it, needs a release.
  • Each customer's rules. required, enum and pattern are checked in your process on every answer, with no model call. A broken rule becomes a finding with its place in the result, such as value.costCenter, and the next attempt sends it to the model, within the attempts you allow.
  • A customer without a file. An unknown intent name throws IntentNotFoundException before any model call, and a file with enabled: false throws IntentDisabledException.
  • What a failure looks like. When the attempts run out, the result has Success false and every finding in Issues, and your code decides what happens to the request.
  • Checks that need code. A rule a file cannot express, such as a purchase order that must exist in your system, is a C# check your application registers by name and a file switches on under validators.
  • A new customer. A new file, which the watched folder loads while the application runs. The call builds the intent's name from the customer, so it does not change.
  • A pass is not a proof. A pass means the answer met that customer's rules, not that it read the request right: a category from the file's list can still be the wrong one.

Give one customer its own file.

dotnet add package IntentSharp --prerelease
dotnet add package IntentSharp.Catalog --prerelease