When I add AI to an existing support, ERP, or CRM application, my first release boundary is simple: the model drafts; an authorized person decides what leaves the system. That keeps a model mistake from immediately becoming an external action.
That is the starting point in SupportAgent.NET, the ASP.NET Core and React support copilot I am building (source on GitHub). The assistant prepares replies for human review. Here is how I would build the approval, audit, and delivery workflow around that boundary.
An Approve button is not enough. Approval must cover a specific revision, recipient, and set of attachments, and remain valid when a background worker eventually sends them.
1. Model the lifecycle explicitly
The main path is Generated → In Review → Edited → Approved → Sent. Editing is optional: an unchanged draft can move from In Review to Approved. Rejection branches from In Review or Edited; Sent is not a precursor to Rejected.
Generated -> In Review
In Review -> Edited | Approved | Rejected
Edited -> Approved | Rejected
Approved -> Sent
Generated means a candidate exists. In Review means a reviewer has opened it. Edited means a new immutable revision exists, still awaiting approval.
I freeze approved content. A changed body, subject, recipient, or attachment requires a new review. Regeneration creates a new draft with its own provenance, rather than overwriting approved evidence.
Only server-side workflow methods can transition states. The model receives neither an approval tool nor a sending tool. A prompt saying "never send" is not the enforcement mechanism.
2. Store revisions and decisions separately
These examples use .NET 8/9-compatible C# and SQL Server. I keep workflow state and generation provenance on Draft:
using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
public enum DraftStatus
{
Generated, InReview, Edited, Approved, Sent, Rejected
}
public sealed class Draft
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public int TicketId { get; set; }
public DraftStatus Status { get; set; }
public int CurrentRevision { get; set; }
public required string PromptVersion { get; set; }
public required string ModelName { get; set; }
public required string SourceIdsJson { get; set; }
public required string ToolCallsJson { get; set; }
[Timestamp] public byte[] RowVersion { get; set; } = [];
}
On SQL Server, [Timestamp] maps this property to a database-generated concurrency token, not a date.
public sealed class DraftRevision
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid DraftId { get; set; }
public int Number { get; set; }
public required string Text { get; set; }
public required string EnvelopeJson { get; set; }
public required string ActorId { get; set; }
public DateTimeOffset AtUtc { get; set; }
}
public sealed class ApprovalDecision
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public Guid DraftId { get; set; }
public int Revision { get; set; }
public bool Approved { get; set; }
public required string ActorId { get; set; }
public string? Reason { get; set; }
public DateTimeOffset AtUtc { get; set; }
}
EnvelopeJson captures the reviewed recipient, subject, and immutable attachment references. Revisions are append-only through application services; public setters do not enforce immutability.
I configure tenant-consistent foreign keys and unique revision numbers per draft. Each edit also increments the parent's revision, updating its row version. Decision and outbox uniqueness prevent duplicate approval or delivery intents for that revision.
Source IDs include document versions. Tool evidence contains validated arguments, minimal results, timestamps, and correlation IDs, stored in protected storage rather than ordinary logs.
The JSON properties are logical fields, not encryption. I encrypt sensitive payloads or replace them with protected object references. I also retain the exact prompt template and evidence versions: a source ID that later points to changed content cannot reconstruct the original decision.
3. Generate and expose workflow operations
For the final drafting call, authorized retrieval and read-only tools have already gathered evidence. IChatClient supplies the provider abstraction; the server, not generated text, chooses the persisted state.
using Microsoft.Extensions.AI;
public static async Task<ChatResponse> GenerateAsync(
IChatClient client, string prompt, string evidence,
CancellationToken ct)
{
var response = await client.GetResponseAsync(
[new(ChatRole.System, prompt), new(ChatRole.User, evidence)],
new ChatOptions { Tools = [], ToolMode = ChatToolMode.None }, ct);
if (response.FinishReason != ChatFinishReason.Stop ||
string.IsNullOrWhiteSpace(response.Text))
throw new InvalidOperationException("No complete draft returned.");
return response;
}
This client has no sending capability. I persist the response text as revision one, capture the returned model ID alongside configured model information, and create the draft as Generated. This checks completion, not correctness; provider refusals need separate handling before persistence.
Generation happens outside a database transaction; saving the draft and evidence happens together.
With Identity already configured, I register policies and routes:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("Drafts.Edit", p => p.RequireAuthenticatedUser()
.RequireRole("SupportAgent", "Admin"));
options.AddPolicy("Drafts.Approve", p => p.RequireAuthenticatedUser()
.RequireRole("SupportAgent", "Admin")
.RequireClaim("permission", "drafts.approve"));
});
var app = builder.Build();
var drafts = app.MapGroup("/drafts").RequireAuthorization("Drafts.Edit");
drafts.MapGet("/", DraftEndpoints.List);
drafts.MapPost("/generate", DraftEndpoints.Generate);
drafts.MapPost("/{id:guid}/review", DraftEndpoints.StartReview);
drafts.MapPut("/{id:guid}", DraftEndpoints.Edit);
drafts.MapPost("/{id:guid}/approve", DraftEndpoints.Approve)
.RequireAuthorization("Drafts.Approve");
drafts.MapPost("/{id:guid}/reject", DraftEndpoints.Reject)
.RequireAuthorization("Drafts.Approve");
DraftEndpoints delegates to scoped application services. Named policies enforce role and permission requirements; services additionally authorize the ticket, customer, and tenant. Identity never comes from request DTOs.
For cookie-authenticated mutations, I explicitly validate antiforgery tokens; route authorization alone is not CSRF protection.
The queue is a tenant-filtered, paginated query over reviewable states. Its detail view returns the exact revision and row version displayed to the reviewer.
I use request DTOs rather than binding EF entities. Edit accepts content and concurrency fields; approve accepts only concurrency fields; reject adds a reason. None accepts a tenant, approver identity, timestamp, or target status. I never interpret approval-like language inside a draft as an application command.
Prevent stale approvals
An approval request carries that revision number and row version, not replacement text. After resource authorization and loading the tenant-scoped revision, the service performs:
if (draft.CurrentRevision != request.Revision ||
draft.Status is not (DraftStatus.InReview or DraftStatus.Edited))
return Results.Conflict("Refresh the draft before approving.");
db.Entry(draft).Property(x => x.RowVersion).OriginalValue =
request.RowVersion;
var decision = new ApprovalDecision
{
Id = Guid.NewGuid(), TenantId = tenantId, DraftId = draft.Id,
Revision = draft.CurrentRevision, Approved = true,
ActorId = userId, AtUtc = DateTimeOffset.UtcNow
};
draft.Status = DraftStatus.Approved;
db.ApprovalDecisions.Add(decision);
db.Outbox.Add(CreateOutbox(draft, revision, decision));
try
{
await db.SaveChangesAsync(ct);
return Results.Accepted();
}
catch (DbUpdateConcurrencyException)
{
return Results.Conflict("Another agent changed this draft.");
}
This fragment assumes validated request fields and server-resolved identities. CreateOutbox copies the approved delivery payload and decision reference; it does not send.
One SaveChangesAsync call commits the draft, decision, and outbox together. A concurrent edit or approval makes the stale update fail. Return HTTP 409 and refresh; never automatically approve the newer revision. Handle known unique-key collisions as conflicts too.
I apply equivalent concurrency checks to review, edit, and reject operations. The UI labels the button "Approve and queue for sending", making the external effect explicit.
4. Keep an audit trail without creating a data leak
I record the actor, server timestamp, transition, revision, decision reason, and correlation ID. Immutable revisions support both incremental diffs and the original-AI-versus-final-text comparison.
The reviewer sees sources, tool failures, recipient details, and financial values beside the draft. A stored difference alone does not show whether an edit corrected a factual error.
For ERP and CRM replies, I highlight changed amounts and account references. Totals still come from business services, not the model or an edit-distance score. The reviewer must be able to inspect evidence without searching a separate logging system.
Sensitive text belongs in encrypted, access-controlled evidence storage. Operational logs contain identifiers, timings, and outcome codes, not prompts, customer details, access tokens, or secrets. OWASP's logging guidance explicitly distinguishes useful audit events from sensitive data that should be excluded or protected.
I also avoid production Trace logging on LoggingChatClient, which can include message contents and options.
I restrict audit writes and reads separately and define retention and deletion for revisions, evidence snapshots, exports, and backups. Keeping everything forever is not an audit strategy.
5. Send through a transactional outbox
A transactional outbox separates durable approval from unreliable network delivery. The worker sends only an immutable payload attached to a valid Approved draft and matching decision.
I store a stable idempotency key per tenant, draft, and revision, plus attempt count, next-attempt time, lease ownership, and provider receipt. Multiple workers claim entries atomically with expiring leases, not a read-then-update race.
Before dispatch, the worker verifies approval, recipient association, evidence freshness, and cancellation status. Cancelling pending delivery must compete atomically with claiming it. After dispatch begins, cancellation is not a promise of recall.
If relevant account data changed after approval, I pause delivery for renewed review. The worker never silently regenerates a reply. A server-side kill switch stops new dispatches while receipt processing and reconciliation continue.
The worker resolves a fresh service scope and tenant-specific database context per operation. A BackgroundService registered through AddHostedService is a singleton; it must not hold a request-scoped DbContext (Microsoft documents the scoped-service pattern for this).
After provider acceptance, it records the receipt, completes the outbox entry, and marks Sent in one local transaction. Here, Sent means provider-accepted, not confirmed delivery; bounces and delivery events are separate.
Retries are not exactly-once delivery
A worker can crash after provider acceptance but before recording success. Retrying must reuse the same key and identical payload. Provider-side deduplication, such as Resend's idempotency keys, is necessary for that ambiguous window; its retention period (24 hours for Resend) limits safe retries.
Without that guarantee, an outbox cannot promise duplicate-free external sending. Uncertain outcomes go to reconciliation rather than blind retries. Transient failures get bounded backoff; permanent failures go to operator review without generating a new key.
Delivery failure belongs to outbox status, not rejection of the approved text.
6. Measure review effort and correctness separately
I would track normalized edit distance between the generated revision and the actual sent text: character-level Levenshtein distance divided by the longer text length, with two empty strings defined as zero. Keep normalization consistent across evaluations.
A small distance is not proof of quality: changing one amount can correct a serious error. I pair it with factual-correction labels and rejection reasons such as unsupported claim, wrong account, missing evidence, or inappropriate tone.
For approval rate, I use approved drafts divided by approved plus rejected drafts in a defined cohort. Pending drafts remain separate, as do subsequent delivery failures.
I compare these outcomes by prompt version, model, and retrieval configuration. High acceptance alone might indicate superficial review. I review samples before changing prompts, and treat tenant-scoped feedback as protected data rather than automatically turning it into training material.
7. Mistakes I avoid
- Auto-send "just for simple cases." I do not let model confidence bypass the approval workflow.
- Keeping text without provenance. A polished reply is not explainable without the prompt version, source versions, and tool evidence that produced it.
- Exposing a send tool to the LLM. Sending credentials and execution belong to the delivery worker, not the model's tool registry.
I also test approval-versus-edit races, cross-tenant access, duplicate worker claims, and crashes immediately after provider acceptance. Those tests exercise the boundary that matters: an unapproved or superseded message must never become a customer communication.
Conclusion
When I am adding AI to existing ASP.NET Core apps, my default is not merely "put a human somewhere in the loop." It is a versioned draft, an authorized decision, an immutable delivery payload, and a recoverable sending workflow.
That lets an existing ASP.NET Core application benefit from AI drafting without handing the model authority over customer communication.
Rahmat Afridi is a Senior .NET Developer and AI Integration Specialist who adds AI to existing ASP.NET Core applications. More at rahmatafridi.com.
Top comments (0)