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.
/// <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.foreach (var (order, note) in returnsToday){ var reason = await notes.ReasonAsync(note); if (reason == ReturnReason.Damaged) carrierClaims.Add(order); report.Add((order, reason));}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: 4ReturnReason 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]setsMaxAttempts,MaxExecutionTimeMs,MaxModelCalls,RequireJudge,OutputRequirementorCustomValidator. 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 anAIResult<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:
Successis false andIssues(orFailureSummary, 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,
DoNotRecordor tags. For those, callTransformAsync<T>withAIRequestOptions, 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 asReturnReason Reason(string note)becomesTask<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 withAddIntentSharp, so whatever creates the class, a DI registration or anew, 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.
ScriptedProviderstands in for the model in unit tests, with no network and no key, as in the program above.
Read next
Overview: the intent
An intent inline, in a file or on a method, with its [AIGuardrail] settings.
How it works
What a failed call looks like, and the few mistakes that throw before any model call.
Configuration and DI
Build the client in code, or register it from your settings with AddIntentSharp.
More problems and their answers: Solutions.
Pick one method, and leave the rest alone.
dotnet add package IntentSharp --prerelease