Backend Systemsadvanced
Build a Customer Support Agent in .NET
Build a production customer support agent with tool calling, long-term memory, MCP integration, escalation logic, and human-in-the-loop — all in ASP.NET Core 9 with Microsoft.Extensions.AI.
Asma Hafeez KhanMay 25, 20269 min read
.NETC#AIagentstool callingMCPmemorysupportproduction
Build a Customer Support Agent in .NET
A support agent goes beyond a chatbot: it has access to real business systems, remembers past interactions, knows when to escalate to a human, and follows company-specific policies. This guide builds a complete agent from scratch.
What you'll build:
- Multi-tool agent (order lookup, product search, refund initiation)
- Long-term memory via vector search
- MCP tool server for CRM integration
- Human escalation with approval queue
- Conversation audit trail
- Cost-aware routing (simple questions to cheap model, complex to full model)
Agent Architecture
Customer message
↓
Intent classifier (gpt-4o-mini — cheap)
↓
┌────────────────────────────────────┐
│ Support Agent (gpt-4o) │
│ Tools: │
│ - GetOrderStatus │
│ - SearchProducts │
│ - InitiateRefund │
│ - SearchKnowledgeBase │
│ - EscalateToHuman │
└────────────────────────────────────┘
↓
Response validator (guardrail)
↓
Customer responseStep 1: Project Setup
C#
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Two-tier AI routing
builder.Services.AddKeyedSingleton<IChatClient>("fast",
new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]!)
.AsChatClient("gpt-4o-mini"));
builder.Services.AddKeyedSingleton<IChatClient>("full",
new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]!)
.AsChatClient("gpt-4o"));
builder.Services.AddEmbeddingGenerator<string, Embedding<float>>(services =>
new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]!)
.AsEmbeddingGenerator("text-embedding-3-small"));
builder.Services.AddDbContext<SupportDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
builder.Services.AddStackExchangeRedisCache(opts =>
opts.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddScoped<SupportAgent>();
builder.Services.AddScoped<AgentMemory>();
builder.Services.AddScoped<EscalationService>();
builder.Services.AddScoped<ConversationStore>();
var app = builder.Build();
app.MapSupportEndpoints();
app.Run();Step 2: Tool Definitions
C#
// src/SupportAgent.Application/Tools/SupportTools.cs
public class SupportTools(
IOrderRepository orders,
IProductRepository products,
IRefundService refunds,
IRagSearch knowledge,
EscalationService escalation)
{
[Description("Look up the status and details of a customer's order")]
public async Task<string> GetOrderStatus(
[Description("The order ID (numeric)")] int orderId,
[Description("The customer's user ID for authorization")] int customerId,
CancellationToken ct = default)
{
var order = await orders.GetByIdAsync(orderId, ct);
if (order is null)
return $"Order {orderId} not found.";
if (order.CustomerId != customerId)
return "You are not authorised to view this order.";
return $"""
Order #{order.Id}
Status: {order.Status}
Total: {order.Total:C}
Items: {string.Join(", ", order.Lines.Select(l => $"{l.Quantity}x {l.ProductName}"))}
Placed: {order.CreatedAt:MMM d, yyyy}
Expected delivery: {order.EstimatedDelivery?.ToString("MMM d") ?? "Not yet confirmed"}
""";
}
[Description("Search the product catalogue for items matching a description")]
public async Task<string> SearchProducts(
[Description("Natural language product search query")] string query,
CancellationToken ct = default)
{
var results = await products.SearchAsync(query, topK: 5, ct);
if (!results.Any())
return "No products found matching that description.";
return string.Join("\n", results.Select(p =>
$"- {p.Name} ({p.Category}): {p.Price:C} — " +
$"{(p.StockLevel > 0 ? $"In stock ({p.StockLevel} available)" : "Out of stock")}"));
}
[Description("Search the knowledge base for answers to policy and support questions")]
public async Task<string> SearchKnowledgeBase(
[Description("The question to search for")] string question,
CancellationToken ct = default)
{
var chunks = await knowledge.SearchAsync(question, ct);
if (!chunks.Any())
return "No relevant policy information found.";
return string.Join("\n\n", chunks.Select(c => c.Text));
}
[Description("Initiate a refund for an order. Only use when the customer explicitly requests a refund and the order qualifies.")]
public async Task<string> InitiateRefund(
[Description("The order ID to refund")] int orderId,
[Description("The reason for the refund")] string reason,
[Description("The customer's user ID")] int customerId,
CancellationToken ct = default)
{
var order = await orders.GetByIdAsync(orderId, ct);
if (order is null) return $"Order {orderId} not found.";
if (order.CustomerId != customerId) return "Unauthorised.";
var daysSincePurchase = (DateTime.UtcNow - order.CreatedAt).TotalDays;
if (daysSincePurchase > 30)
return "This order is outside the 30-day refund window. " +
"I'll escalate to our team for a manual review.";
var result = await refunds.InitiateAsync(orderId, reason, ct);
return result.Success
? $"Refund initiated for order #{orderId}. " +
$"Amount: {order.Total:C}. " +
$"Expected back to original payment method within 3-5 business days. " +
$"Reference: {result.RefundReference}"
: $"Unable to process refund automatically: {result.ErrorMessage}. " +
"I'll escalate this to our support team.";
}
[Description("Escalate the conversation to a human support agent. Use when: customer is upset, refund was declined, issue is complex, or you cannot resolve the issue.")]
public async Task<string> EscalateToHuman(
[Description("Brief summary of the issue for the human agent")] string issueSummary,
[Description("Priority: low, medium, high")] string priority,
CancellationToken ct = default)
{
var ticket = await escalation.CreateTicketAsync(issueSummary, priority, ct);
return $"I've created a support ticket (#{ticket.Id}) and " +
$"our team will contact you within {ticket.ExpectedResponseTime}. " +
"Is there anything else I can help you with in the meantime?";
}
}Step 3: Long-Term Agent Memory
C#
// src/SupportAgent.Application/Memory/AgentMemory.cs
public class AgentMemory(
SupportDbContext db,
IEmbeddingGenerator<string, Embedding<float>> embedder)
{
private const double SimilarityThreshold = 0.80;
private const int TopK = 3;
// Store a key customer interaction for future recall
public async Task StoreAsync(
int customerId,
string exchange,
CancellationToken ct)
{
var embedding = await embedder.GenerateAsync([exchange], cancellationToken: ct);
db.CustomerMemories.Add(new CustomerMemory
{
CustomerId = customerId,
Content = exchange,
Embedding = new Vector(embedding[0].Vector.ToArray()),
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync(ct);
}
// Retrieve relevant past interactions for context
public async Task<List<string>> RecallAsync(
int customerId,
string currentQuery,
CancellationToken ct)
{
var embedding = await embedder.GenerateAsync([currentQuery], cancellationToken: ct);
var vector = new Vector(embedding[0].Vector.ToArray());
return await db.CustomerMemories
.Where(m => m.CustomerId == customerId && m.Embedding != null)
.OrderBy(m => m.Embedding!.CosineDistance(vector))
.Take(TopK)
.Where(m => 1.0 - m.Embedding!.CosineDistance(vector) > SimilarityThreshold)
.Select(m => m.Content)
.ToListAsync(ct);
}
}Step 4: Intent Classification (Cheap Model)
C#
// Route to the cheapest model that can handle the request
public class IntentClassifier([FromKeyedServices("fast")] IChatClient fast)
{
private static readonly string[] SimpleIntents =
["greeting", "order_status", "product_search", "faq"];
private static readonly string[] ComplexIntents =
["refund", "complaint", "escalation", "billing_dispute"];
public async Task<IntentResult> ClassifyAsync(string message, CancellationToken ct)
{
var response = await fast.CompleteAsync([
new(ChatRole.System, """
Classify the customer message into exactly one intent.
Reply with JSON only: {"intent": "string", "complexity": "simple|complex"}
Intents: greeting, order_status, product_search, faq, refund,
complaint, escalation, billing_dispute, other
"""),
new(ChatRole.User, message)
], new ChatOptions { ResponseFormat = ChatResponseFormat.Json }, ct);
return JsonSerializer.Deserialize<IntentResult>(response.Message.Text!)
?? new IntentResult("other", "complex");
}
}
public record IntentResult(string Intent, string Complexity);Step 5: The Support Agent
C#
// src/SupportAgent.Application/SupportAgent.cs
public class SupportAgent(
[FromKeyedServices("full")] IChatClient full,
[FromKeyedServices("fast")] IChatClient fast,
SupportTools tools,
AgentMemory memory,
IntentClassifier classifier,
ConversationStore conversations,
ILogger<SupportAgent> logger)
{
private const string SystemPrompt = """
You are a customer support agent for OrderFlow.
Be empathetic, professional, and solution-focused.
CAPABILITIES:
- Look up order status and details
- Search products and check availability
- Initiate refunds for orders within 30 days
- Search policy documentation
- Escalate to human agents when needed
RULES:
1. Always verify the customer owns an order before sharing order details.
2. Initiate refunds only when explicitly requested.
3. If you cannot resolve an issue, escalate rather than apologise repeatedly.
4. Keep responses concise — under 150 words when possible.
5. Use the SearchKnowledgeBase tool for policy questions.
""";
public async IAsyncEnumerable<string> ChatAsync(
int customerId,
string sessionId,
string userMessage,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
// 1. Classify intent to decide model tier
var intent = await classifier.ClassifyAsync(userMessage, ct);
// 2. Recall relevant past interactions
var memories = await memory.RecallAsync(customerId, userMessage, ct);
// 3. Load conversation history
var history = await conversations.GetHistoryAsync(sessionId, ct);
// 4. Build the message list
var messages = new List<ChatMessage>
{
new(ChatRole.System, SystemPrompt),
new(ChatRole.System, $"Customer ID: {customerId}"),
};
if (memories.Any())
{
messages.Add(new(ChatRole.System,
"Relevant history with this customer:\n" +
string.Join("\n---\n", memories)));
}
messages.AddRange(history);
messages.Add(new(ChatRole.User, userMessage));
// 5. Choose model based on complexity
var client = intent.Complexity == "simple" ? fast : full;
// 6. Register tools
var allTools = new List<AIFunction>
{
AIFunctionFactory.Create(
(int orderId, CancellationToken innerCt)
=> tools.GetOrderStatus(orderId, customerId, innerCt),
"GetOrderStatus"),
AIFunctionFactory.Create(
(string query, CancellationToken innerCt)
=> tools.SearchProducts(query, innerCt),
"SearchProducts"),
AIFunctionFactory.Create(
(string question, CancellationToken innerCt)
=> tools.SearchKnowledgeBase(question, innerCt),
"SearchKnowledgeBase"),
AIFunctionFactory.Create(
(int orderId, string reason, CancellationToken innerCt)
=> tools.InitiateRefund(orderId, reason, customerId, innerCt),
"InitiateRefund"),
AIFunctionFactory.Create(
(string summary, string priority, CancellationToken innerCt)
=> tools.EscalateToHuman(summary, priority, innerCt),
"EscalateToHuman"),
};
var options = new ChatOptions
{
Tools = allTools,
ToolMode = ChatToolMode.Auto,
};
logger.LogInformation(
"Support agent responding to customer {CustomerId}, " +
"intent={Intent}, complexity={Complexity}, model={Model}",
customerId, intent.Intent, intent.Complexity,
intent.Complexity == "simple" ? "gpt-4o-mini" : "gpt-4o");
// 7. Stream the response
var accumulated = new System.Text.StringBuilder();
await foreach (var update in client.CompleteStreamingAsync(messages, options, ct))
{
if (update.Text is { Length: > 0 } token)
{
accumulated.Append(token);
yield return token;
}
}
var responseText = accumulated.ToString();
// 8. Persist conversation and update memory
await conversations.AppendAsync(sessionId, "user", userMessage, ct);
await conversations.AppendAsync(sessionId, "assistant", responseText, ct);
// Store key exchanges in long-term memory
if (intent.Intent is "refund" or "complaint" or "escalation")
{
await memory.StoreAsync(customerId,
$"Customer said: {userMessage}\nAgent responded: {responseText}", ct);
}
}
}Step 6: MCP Integration (CRM Tools)
C#
// Expose CRM tools via MCP so Claude Desktop and other clients can call them
[McpServerToolType]
public class CrmMcpTools(ICrmClient crm)
{
[McpServerTool(Name = "get_customer_profile")]
[Description("Get full customer profile including tier, lifetime value, and open tickets")]
public async Task<string> GetCustomerProfile(
[Description("Customer ID")] int customerId,
CancellationToken ct = default)
{
var profile = await crm.GetProfileAsync(customerId, ct);
if (profile is null) return "Customer not found.";
return $"""
Customer: {profile.Name} ({profile.Email})
Tier: {profile.Tier} customer
Lifetime value: {profile.LifetimeValue:C}
Member since: {profile.JoinDate:MMM yyyy}
Open support tickets: {profile.OpenTickets}
Last contact: {profile.LastContactDate?.ToString("MMM d") ?? "Never"}
""";
}
[McpServerTool(Name = "update_customer_notes")]
[Description("Add a note to the customer's CRM record")]
public async Task<string> UpdateCustomerNotes(
[Description("Customer ID")] int customerId,
[Description("Note to add")] string note,
CancellationToken ct = default)
{
await crm.AddNoteAsync(customerId, note, DateTime.UtcNow, ct);
return $"Note added to customer {customerId}'s record.";
}
}
// Register MCP endpoint (HTTP + SSE transport)
app.MapMcp("/mcp");Step 7: Escalation with Human Approval
C#
// src/SupportAgent.Application/EscalationService.cs
public class EscalationService(
SupportDbContext db,
ISlackClient slack,
IEmailService email)
{
public async Task<EscalationTicket> CreateTicketAsync(
string summary,
string priority,
CancellationToken ct)
{
var expectedResponse = priority switch
{
"high" => "1 hour",
"medium" => "4 hours",
_ => "1 business day"
};
var ticket = new EscalationTicket
{
Summary = summary,
Priority = priority,
Status = "Open",
ExpectedResponseTime = expectedResponse,
CreatedAt = DateTime.UtcNow,
};
db.EscalationTickets.Add(ticket);
await db.SaveChangesAsync(ct);
// Notify the support team
if (priority == "high")
{
await slack.PostAsync("#support-urgent",
$"HIGH PRIORITY ticket #{ticket.Id}: {summary}");
}
else
{
await email.SendAsync("support@company.com",
$"New support ticket #{ticket.Id} ({priority})",
summary);
}
return ticket;
}
}Streaming Chat Endpoint
C#
app.MapPost("/api/support/chat", async (
SupportChatRequest req,
ClaimsPrincipal user,
SupportAgent agent,
HttpContext ctx,
CancellationToken ct) =>
{
var customerId = user.GetUserId();
var sessionId = req.SessionId ?? Guid.NewGuid().ToString();
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
await ctx.Response.WriteAsync(
$"data: {JsonSerializer.Serialize(new { sessionId })}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
try
{
await foreach (var token in agent.ChatAsync(customerId, sessionId, req.Message, ct))
{
await ctx.Response.WriteAsync(
$"data: {JsonSerializer.Serialize(new { token })}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException) { }
await ctx.Response.WriteAsync("data: [DONE]\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
})
.RequireAuthorization();
public record SupportChatRequest(string Message, string? SessionId = null);Cost and Performance Profile
Per conversation message:
Intent classification (gpt-4o-mini): ~100 tokens, $0.00002
Simple response (gpt-4o-mini): ~800 tokens, $0.00016
Complex response (gpt-4o): ~1,500 tokens, $0.004
Tool calls add:
GetOrderStatus: ~200 extra tokens per call
SearchKnowledgeBase: ~1,000 extra tokens per call (retrieval context)
Typical cost per conversation (5 turns):
Simple support: $0.001 - $0.003
Complex refund: $0.010 - $0.020
At 10,000 conversations/month: $10-200/month
At 100,000 conversations/month: $100-2,000/month
Model routing (simple → fast) saves ~80% on simple queries.Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.