Existing code

Existing software needs AI, without a rewrite.

Adding AI often starts with a new service, a chat pipeline or an agent framework around code that already works, and every caller has to move with it. With IntentSharp you replace one interpretation step at a time, inside the method its callers already use, and the method keeps its output type and, when it is already asynchronous, its signature.

What it costs today

Business applications are full of small interpretation steps: reading why a customer returns an order, what a delivery note asks for, which account a free-text reference means. Each is a method of keyword checks that someone keeps extending, and each misses the phrasings nobody listed.

Improving one of them should not mean rebuilding the application around a new framework. A large change carries large risk, and every caller that depends on the method's output type would have to change with it.

How IntentSharp handles it

The returns desk's ReasonAsync used to be a list of keyword checks. Its body now runs ReadReason, a method marked [AIIntent], through ExecuteIntentAsync. The method was already asynchronous, so its signature and the ReturnReason enum are unchanged, and so are its callers. For the third note, both answers name a reason the enum does not have: the call fails, and the method logs a warning and returns Unclear, as the keyword version did for a note it could not read.

ReturnNotes.cs
/// <summary>/// Reads return notes for the returns desk. Its callers use/// <see cref="ReasonAsync"/>, as they did before./// </summary>public sealed class ReturnNotes(    IntentClient client, ILogger<ReturnNotes> logger){    // New: the interpretation step, stated as an intent. Its body    // never runs.    [AIIntent("Read why the customer returns the order.",        ExpectedOutput = "One ReturnReason, or Unclear if none fits.")]    [AIGuardrail(GuardrailType.MaxAttempts, 2)]    public static ReturnReason ReadReason(string note)        => throw new NotImplementedException("The body never runs.");     // The method the callers already use, already asynchronous:    // the same signature and the same ReturnReason. Only its body    // changed; it was a list of keyword checks.    public async Task<ReturnReason> ReasonAsync(        string note, CancellationToken cancellationToken = default)    {        var method = typeof(ReturnNotes).GetMethod(nameof(ReadReason))!;        var result = await client.ExecuteIntentAsync<ReturnReason>(            method, note, cancellationToken);        if (result.Success) return result.Value;         logger.LogWarning(            "Return note not read ({Attempts} attempts): {Problems}",            result.Attempts.Count, result.FailureSummary);        return ReturnReason.Unclear;    }}
A caller, unchanged
// A caller, unchanged.foreach (var (order, note) in returnsToday){    var reason = await notes.ReasonAsync(note);    if (reason == ReturnReason.Damaged)        carrierClaims.Add(order);    report.Add((order, reason));}
Output: three return notes
R-1001: "The mug arrived in pieces, the box was crushed." -> DamagedR-1002: "You sent the blue one. I ordered green." -> WrongItemR-1003: "See attached." -> UnclearLogged: Warning, "Return note not read (2 attempts)", with the result's FailureSummaryCarrier claims: R-1001Model calls: 4

ReturnReason is Damaged, WrongItem, NoLongerNeeded, ArrivedLate or Unclear. 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

One method's body changes. Its output type stays, and so do its callers when the method is already asynchronous.

  • The attributes are the intent. [AIIntent] states the instruction and the expected output, and [AIGuardrail] sets MaxAttempts, MaxExecutionTimeMs, MaxModelCalls, RequireJudge, OutputRequirement or CustomValidator. Settings on the class are the defaults, a setting on the method overrides them, and the method body never runs.
  • The output type. ExecuteIntentAsync<ReturnReason> returns an AIResult<ReturnReason>. Enum values are read by name, so an answer that is not a member of the enum fails the check and is retried with the finding.
  • The failure path is yours. A failed call does not throw: Success is false and Issues (or FailureSummary, on one line) says why. Here the method keeps its old answer for a note it cannot read, Unclear, and logs the reason; GetValueOrThrow() throws an exception that lists every issue instead.
  • What a method cannot set. A method has no setting for examples, the output-token limit, the provider, the model, DoNotRecord or tags. For those, call TransformAsync<T> with AIRequestOptions, or keep the intent in a file.
  • A synchronous method becomes asynchronous. A model call is I/O, and every IntentSharp call returns a Task, so a method such as ReturnReason Reason(string note) becomes Task<ReturnReason> ReasonAsync(...), and its callers await it. The returns desk's method was already asynchronous, so its callers did not change.
  • One step at a time. Other methods do not change. The class that holds the method takes an IntentClient, built once, or registered from your settings with AddIntentSharp, so whatever creates the class, a DI registration or a new, passes it in.
  • A pass is not a proof. A pass means the answer is a member of the enum and met your checks, not that it is the right reason.
  • Tests without a key. ScriptedProvider stands in for the model in unit tests, with no network and no key, as in the program above.

Pick one method, and leave the rest alone.

dotnet add package IntentSharp --prerelease