Integrate an AI Agent into existing apps using GitHub Copilot SDK
The GitHub Copilot SDK exposes the same engine behind GitHub Copilot CLI as a programmable SDK. It allows you to embed agentic AI workflows in your applications — including custom tools that let the AI call your code.
In this exercise, you integrate an AI-powered customer support agent into the ContosoShop E-commerce Support Portal. By the end, the “Contact Support” page will allow a user to ask questions (for example, “Where is my order?” or “I need to return an item”) and receive helpful, automated answers from an AI agent. The agent uses backend tools (like checking order status or initiating a return) to resolve queries.
This exercise should take approximately 60 minutes to complete.
IMPORTANT: To complete this exercise, you must provide your own GitHub account and GitHub Copilot subscription. If you don’t have a GitHub account, you can sign up for a free individual account and use a GitHub Copilot Free plan to complete the exercise. If you have access to a GitHub Copilot Pro, GitHub Copilot Pro+, GitHub Copilot Business, or GitHub Copilot Enterprise subscription from within your lab environment, you can use your existing GitHub Copilot subscription to complete this exercise.
Before you start
Your lab environment MUST include the following resources:
- Git 2.48 or later
- .NET SDK 8.0 or later
- Visual Studio Code with the C# Dev Kit and GitHub Copilot Chat extensions
- GitHub Copilot CLI installed and in your PATH
TODO: Update with instructions for installing GitHub Copilot CLI if not present in the environment. For help with configuring your lab environment, open the following link in a browser: Configure your GitHub Copilot SDK lab environment.
Exercise scenario
You’re a software developer working for a consulting firm. The firm developed the ContosoShop E-commerce Support Portal (a Blazor WebAssembly application with an ASP.NET Core backend) for a client named Contoso Corporation. The application includes order management, item returns, and inventory tracking. Contoso needs you to enhance the existing “Contact Support” page with an AI-powered customer support agent that can look up order details and initiate returns on behalf of customers.
The ContosoShop application uses a three-project architecture:
- ContosoShop.Server: ASP.NET Core Web API with Entity Framework Core, Identity authentication, and SQLite.
- ContosoShop.Client: Blazor WebAssembly SPA that runs in the browser and calls the server API.
- ContosoShop.Shared: Shared class library containing models, DTOs, and enums.
For the purposes of this lab, the application can be tested using two demo users (Mateo Gomez and Megan Bowen) with 20 sample orders across various statuses (Processing, Shipped, Delivered, and Returned).
This exercise includes the following tasks:
- Review the starter application.
- Install the GitHub Copilot SDK components.
- Create the agent tools service.
- Configure the Copilot SDK agent and expose an API endpoint.
- Update the Blazor frontend to interact with the agent.
- Test the end-to-end AI agent experience.
Review the starter application
Before developing the AI customer support agent, you need to become familiar with the existing codebase and ensure the application runs correctly.
Use the following steps to complete this task:
-
Open a browser window and navigate to GitHub.com.
You can log in to your GitHub account using the following URL: GitHub login.
-
Sign in to your GitHub account, and then open your repositories tab.
You can open your repositories tab by clicking on your profile icon in the top-right corner, then selecting Repositories.
-
On the Repositories tab, select the New button.
-
Under the Create a new repository section, select Import a repository.
-
On the Import your project to GitHub page, under Your source repository details, enter the following URL for the source repository:
https://github.com/MicrosoftLearning/github-copilot-sdk-starter-app -
Under the Your new repository details section, in the Owner dropdown, select your GitHub username.
-
In the Repository name field, enter ContosoShop
GitHub automatically checks the availability of the repository name. If this name is already taken, append a unique suffix (for example, your initials or a random number) to the repository name to make it unique.
-
To create a private repository, select Private, and then select Begin import.
GitHub uses the import process to create the new repository in your account.
NOTE: It can take a minute or two for the import process to finish. Wait for the import process to complete.
GitHub displays a progress indicator and notify you when the import is complete.
-
Once the import is complete, open your new repository.
A link to your repository should be displayed. Your repository should be located at:
https://github.com/YOUR-USERNAME/ContosoShop.You can create a local clone of your ContosoShop repository and then initialize GitHub Spec Kit within the project directory.
-
On your ContosoShop repository page, select the Code button, and then copy the HTTPS URL.
The URL should be similar to:
https://github.com/YOUR-USERNAME/ContosoShop.git -
Open a terminal window in your development environment, and then navigate to the location where you want to create the local clone of the repository.
For example:
Open a terminal window (Command Prompt, PowerShell, or Terminal), and then run:
cd C:\TrainingProjectsReplace
C:\TrainingProjectswith your preferred location. You can use any directory where you have write permissions, and you can create a new folder location if needed. -
To clone your ContosoShop repository, enter the following command:
Be sure to replace
YOUR-USERNAMEwith your actual GitHub username before running the command.git clone https://github.com/YOUR-USERNAME/ContosoShop.gitYou might be prompted to authenticate using your GitHub credentials during the clone operation. You can authenticate using your browser.
-
To navigate into your ContosoShop directory and open it in Visual Studio Code, enter the following commands:
cd ContosoShop code . -
Take a moment to review the project structure.
Use Visual Studio Code’s EXPLORER view to expand the project folders. You should see a folder structure that’s similar to the following example:
github-copilot-sdk-starter-app (root) ├── ContosoShop.Client/ (Blazor WebAssembly frontend) │ ├── Layout/ (MainLayout, NavMenu) │ ├── Pages/ (Home, Login, Orders, OrderDetails, Support, Inventory) │ ├── Services/ (OrderService, CookieAuthenticationStateProvider) │ └── Shared/ (OrderStatusBadge) ├── ContosoShop.Server/ (ASP.NET Core backend) │ ├── App_Data/ (used for the SQLite database file) │ ├── Controllers/ (AuthController, OrdersController, InventoryController) │ ├── Data/ (ContosoContext, DbInitializer, Migrations) │ ├── Services/ (OrderService, InventoryService, EmailServiceDev) │ └── Program.cs (App configuration and middleware) ├── ContosoShop.Shared/ (Shared class library) │ ├── Models/ (Order, OrderItem, Product, User, etc.) │ └── DTOs/ (InventorySummary, ReturnItemRequest) └── ContosoShopSupportPortal.slnx (Solution file) -
Open the ContosoShop.Server/Program.cs file and review the application configuration.
Notice the following key configuration areas:
- Entity Framework Core with SQLite for data access
- ASP.NET Core Identity for authentication with cookie-based sessions
- Service registrations for
IEmailService,IInventoryService, andIOrderService - Database seeding via
DbInitializer.InitializeAsyncat startup - CORS, rate limiting, CSRF protection, and security headers middleware
-
Open the ContosoShop.Server/Controllers/OrdersController.cs file and note the existing API endpoints.
The orders controller provides the following endpoints for managing orders:
-
GetOrders— Gets all orders for the authenticated user -
GetOrder— Gets a specific order with items (verifies ownership) -
ReturnOrderItems— Processes item-level returns for a delivered order
-
-
Open the ContosoShop.Server/Services/OrderService.cs file and review the
ProcessItemReturnAsyncmethod.The ProcessItemReturnAsync method processes customer returns for order items. It performs several critical operations to ensure that returns are handled correctly while maintaining data integrity and providing a good customer experience.
Key Operations:
- Validates order exists and is returnable (Delivered/Returned/PartialReturn status)
- Verifies return quantities don’t exceed available amounts
- Creates OrderItemReturn records with refund calculations
- Restores inventory stock via _inventoryService
- Updates order status (Returned or PartialReturn based on items)
- Sends email confirmation with refund details
-
Open a terminal in the ContosoShop.Server directory and build the solution.
cd ContosoShop.Server dotnet buildIMPORTANT: The project uses .NET 8 by default. If you have the .NET 9 or .NET 10 SDK installed, but not .NET 8, you need to update the project to target the version of .NET that you have installed. To update to a later version of .NET, open the GitHub Copilot Chat view and ask GitHub Copilot to update your project files to the version of .NET that you have installed in your environment. For example, you can ask: “I need you to update the project to target .NET 10. Be sure to update all related resources such as NuGet packages and project references. After completing all required updates, ensure that all projects build successfully.” The AI assistant will help update your solution.
The build should complete successfully without errors (there might be warnings).
-
Start the server application.
dotnet runNOTE: The first time you run the application, it may take a minute to apply database migrations and seed the database with sample data. You should see console output indicating that the database has been initialized and seeded. You should also see a message that the server starts listening on
http://localhost:5266. -
Open a browser and navigate to
http://localhost:5266.You should see the ContosoShop login page. Accept any certificate warnings for the localhost development certificate.
-
Sign in using the demo credentials.
Enter
mateo@contoso.comfor the email andPassword123!for the password, and then select Login. -
Verify that orders are displayed on the Orders page.
You should see 10 orders for Mateo with various statuses (Delivered, Shipped, Processing, Returned). You can select the View Details button for an order to view the order details page.
-
Navigate to the Contact Support page.
You should see contact information and a message that states “Interactive AI Chat Support Coming Soon”. This Customer Support page is the page that you’ll enhance in subsequent tasks. The corresponding project file is: ContosoShop.Client/Pages/Support.razor.
-
On the navigation menu, select Logout.
-
Return to the terminal where the server is running and press Ctrl+C to stop the application.
Install the GitHub Copilot SDK components
In this task, you add the GitHub Copilot SDK NuGet package and the Microsoft.Extensions.AI package to the server project. The GitHub Copilot SDK provides the core components for building AI agents, while Microsoft.Extensions.AI provides types for defining custom tools that the agent can call.
Use the following steps to complete this task:
-
Ensure that you have Visual Studio Code’s integrated terminal open and that you are in the ContosoShop.Server directory.
-
In terminal, to verify that the GitHub Copilot CLI is installed and authenticated, enter the following command:
copilot --versionYou should see a version number (for example,
0.0.403). If the command is not found, install the Copilot CLI by following the Copilot CLI installation guide.NOTE: The GitHub Copilot SDK communicates with the Copilot CLI in server mode. The SDK manages the CLI process lifecycle automatically, but the CLI must be installed and accessible in your PATH.
-
To configure the GitHub Copilot SDK NuGet package to your project, enter the following command:
dotnet add package GitHub.Copilot.SDK --prereleaseThis command installs the latest preview version of the SDK. The SDK provides
CopilotClient,CopilotSession, and related types for building AI agents.NOTE: While the GitHub Copilot SDK is in Technical Preview, the
--prereleaseflag is required to install it. -
To add the
Microsoft.Extensions.AIpackage to your project, enter the following command:dotnet add package Microsoft.Extensions.AIThe GitHub Copilot SDK uses
Microsoft.Extensions.AIfor defining custom tools. This package provides theAIFunctionFactoryand related types for creating tools that the AI agent can call. -
To verify the packages installed correctly, build the project:
dotnet buildThe build should succeed without errors.
Create the agent tools service
In this task, you create a new service class in the server project that implements the tools the AI agent will use to look up orders and process returns. This service will be registered in dependency injection and called by the AI agent when handling user queries.
Use the following steps to complete this task:
-
In Visual Studio Code’s EXPLORER view, right-click the ContosoShop.Server/Services folder, and then select New File.
You’ll use this file to create the SupportAgentTools service class.
-
Name the file SupportAgentTools.cs.
-
Add the following code to the SupportAgentTools.cs file:
using ContosoShop.Server.Data; using ContosoShop.Shared.Models; using ContosoShop.Shared.DTOs; using Microsoft.EntityFrameworkCore; namespace ContosoShop.Server.Services; /// <summary> /// Provides tool functions that the AI support agent can invoke /// to look up order information and process returns. /// </summary> public class SupportAgentTools { private readonly ContosoContext _context; private readonly IOrderService _orderService; private readonly IEmailService _emailService; private readonly ILogger<SupportAgentTools> _logger; public SupportAgentTools( ContosoContext context, IOrderService orderService, IEmailService emailService, ILogger<SupportAgentTools> logger) { _context = context; _orderService = orderService; _emailService = emailService; _logger = logger; } // add the `GetOrderDetailsAsync` method here // add the `GetUserOrdersSummaryAsync` method here // add the `ProcessReturnAsync` method here // add the `SendCustomerEmailAsync` method here }This code sets up the class skeleton with dependency injection. The constructor receives four dependencies:
-
ContosoContext— the Entity Framework Core database context for querying orders and users directly. -
IOrderService— the existing service that handles return processing logic, inventory updates, and email confirmations. -
IEmailService— the service used to send follow-up emails to customers. -
ILogger<SupportAgentTools>— a logger for recording each tool invocation, which is useful for debugging and monitoring agent behavior.
These dependencies allow the tools to access real data and leverage existing business logic rather than duplicating it.
-
-
Inside the
SupportAgentToolsclass (after the constructor’s closing brace), add theGetOrderDetailsAsyncmethod:/// <summary> /// Gets the status and details of a specific order by order ID. /// The AI agent calls this tool when a user asks about their order status. /// </summary> public async Task<string> GetOrderDetailsAsync(int orderId, int userId) { _logger.LogInformation("Agent tool invoked: GetOrderDetails for orderId {OrderId}, userId {UserId}", orderId, userId); var order = await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId); if (order == null) { return $"I could not find order #{orderId} associated with your account. Please double-check the order number."; } var statusMessage = order.Status switch { OrderStatus.Processing => "is currently being processed and has not shipped yet", OrderStatus.Shipped => order.ShipDate.HasValue ? $"was shipped on {order.ShipDate.Value:MMMM dd, yyyy} and is on its way" : "has been shipped and is on its way", OrderStatus.Delivered => order.DeliveryDate.HasValue ? $"was delivered on {order.DeliveryDate.Value:MMMM dd, yyyy}" : "has been delivered", OrderStatus.Returned => "has been returned and a refund was issued", _ => "has an unknown status" }; var itemSummary = string.Join(", ", order.Items.Select(i => $"{i.ProductName} (qty: {i.Quantity}, ${i.Price:F2} each)")); return $"Order #{order.Id} {statusMessage}. " + $"Order date: {order.OrderDate:MMMM dd, yyyy}. " + $"Total: ${order.TotalAmount:F2}. " + $"Items: {itemSummary}."; }This is the first agent tool. The AI agent calls this method when a customer asks about a specific order. The method queries the database for the order (including its items), verifies that the order belongs to the authenticated user via
userId, and builds a natural language response. A C#switchexpression translates theOrderStatusenum into human-readable phrases, and the item summary lists each product with its quantity and price. If the order isn’t found, the method returns a friendly error message rather than throwing an exception — this is important because the AI agent will present the return value directly to the customer. -
After the
GetOrderDetailsAsyncmethod, add theGetUserOrdersSummaryAsyncmethod:/// <summary> /// Gets a summary of all orders for a given user. /// The AI agent calls this tool when a user asks about their orders /// without specifying a particular order number. /// </summary> public async Task<string> GetUserOrdersSummaryAsync(int userId) { _logger.LogInformation("Agent tool invoked: GetUserOrdersSummary for userId {UserId}", userId); var orders = await _context.Orders .Where(o => o.UserId == userId) .OrderByDescending(o => o.OrderDate) .ToListAsync(); if (!orders.Any()) { return "You don't have any orders on file."; } var summaries = orders.Select(o => { var status = o.Status switch { OrderStatus.Processing => "Processing", OrderStatus.Shipped => "Shipped", OrderStatus.Delivered => "Delivered", OrderStatus.Returned => "Returned", _ => "Unknown" }; return $"Order #{o.Id} - {status} - ${o.TotalAmount:F2} - Placed {o.OrderDate:MMM dd, yyyy}"; }); return $"You have {orders.Count} orders:\n" + string.Join("\n", summaries); }This tool complements
GetOrderDetailsAsyncby handling cases where the customer asks about their orders without specifying a particular order number (for example, “What are my recent orders?”). It retrieves all orders for the user, sorted by date in descending order, and formats each one as a concise summary line showing the order number, status, total, and date. The AI agent uses this overview to help the customer identify the order they’re interested in. -
After the
GetUserOrdersSummaryAsyncmethod, add theProcessReturnAsyncmethod:/// <summary> /// Processes a return for specific items in a delivered order. /// The AI agent calls this tool when a user wants to return items. /// </summary> public async Task<string> ProcessReturnAsync(int orderId, int userId) { _logger.LogInformation("Agent tool invoked: ProcessReturn for orderId {OrderId}, userId {UserId}", orderId, userId); var order = await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId); if (order == null) { return $"I could not find order #{orderId} associated with your account."; } if (order.Status != OrderStatus.Delivered && order.Status != OrderStatus.Returned) { return order.Status switch { OrderStatus.Processing => $"Order #{orderId} is still being processed and cannot be returned yet. It must be delivered first.", OrderStatus.Shipped => $"Order #{orderId} is currently in transit and cannot be returned until it has been delivered.", _ => $"Order #{orderId} has a status of {order.Status} and cannot be returned." }; } // Build return items list for all unreturned items var returnItems = order.Items .Where(i => i.RemainingQuantity > 0) .Select(i => new ReturnItem { OrderItemId = i.Id, Quantity = i.RemainingQuantity, Reason = "Customer requested return via AI support agent" }) .ToList(); if (!returnItems.Any()) { return $"All items in order #{orderId} have already been returned."; } var success = await _orderService.ProcessItemReturnAsync(orderId, returnItems); if (!success) { return $"I was unable to process the return for order #{orderId}. Please contact our support team for assistance."; } var refundAmount = order.Items .Where(i => i.RemainingQuantity > 0) .Sum(i => i.Price * i.RemainingQuantity); return $"I've processed the return for order #{orderId}. " + $"A refund of ${refundAmount:F2} will be issued to your original payment method within 5-7 business days. " + $"You will receive a confirmation email shortly."; }This is the most complex tool because it performs a state-changing operation. The method includes several validation layers before processing a return: it verifies the order exists and belongs to the user, checks that the order status is either
DeliveredorReturned(partially returned orders can still have unreturned items), and confirms there are items with remaining quantity to return. If validation passes, it builds a list ofReturnItemobjects for all unreturned items and delegates the actual return processing to the existingIOrderService.ProcessItemReturnAsyncmethod, which handles inventory updates and email confirmations. The method calculates and reports the refund amount in the response. Each validation failure returns a specific, helpful message explaining why the return can’t be processed. -
After the
ProcessReturnAsyncmethod, add theSendCustomerEmailAsyncmethod:/// <summary> /// Sends a follow-up email to the customer regarding their order. /// The AI agent calls this tool to send additional information by email. /// </summary> public async Task<string> SendCustomerEmailAsync(int orderId, int userId, string message) { _logger.LogInformation("Agent tool invoked: SendCustomerEmail for orderId {OrderId}", orderId); var order = await _context.Orders .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId); if (order == null) { return $"Could not find order #{orderId} to send an email about."; } // Get the user's email from Identity var user = await _context.Users.FindAsync(userId); var email = user?.Email ?? "customer@contoso.com"; await _emailService.SendEmailAsync(email, $"Regarding your order #{orderId}", message); return $"I've sent an email to {email} with the details about order #{orderId}."; }This tool enables the AI agent to send follow-up emails to customers. The method verifies that the order exists and belongs to the user, retrieves the user’s email address from the Identity system, and sends the email using
IEmailService. Themessageparameter is generated by the AI agent itself, allowing it to compose context-appropriate email content based on the conversation. A fallback email address is provided in case the user’s email cannot be retrieved. -
Your completed SupportAgentTools.cs file should look like the following code:
using ContosoShop.Server.Data; using ContosoShop.Shared.Models; using ContosoShop.Shared.DTOs; using Microsoft.EntityFrameworkCore; namespace ContosoShop.Server.Services; /// <summary> /// Provides tool functions that the AI support agent can invoke /// to look up order information and process returns. /// </summary> public class SupportAgentTools { private readonly ContosoContext _context; private readonly IOrderService _orderService; private readonly IEmailService _emailService; private readonly ILogger<SupportAgentTools> _logger; public SupportAgentTools( ContosoContext context, IOrderService orderService, IEmailService emailService, ILogger<SupportAgentTools> logger) { _context = context; _orderService = orderService; _emailService = emailService; _logger = logger; } // add the `GetOrderDetailsAsync` method here /// <summary> /// Gets the status and details of a specific order by order ID. /// The AI agent calls this tool when a user asks about their order status. /// </summary> public async Task<string> GetOrderDetailsAsync(int orderId, int userId) { _logger.LogInformation("Agent tool invoked: GetOrderDetails for orderId {OrderId}, userId {UserId}", orderId, userId); var order = await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId); if (order == null) { return $"I could not find order #{orderId} associated with your account. Please double-check the order number."; } var statusMessage = order.Status switch { OrderStatus.Processing => "is currently being processed and has not shipped yet", OrderStatus.Shipped => order.ShipDate.HasValue ? $"was shipped on {order.ShipDate.Value:MMMM dd, yyyy} and is on its way" : "has been shipped and is on its way", OrderStatus.Delivered => order.DeliveryDate.HasValue ? $"was delivered on {order.DeliveryDate.Value:MMMM dd, yyyy}" : "has been delivered", OrderStatus.PartialReturn => "has been partially returned (some items have been returned, others are still with you)", OrderStatus.Returned => "has been fully returned and a refund was issued", _ => "has an unknown status" }; var itemSummary = string.Join(", ", order.Items.Select(i => { var itemInfo = $"{i.ProductName} (Id: {i.Id}, qty: {i.Quantity}, ${i.Price:F2} each"; if (i.ReturnedQuantity > 0) { itemInfo += $", {i.ReturnedQuantity} returned, {i.RemainingQuantity} remaining"; } itemInfo += ")"; return itemInfo; })); return $"Order #{order.Id} {statusMessage}. " + $"Order date: {order.OrderDate:MMMM dd, yyyy}. " + $"Total: ${order.TotalAmount:F2}. " + $"Items: {itemSummary}."; } // add the `GetUserOrdersSummaryAsync` method here /// <summary> /// Gets a summary of all orders for a given user. /// The AI agent calls this tool when a user asks about their orders /// without specifying a particular order number. /// </summary> public async Task<string> GetUserOrdersSummaryAsync(int userId) { _logger.LogInformation("Agent tool invoked: GetUserOrdersSummary for userId {UserId}", userId); var orders = await _context.Orders .Where(o => o.UserId == userId) .OrderByDescending(o => o.OrderDate) .ToListAsync(); if (!orders.Any()) { return "You don't have any orders on file."; } var summaries = orders.Select(o => { var status = o.Status switch { OrderStatus.Processing => "Processing", OrderStatus.Shipped => "Shipped", OrderStatus.Delivered => "Delivered", OrderStatus.Returned => "Returned", _ => "Unknown" }; return $"Order #{o.Id} - {status} - ${o.TotalAmount:F2} - Placed {o.OrderDate:MMM dd, yyyy}"; }); return $"You have {orders.Count} orders:\n" + string.Join("\n", summaries); } // add the `ProcessReturnAsync` method here /// <summary> /// Processes a return for specific items in a delivered order. /// The AI agent calls this tool when a user wants to return items. /// Supports returning all items, specific items by ID, or specific quantities. /// </summary> /// <param name="orderId">The order ID to process returns for</param> /// <param name="userId">The authenticated user ID</param> /// <param name="orderItemIds">Optional: Specific order item IDs to return (comma-separated, e.g., "123,456"). If empty, returns all unreturned items.</param> /// <param name="quantities">Optional: Quantities for each item (comma-separated, e.g., "1,2" for items 123 and 456). Must match orderItemIds length. If empty, returns full remaining quantity for each item.</param> /// <param name="reason">Optional: Reason for the return</param> public async Task<string> ProcessReturnAsync( int orderId, int userId, string orderItemIds = "", string quantities = "", string reason = "Customer requested return via AI support agent") { _logger.LogInformation("Agent tool invoked: ProcessReturn for orderId {OrderId}, userId {UserId}, items: {Items}", orderId, userId, string.IsNullOrEmpty(orderItemIds) ? "all" : orderItemIds); var order = await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId); if (order == null) { return $"I could not find order #{orderId} associated with your account."; } if (order.Status != OrderStatus.Delivered && order.Status != OrderStatus.Returned && order.Status != OrderStatus.PartialReturn) { return order.Status switch { OrderStatus.Processing => $"Order #{orderId} is still being processed and cannot be returned yet. It must be delivered first.", OrderStatus.Shipped => $"Order #{orderId} is currently in transit and cannot be returned until it has been delivered.", _ => $"Order #{orderId} has a status of {order.Status} and cannot be returned." }; } List<ReturnItem> returnItems; // Parse specific items if provided if (!string.IsNullOrWhiteSpace(orderItemIds)) { var itemIdStrings = orderItemIds.Split(',', StringSplitOptions.RemoveEmptyEntries); var itemIds = new List<int>(); foreach (var idStr in itemIdStrings) { if (int.TryParse(idStr.Trim(), out int itemId)) { itemIds.Add(itemId); } else { return $"Invalid item ID format: '{idStr}'. Please provide valid item IDs."; } } // Parse quantities if provided var itemQuantities = new List<int>(); if (!string.IsNullOrWhiteSpace(quantities)) { var quantityStrings = quantities.Split(',', StringSplitOptions.RemoveEmptyEntries); foreach (var qtyStr in quantityStrings) { if (int.TryParse(qtyStr.Trim(), out int qty) && qty > 0) { itemQuantities.Add(qty); } else { return $"Invalid quantity format: '{qtyStr}'. Quantities must be positive numbers."; } } if (itemQuantities.Count != itemIds.Count) { return "The number of quantities must match the number of items."; } } // Build return items for specific items returnItems = new List<ReturnItem>(); for (int i = 0; i < itemIds.Count; i++) { var orderItem = order.Items.FirstOrDefault(item => item.Id == itemIds[i]); if (orderItem == null) { return $"Item ID {itemIds[i]} was not found in order #{orderId}."; } if (orderItem.RemainingQuantity <= 0) { return $"{orderItem.ProductName} has already been fully returned."; } var quantityToReturn = itemQuantities.Count > 0 ? itemQuantities[i] : orderItem.RemainingQuantity; if (quantityToReturn > orderItem.RemainingQuantity) { return $"Cannot return {quantityToReturn} of {orderItem.ProductName}. Only {orderItem.RemainingQuantity} available to return."; } returnItems.Add(new ReturnItem { OrderItemId = orderItem.Id, Quantity = quantityToReturn, Reason = reason }); } } else { // Return all unreturned items (original behavior) returnItems = order.Items .Where(i => i.RemainingQuantity > 0) .Select(i => new ReturnItem { OrderItemId = i.Id, Quantity = i.RemainingQuantity, Reason = reason }) .ToList(); } if (!returnItems.Any()) { return $"All items in order #{orderId} have already been returned."; } var success = await _orderService.ProcessItemReturnAsync(orderId, returnItems); if (!success) { _logger.LogError("Failed to process return for orderId {OrderId}, userId {UserId}", orderId, userId); return $"I was unable to process the return for order #{orderId}. Please contact our support team for assistance."; } _logger.LogInformation("Successfully processed return for orderId {OrderId}, userId {UserId}, items: {ItemCount}", orderId, userId, returnItems.Count); // Calculate refund amount for the items being returned var refundAmount = returnItems.Sum(ri => { var item = order.Items.First(i => i.Id == ri.OrderItemId); return item.Price * ri.Quantity; }); // Build response message var itemsSummary = string.Join(", ", returnItems.Select(ri => { var item = order.Items.First(i => i.Id == ri.OrderItemId); return $"{item.ProductName} (qty: {ri.Quantity})"; })); return $"I've successfully processed the return for the following items from order #{orderId}: {itemsSummary}. " + $"A refund of ${refundAmount:F2} will be issued to your original payment method within 5-7 business days. " + $"You will receive a confirmation email shortly. " + $"To view the updated return status, please visit the Order Details page for order #{orderId}."; } // add the `SendCustomerEmailAsync` method here /// <summary> /// Sends a follow-up email to the customer regarding their order. /// The AI agent calls this tool to send additional information by email. /// </summary> public async Task<string> SendCustomerEmailAsync(int orderId, int userId, string message) { _logger.LogInformation("Agent tool invoked: SendCustomerEmail for orderId {OrderId}", orderId); var order = await _context.Orders .FirstOrDefaultAsync(o => o.Id == orderId && o.UserId == userId); if (order == null) { return $"Could not find order #{orderId} to send an email about."; } // Get the user's email from Identity var user = await _context.Users.FindAsync(userId); var email = user?.Email ?? "customer@contoso.com"; await _emailService.SendEmailAsync(email, $"Regarding your order #{orderId}", message); return $"I've sent an email to {email} with the details about order #{orderId}."; } }The completed SupportAgentTools.cs file has the following structure:
- The
usingstatements, namespace, and class declaration at the top - The constructor with four injected dependencies
- Four public methods:
GetOrderDetailsAsync,GetUserOrdersSummaryAsync,ProcessReturnAsync, andSendCustomerEmailAsync
All four methods follow a consistent design pattern: they accept a
userIdparameter for security verification, log the tool invocation, query the database, perform validation, and return human-readable strings that the AI agent presents directly to the customer. - The
-
Open the ContosoShop.Server/Program.cs file.
You’ll use the Program.cs file to register SupportAgentTools in dependency injection.
-
Scroll down to locate the service registration section.
You can search for the following code comment:
// Register order business logic service. -
Create a blank line after the code used to register the
OrderService. -
To register the
SupportAgentToolsservice, add the following code:// Register AI agent tools service builder.Services.AddScoped<SupportAgentTools>(); -
Save your updated files.
-
Build the ContosoShop.Server project and verify that there are no errors.
For example, you can build the project by entering the following command in the terminal:
dotnet buildThe build should succeed. If there are errors, review the
SupportAgentTools.csfile to ensure allusingstatements and references are correct. You can use GitHub Copilot to help debug if needed.
Configure the Copilot SDK agent and expose an API endpoint
In this task, you create a CopilotClient singleton, register it in dependency injection, and create a new API controller that accepts user questions and returns the AI agent’s responses.
Use the following steps to complete this task:
-
Open the ContosoShop.Server/Program.cs file.
You’ll use the Program.cs file to register CopilotClient as a singleton in dependency injection.
-
Add the following
usingstatement at the top of the file, after the existingusingstatements:using GitHub.Copilot.SDK; -
Locate the service registration section.
You can search for the code comment that you added earlier:
// Register AI agent tools service. -
Create a blank line after the code used to register the
SupportAgentToolsservice.This is where you’ll add the code to register the
CopilotClientsingleton. -
Add the following code to create and register a
CopilotClientsingleton:// Register GitHub Copilot SDK client as a singleton builder.Services.AddSingleton<CopilotClient>(sp => { var logger = sp.GetRequiredService<ILogger<CopilotClient>>(); return new CopilotClient(new CopilotClientOptions { AutoStart = true, LogLevel = "info" }); });The
CopilotClientmanages the Copilot CLI process lifecycle. SettingAutoStart = truemeans the CLI server starts automatically when the first session is created. -
Scroll down to locate the following code line:
var app = builder.Build(); -
Create a blank code line above the database initialization block.
-
To initialize and start the GitHub Copilot SDK client, add the following code:
// Ensure CopilotClient is started var copilotClient = app.Services.GetRequiredService<CopilotClient>(); await copilotClient.StartAsync();This code also ensures that the
CopilotClientis properly disposed when the application shuts down. -
Save the file.
-
In Visual Studio Code’s EXPLORER view, right-click the ContosoShop.Shared/Models folder, and then select New File.
-
Name the file SupportQuery.cs.
-
Add the following code:
using System.ComponentModel.DataAnnotations; namespace ContosoShop.Shared.Models; /// <summary> /// Represents a support question submitted by the user to the AI agent. /// </summary> public class SupportQuery { /// <summary> /// The user's question or message for the AI support agent. /// </summary> [Required] [StringLength(1000, MinimumLength = 1)] public string Question { get; set; } = string.Empty; } /// <summary> /// Represents the AI agent's response to a support query. /// </summary> public class SupportResponse { /// <summary> /// The AI agent's answer to the user's question. /// </summary> public string Answer { get; set; } = string.Empty; } -
Take a minute to review the
SupportQueryandSupportResponsemodels.This file defines data transfer models for AI support agent communication:
SupportQuery
- Represents customer questions sent to the AI support agent
- Contains a Question property with validation: required, 1-1000 characters
- Used as the request payload from client to server
SupportResponse
- Represents AI agent responses back to the customer
- Contains an Answer property with the agent’s reply
- Used as the response payload from server to client
These are lightweight DTOs for the support chat interface, enabling structured communication between the Blazor client and the AI-powered support endpoint. The simple design focuses on text-based question-and-answer exchanges with basic input validation.
-
In Visual Studio Code’s EXPLORER view, right-click the ContosoShop.Server/Controllers folder, and then select New File.
-
Name the file SupportAgentController.cs.
-
Add the following code to the SupportAgentController.cs file:
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.AI; using GitHub.Copilot.SDK; using ContosoShop.Server.Services; using ContosoShop.Shared.Models; using System.ComponentModel; using System.Security.Claims; namespace ContosoShop.Server.Controllers; /// <summary> /// API controller that handles AI support agent queries. /// Accepts user questions, creates a Copilot SDK session with custom tools, /// and returns the agent's response. /// </summary> [ApiController] [Route("api/[controller]")] [Authorize] public class SupportAgentController : ControllerBase { private readonly CopilotClient _copilotClient; private readonly SupportAgentTools _agentTools; private readonly ILogger<SupportAgentController> _logger; public SupportAgentController( CopilotClient copilotClient, SupportAgentTools agentTools, ILogger<SupportAgentController> logger) { _copilotClient = copilotClient; _agentTools = agentTools; _logger = logger; } /// <summary> /// Accepts a support question from the user and returns the AI agent's response. /// POST /api/supportagent/ask /// </summary> [HttpPost("ask")] public async Task<IActionResult> AskQuestion([FromBody] SupportQuery query) { if (query == null || string.IsNullOrWhiteSpace(query.Question)) { return BadRequest(new SupportResponse { Answer = "Please enter a question." }); } // Get the authenticated user's ID from claims var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(userIdClaim, out int userId)) { return Unauthorized(new SupportResponse { Answer = "Unable to identify user." }); } _logger.LogInformation("Support agent query from user {UserId}: {Question}", userId, query.Question); } }This code establishes the controller skeleton. Key design decisions in this code:
- The
[Authorize]attribute ensures only authenticated users can reach the endpoint, which is critical since the agent accesses user-specific order data. - The
[ApiController]and[Route("api/[controller]")]attributes configure the endpoint atPOST /api/supportagent/ask. - The constructor injects three dependencies:
CopilotClient(the SDK client for creating AI sessions),SupportAgentTools(the tools service you created earlier), andILoggerfor diagnostics. - The method starts by validating the input and extracting the authenticated user’s ID from the claims. The
userIdis extracted once and then passed to each tool call — this ensures the agent can only access the current user’s data, preventing cross-user data leaks.
- The
-
Inside the
AskQuestionmethod, after the logging statement, add the following code:NOTE: The following code doesn’t include the entire
tryblock — you will add more code in the following steps.try { // Define the tools the AI agent can use var tools = new[] { AIFunctionFactory.Create( async ([Description("The order ID number")] int orderId) => await _agentTools.GetOrderDetailsAsync(orderId, userId), "get_order_details", "Look up the status and details of a specific order by its order number. Returns order status, items, dates, and total amount."), AIFunctionFactory.Create( async () => await _agentTools.GetUserOrdersSummaryAsync(userId), "get_user_orders", "Get a summary list of all orders for the current user. Use this when the user asks about their orders without specifying an order number."), AIFunctionFactory.Create( async ([Description("The order ID number to return")] int orderId) => await _agentTools.ProcessReturnAsync(orderId, userId), "process_return", "Process a return for a delivered order. Returns all unreturned items in the order and initiates a refund. Only works for orders with Delivered status."), AIFunctionFactory.Create( async ( [Description("The order ID number")] int orderId, [Description("The email message content")] string message) => await _agentTools.SendCustomerEmailAsync(orderId, userId, message), "send_customer_email", "Send a follow-up email to the customer with additional information about their order.") };This is where the AI agent’s capabilities are defined. The code uses
AIFunctionFactoryfromMicrosoft.Extensions.AIto wrap eachSupportAgentToolsmethod as a callable AI tool. Each call toAIFunctionFactory.Createwraps aSupportAgentToolsmethod as a tool the AI model can invoke. For each tool, you provide:- A lambda delegate that calls the corresponding method — notice that
userIdis captured from the outer scope so the AI model never needs to know or guess the user’s identity. - A tool name (like
"get_order_details") that the model uses when deciding which tool to call. - A description that helps the model understand when and how to use the tool.
-
[Description]attributes on parameters that tell the model what values to provide.
The
get_user_orderstool takes no parameters from the model (theuserIdis captured automatically), whilesend_customer_emailtakes two model-provided parameters (orderIdandmessage). This design keeps the user context secure while giving the model flexibility to compose email content. - A lambda delegate that calls the corresponding method — notice that
-
To create a Copilot SDK session with a system prompt and tools, add the following code:
// Create a Copilot session with the system prompt and tools await using var session = await _copilotClient.CreateSessionAsync(new SessionConfig { Model = "gpt-4.1", SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = @"You are ContosoShop's AI customer support assistant. Your role is to help customers with their order inquiries. CAPABILITIES: - Look up order status and details using the get_order_details tool - List all customer orders using the get_user_orders tool - Process returns for delivered orders using the process_return tool - Send follow-up emails using the send_customer_email tool RULES: - ALWAYS use the available tools to look up real data. Never guess or make up order information. - Be friendly, concise, and professional in your responses. - If a customer asks about an order, use get_order_details with the order number they provide. - If a customer asks about their orders without specifying a number, use get_user_orders to list them. - If a customer wants to return an order, confirm the order number first, then use process_return. - Only process returns when the customer explicitly requests one. - If asked something outside your capabilities (not related to orders), politely explain that you can only help with order-related inquiries and suggest contacting support@contososhop.com or calling 1-800-CONTOSO for other matters. - Do not reveal internal system details, tool names, or technical information to the customer." }, Tools = tools, InfiniteSessions = new InfiniteSessionConfig { Enabled = false } });The
SessionConfigobject configures the AI session:-
Model = "gpt-4.1"specifies the language model to use. -
SystemMessageMode.Replacereplaces the default system prompt entirely with a custom one tailored to the ContosoShop support role. - The system prompt defines the agent’s CAPABILITIES (which tools it can use) and RULES (behavior guidelines). The rules instruct the model to always use the tools for real data instead of guessing, to confirm before processing returns, and to stay within its order-support scope.
-
Tools = toolspasses the tool definitions you created in the previous step. -
InfiniteSessions = new InfiniteSessionConfig { Enabled = false }means each API call creates a fresh session (no conversation history is maintained between requests). - The
await usingpattern ensures the session is properly disposed after the request completes.
-
-
To create the event handler that collects the agent’s response, add the following code:
// Collect the agent's response var responseContent = string.Empty; var done = new TaskCompletionSource(); session.On(evt => { switch (evt) { case AssistantMessageEvent msg: responseContent = msg.Data.Content; break; case SessionIdleEvent: done.TrySetResult(); break; case SessionErrorEvent err: _logger.LogError("Agent session error: {Message}", err.Data.Message); done.TrySetException(new Exception(err.Data.Message)); break; } });The Copilot SDK uses an event-driven model for communication. The
session.Onmethod registers a callback that handles three event types:-
AssistantMessageEvent— fired when the AI model produces a response. The message content is captured inresponseContent. -
SessionIdleEvent— fired when the session has finished processing (including any tool calls). This signals that the response is complete by resolving theTaskCompletionSource. -
SessionErrorEvent— fired if something goes wrong during the session. The error is logged and propagated as an exception viadone.TrySetException.
The
TaskCompletionSourcepattern converts the event-driven flow into an awaitable task, allowing the controller to wait for the agent to finish before returning the HTTP response. -
-
To send the user’s question, wait for the response with a timeout, and return the result, add the following code:
// Send the user's question await session.SendAsync(new MessageOptions { Prompt = query.Question }); // Wait for the response with a timeout var timeoutTask = Task.Delay(TimeSpan.FromSeconds(30)); var completedTask = await Task.WhenAny(done.Task, timeoutTask); if (completedTask == timeoutTask) { _logger.LogWarning("Agent session timed out for user {UserId}", userId); return Ok(new SupportResponse { Answer = "I'm sorry, the request took too long. Please try again or contact our support team." }); } // Rethrow if the task faulted await done.Task; _logger.LogInformation("Agent response for user {UserId}: {Answer}", userId, responseContent); return Ok(new SupportResponse { Answer = responseContent });This code sends the customer’s question and handles the asynchronous response:
-
session.SendAsyncdispatches the user’s question to the AI model, which may invoke zero or more tools before composing a final response. - A 30-second timeout protects against long-running requests. If the agent takes too long (perhaps due to multiple tool calls or network delays), the user gets a friendly timeout message rather than the request hanging indefinitely.
-
Task.WhenAnyraces the agent’s completion against the timeout. If thedone.Taskcompletes first,await done.Taskis called again to propagate any exception that may have been set bySessionErrorEvent. - The successful response is wrapped in a
SupportResponseDTO and returned as HTTP 200.
-
-
To complete the
try-catchblock, add the following code:} catch (Exception ex) { _logger.LogError(ex, "Error processing support agent query for user {UserId}", userId); return StatusCode(500, new SupportResponse { Answer = "I'm sorry, I encountered an error processing your request. Please try again or contact our support team at support@contososhop.com." }); }The
catchblock provides a safety net for any unhandled exceptions — including errors from the Copilot SDK, tool execution failures, or network issues. Rather than exposing a raw error to the customer, it logs the full exception for debugging and returns a friendly error message with a fallback contact option. This ensures the API always returns a validSupportResponseregardless of what goes wrong internally. -
Your completed SupportAgentController.cs file should look like the following code:
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.AI; using GitHub.Copilot.SDK; using ContosoShop.Server.Services; using ContosoShop.Shared.Models; using System.ComponentModel; using System.Security.Claims; namespace ContosoShop.Server.Controllers; /// <summary> /// API controller that handles AI support agent queries. /// Accepts user questions, creates a Copilot SDK session with custom tools, /// and returns the agent's response. /// </summary> [ApiController] [Route("api/[controller]")] [Authorize] public class SupportAgentController : ControllerBase { private readonly CopilotClient _copilotClient; private readonly SupportAgentTools _agentTools; private readonly ILogger<SupportAgentController> _logger; public SupportAgentController( CopilotClient copilotClient, SupportAgentTools agentTools, ILogger<SupportAgentController> logger) { _copilotClient = copilotClient; _agentTools = agentTools; _logger = logger; } /// <summary> /// Accepts a support question from the user and returns the AI agent's response. /// POST /api/supportagent/ask /// </summary> [HttpPost("ask")] public async Task<IActionResult> AskQuestion([FromBody] SupportQuery query) { if (query == null || string.IsNullOrWhiteSpace(query.Question)) { return BadRequest(new SupportResponse { Answer = "Please enter a question." }); } // Get the authenticated user's ID from claims var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(userIdClaim, out int userId)) { return Unauthorized(new SupportResponse { Answer = "Unable to identify user." }); } _logger.LogInformation("Support agent query from user {UserId}: {Question}", userId, query.Question); try { // Define the tools the AI agent can use var tools = new[] { AIFunctionFactory.Create( async ([Description("The order ID number")] int orderId) => await _agentTools.GetOrderDetailsAsync(orderId, userId), "get_order_details", "Look up the status and details of a specific order by its order number. Returns order status, items, dates, and total amount."), AIFunctionFactory.Create( async () => await _agentTools.GetUserOrdersSummaryAsync(userId), "get_user_orders", "Get a summary list of all orders for the current user. Use this when the user asks about their orders without specifying an order number."), AIFunctionFactory.Create( async ( [Description("The order ID number")] int orderId, [Description("Optional: Specific order item IDs to return (comma-separated, e.g. '123,456'). Leave empty to return all items.")] string orderItemIds = "", [Description("Optional: Quantities for each item (comma-separated, e.g. '1,2'). Must match orderItemIds count. Leave empty to return full quantity.")] string quantities = "", [Description("Optional: Reason for return")] string reason = "Customer requested return via AI support agent") => await _agentTools.ProcessReturnAsync(orderId, userId, orderItemIds, quantities, reason), "process_return", "Process a return for specific items from a delivered order. Can return all items, specific items by ID, or specific quantities of items. Accepts comma-separated item IDs and quantities. Works for orders with Delivered, PartialReturn, or Returned status."), AIFunctionFactory.Create( async ( [Description("The order ID number")] int orderId, [Description("The email message content")] string message) => await _agentTools.SendCustomerEmailAsync(orderId, userId, message), "send_customer_email", "Send a follow-up email to the customer with additional information about their order.") }; // Create a Copilot session with the system prompt and tools await using var session = await _copilotClient.CreateSessionAsync(new SessionConfig { Model = "gpt-4.1", SystemMessage = new SystemMessageConfig { Mode = SystemMessageMode.Replace, Content = @"You are ContosoShop's AI customer support assistant. Your role is to help customers with their order inquiries. CAPABILITIES: - Look up order status and details using the get_order_details tool - List all customer orders using the get_user_orders tool - Process returns for delivered orders using the process_return tool (supports full or partial returns) - Send follow-up emails using the send_customer_email tool RETURN PROCESSING WORKFLOW: 1. When customer wants to return an item, first call get_order_details to see items and their IDs 2. Parse the customer's request carefully: - Extract the product name they mentioned (e.g., 'Headphones', 'Desk Lamp', 'Monitor') - Check if they specified a quantity (e.g., '1 Desk Lamp', '2 monitors', 'one laptop') - Number words: 'one'=1, 'two'=2, 'three'=3, etc. 3. From the order details returned by get_order_details, find the item(s) that match the product name: - Match by ProductName field (case-insensitive, partial match is OK) - AUTOMATICALLY extract the Id field from the matching OrderItem - this is the item ID you need - NEVER ask the customer for an item ID - they don't have this information 4. Determine the return quantity: - If customer specified quantity in their request: use that quantity - Else if remaining quantity is 1: automatically return that 1 item - Else if remaining quantity is more than 1 and no quantity specified: ask how many they want to return 5. Call process_return with the extracted item ID and quantity: - Pass orderItemIds as the Id value from the OrderItem (e.g., '456') - Pass quantities as the number to return (e.g., '1') 6. After successful return, tell customer to view Order Details page to see the updated status IMPORTANT RULES FOR RETURNS: - NEVER ask the customer for an item ID - extract it automatically from get_order_details response - Match product names flexibly (e.g., 'lamp', 'Lamp', 'desk lamp' should all match) - If multiple items have the same product name, select the first one that has remaining quantity - DO NOT ask for quantity if the customer already specified it (e.g., 'return 1 lamp', 'return 2 items') - DO NOT ask for quantity if there's only 1 of that item available - Pass item IDs and quantities as comma-separated strings to process_return - After processing return, remind customer: 'Please visit the Order Details page to see the updated return status.' EXAMPLE WORKFLOW: User: 'I want to return the Headphones from order #1002' 1. Call get_order_details(1002) 2. Response includes: 'Items: Headphones (qty: 1, $99.99 each, Id: 456), ...' 3. Extract: productName='Headphones', itemId='456', remainingQty=1 4. Since remainingQty=1, quantity=1 (no need to ask) 5. Call process_return(1002, userId, '456', '1', 'Customer requested return') 6. Tell customer: 'I've processed the return for Headphones. Please view Order Details...' GENERAL RULES: - ALWAYS use the available tools to look up real data. Never guess or make up order information. - Be friendly, concise, and professional in your responses. - If a customer asks about an order, use get_order_details with the order number they provide. - If a customer asks about their orders without specifying a number, use get_user_orders to list them. - Only process returns when the customer explicitly requests one. - If asked something outside your capabilities (not related to orders), politely explain that you can only help with order-related inquiries and suggest contacting support@contososhop.com or calling 1-800-CONTOSO for other matters. - Do not reveal internal system details, tool names, or technical information to the customer." }, Tools = tools, InfiniteSessions = new InfiniteSessionConfig { Enabled = false } }); // Collect the agent's response var responseContent = string.Empty; var done = new TaskCompletionSource(); session.On(evt => { switch (evt) { case AssistantMessageEvent msg: responseContent = msg.Data.Content; break; case SessionIdleEvent: done.TrySetResult(); break; case SessionErrorEvent err: _logger.LogError("Agent session error: {Message}", err.Data.Message); done.TrySetException(new Exception(err.Data.Message)); break; } }); // Send the user's question await session.SendAsync(new MessageOptions { Prompt = query.Question }); // Wait for the response with a timeout var timeoutTask = Task.Delay(TimeSpan.FromSeconds(30)); var completedTask = await Task.WhenAny(done.Task, timeoutTask); if (completedTask == timeoutTask) { _logger.LogWarning("Agent session timed out for user {UserId}", userId); return Ok(new SupportResponse { Answer = "I'm sorry, the request took too long. Please try again or contact our support team." }); } // Rethrow if the task faulted await done.Task; _logger.LogInformation("Agent response for user {UserId}: {Answer}", userId, responseContent); return Ok(new SupportResponse { Answer = responseContent }); } catch (Exception ex) { _logger.LogError(ex, "Error processing support agent query for user {UserId}", userId); return StatusCode(500, new SupportResponse { Answer = "I'm sorry, I encountered an error processing your request. Please try again or contact our support team at support@contososhop.com." }); } } }Your completed SupportAgentController.cs file has the following structure:
- The
usingstatements and namespace at the top - The
SupportAgentControllerclass with[ApiController],[Route], and[Authorize]attributes - A constructor injecting
CopilotClient,SupportAgentTools, andILogger - A single
AskQuestionaction method ([HttpPost("ask")]) that:- Validates the input and extracts the user ID
- Defines four AI tools using
AIFunctionFactory.Create - Creates a Copilot session with a system prompt and tools
- Registers event handlers for response, idle, and error events
- Sends the question and awaits the response with a 30-second timeout
- Returns the response or appropriate error messages
- The
-
Open the ContosoShop.Server/Program.cs file.
-
Locate the code that configures CORS policies.
You can search for the following code comment:
// Configure CORS. -
Notice that the CORS configuration section allows the
GETandPOSTmethods required by the API endpoint you just created.The existing configuration allows
GETandPOSTmethods, which is sufficient..WithMethods("GET", "POST") // Only required methods -
To build the project, enter the following command in the terminal:
dotnet buildThe build should succeed without errors. If you see errors related to
GitHub.Copilot.SDKtypes, verify that the NuGet package was installed correctly.
Update the Blazor frontend to interact with the agent
In this task, you create a client-side service to call the agent API and update the Support.razor page with an interactive chat interface.
Use the following steps to complete this task:
-
In Visual Studio Code’s EXPLORER view, right-click the ContosoShop.Client/Services folder, and then select New File.
-
Name the file SupportAgentService.cs.
-
Add the following code:
using System.Net.Http.Json; using ContosoShop.Shared.Models; namespace ContosoShop.Client.Services; /// <summary> /// Client-side service for communicating with the AI support agent API. /// </summary> public class SupportAgentService { private readonly HttpClient _http; public SupportAgentService(HttpClient http) { _http = http; } /// <summary> /// Sends a question to the AI support agent and returns the response. /// </summary> /// <param name="question">The user's question</param> /// <returns>The agent's response text</returns> public async Task<string> AskAsync(string question) { var query = new SupportQuery { Question = question }; var response = await _http.PostAsJsonAsync("api/supportagent/ask", query); if (!response.IsSuccessStatusCode) { var errorText = await response.Content.ReadAsStringAsync(); throw new HttpRequestException( $"Support agent returned {response.StatusCode}: {errorText}"); } var result = await response.Content.ReadFromJsonAsync<SupportResponse>(); return result?.Answer ?? "I'm sorry, I didn't receive a response. Please try again."; } } -
Take a minute to review the
SupportAgentServicecode.This is a client-side HTTP service that interfaces with the AI support agent backend. Key features:
Simple API Wrapper:
- Single method AskAsync(string question) - sends user questions to the support agent API endpoint
- Posts to POST /api/supportagent/ask on the server
Communication Handling:
- Wraps the question in a SupportQuery DTO
- Uses HttpClient.PostAsJsonAsync for automatic JSON serialization
- Deserializes the response into a SupportResponse object
Error Management:
- Checks HTTP status codes for failures
- Throws HttpRequestException with detailed error information on non-success responses
- Provides fallback message if response parsing fails
Design Pattern:
- Thin client wrapper following the service layer pattern
- Injected HttpClient for testability and proper lifetime management
- Used by Blazor components (like Support.razor) to interact with the AI agent without handling HTTP details directly
This service abstracts away the HTTP communication complexity, providing a clean interface for Blazor components to ask questions to the AI support agent.
-
Open the ContosoShop.Client/Program.cs file.
-
Locate the service registration section (near the existing
builder.Services.AddScoped...lines). Add the following line:builder.Services.AddScoped<SupportAgentService>(sp => new SupportAgentService(sp.GetRequiredService<HttpClient>()));This code registers the
SupportAgentServiceas a scoped service in Blazor’s dependency injection container, allowing it to be injected into components. TheHttpClientis injected into the service constructor, ensuring proper lifetime management and configuration. Theusing ContosoShop.Client.Services;statement should already be present at the top of the file. -
Save the file.
-
Open the ContosoShop.Client/Pages/Support.razor file.
You’ll replace the existing content of this file to create a new support chat interface that interacts with the AI agent.
-
Select and then delete the existing content of the file.
-
To begin the construction of the new file, add the following code:
NOTE: You’ll build the Support.razor file in stages. Don’t autoformat (Format Document) the file until you’ve added all the code snippets.
@page "/support" @using ContosoShop.Shared.Models @using ContosoShop.Client.Services @attribute [Microsoft.AspNetCore.Authorization.Authorize] @inject SupportAgentService AgentService <PageTitle>Contact Support - ContosoShop Support Portal</PageTitle> <div class="container mt-4"> <div class="row"> <div class="col-lg-8 mx-auto"> <h2 class="mb-4">Contact Support</h2> <!-- AI Chat Card --> <div class="card mb-4 border-info"> <div class="card-header bg-info text-white"> <h5 class="mb-0"> <i class="bi bi-robot me-2"></i>AI Chat Support </h5> </div> <div class="card-body"> <!-- Chat Messages Area --> <div class="border rounded p-3 mb-3" style="min-height: 300px; max-height: 500px; overflow-y: auto;" id="chatMessages"> @if (!conversations.Any()) { <div class="text-center text-muted py-4"> <i class="bi bi-chat-dots display-4 mb-2"></i> <p>Ask me about your orders! For example:</p> <ul class="list-unstyled"> <li><em>"What is the status of order #1001?"</em></li> <li><em>"Show me all my orders"</em></li> <li><em>"I want to return order #1005"</em></li> </ul> </div> } @foreach (var entry in conversations) { <div class="mb-3"> <div class="d-flex align-items-start mb-1"> <span class="badge bg-primary me-2">You</span> <span>@entry.Question</span> </div> @if (!string.IsNullOrEmpty(entry.Answer)) { <div class="d-flex align-items-start ms-2"> <span class="badge bg-info me-2">Agent</span> <span style="white-space: pre-line;">@entry.Answer</span> </div> } </div> } @if (isLoading) { <div class="d-flex align-items-start ms-2"> <span class="badge bg-info me-2">Agent</span> <span class="text-muted"><em>Thinking...</em></span> </div> } </div>This first section establishes the page structure:
- The
@page "/support"directive maps this component to the/supportURL route. - The
@attribute [Authorize]ensures only authenticated users can access the page. - The
@inject SupportAgentService AgentServiceinjects the client-side service you created in the previous step, giving the page access to the AI agent API. - The chat messages area is a scrollable
div(300-500px height) that displays the conversation history. When there are no messages yet, it shows helpful example prompts to guide the user. Each conversation entry shows the user’s question with a “You” badge and the agent’s response with an “Agent” badge. Thewhite-space: pre-linestyle preserves line breaks in the agent’s responses (for example, when listing multiple orders). A “Thinking…” indicator appears while the agent is processing a request.
- The
-
After the chat messages area
</div>, add the input area and close the AI Chat card. This section provides the text input, send button, and error display:<!-- Input Area --> <div class="input-group"> <input type="text" class="form-control" placeholder="Type your question..." @bind="currentQuestion" @bind:event="oninput" @onkeydown="HandleKeyDown" disabled="@isLoading" /> <button class="btn btn-info text-white" @onclick="SubmitQuestion" disabled="@(isLoading || string.IsNullOrWhiteSpace(currentQuestion))"> <i class="bi bi-send me-1"></i>Send </button> </div> @if (!string.IsNullOrEmpty(errorMessage)) { <div class="alert alert-danger mt-2 mb-0"> <i class="bi bi-exclamation-triangle me-1"></i>@errorMessage </div> } </div> </div>The input area uses Bootstrap’s
input-groupfor a clean text field with attached send button. Key interaction details:-
@bind="currentQuestion"with@bind:event="oninput"provides real-time two-way binding — thecurrentQuestionvariable updates as the user types (not just on blur). -
@onkeydown="HandleKeyDown"enables the Enter key shortcut for submitting questions. - Both the input and button are disabled while
isLoadingis true, preventing duplicate submissions during agent processing. - The button is also disabled when the input is empty (
string.IsNullOrWhiteSpace(currentQuestion)). - An error alert conditionally appears below the input when
errorMessageis set, providing user-friendly feedback if something goes wrong.
-
-
After the AI Chat card, add the Contact Information card:
<!-- Contact Information Card --> <div class="card mb-4"> <div class="card-header bg-primary text-white"> <h5 class="mb-0"> <i class="bi bi-headset me-2"></i>Get in Touch </h5> </div> <div class="card-body"> <div class="row"> <div class="col-md-6 mb-3"> <h6 class="text-muted">Email Support</h6> <p class="mb-0"> <i class="bi bi-envelope me-2"></i> <a href="mailto:support@contososhop.com">support@contososhop.com</a> </p> <small class="text-muted">Response time: 24-48 hours</small> </div> <div class="col-md-6 mb-3"> <h6 class="text-muted">Phone Support</h6> <p class="mb-0"> <i class="bi bi-telephone me-2"></i> <a href="tel:1-800-266-8676">1-800-CONTOSO</a> </p> <small class="text-muted">Mon-Fri 9AM-5PM EST</small> </div> </div> </div> </div>This card provides traditional contact methods as a fallback when the AI agent can’t fully resolve a customer’s issue. The two-column layout (using Bootstrap’s grid) shows email and phone support side by side on medium+ screens, each with response time expectations. This is consistent with the system prompt you configured earlier, which tells the AI agent to direct customers to
support@contososhop.comor1-800-CONTOSOfor non-order matters. -
After the Contact Information card, add the Quick Links card and the closing
</div>tags for the page layout:<!-- Quick Links --> <div class="card"> <div class="card-header"> <h5 class="mb-0"> <i class="bi bi-question-circle me-2"></i>Need Help With Your Order? </h5> </div> <div class="card-body"> <ul class="list-unstyled mb-0"> <li class="mb-2"> <a href="/orders" class="text-decoration-none"> <i class="bi bi-box-seam me-2"></i>View Your Orders </a> </li> <li class="mb-2"> <i class="bi bi-arrow-return-left me-2"></i> <span>Return a delivered order from the Order Details page</span> </li> <li class="mb-0"> <i class="bi bi-info-circle me-2"></i> <span>Track shipment status and delivery updates</span> </li> </ul> </div> </div> </div> </div> </div>The Quick Links card provides navigation shortcuts to other parts of the application. The “View Your Orders” link navigates to the
/orderspage where customers can see their full order list. The remaining items describe self-service actions available elsewhere in the app. The three closing</div>tags close thecol-lg-8,row, andcontainerelements that wrap the entire page layout. -
After all the HTML, add the
@codeblock that contains the component’s state management and event handling logic:@code { private class ConversationEntry { public string Question { get; set; } = string.Empty; public string Answer { get; set; } = string.Empty; } private List<ConversationEntry> conversations = new(); private string currentQuestion = string.Empty; private bool isLoading = false; private string errorMessage = string.Empty; private async Task HandleKeyDown(KeyboardEventArgs e) { if (e.Key == "Enter" && !isLoading && !string.IsNullOrWhiteSpace(currentQuestion)) { await SubmitQuestion(); } } private async Task SubmitQuestion() { if (string.IsNullOrWhiteSpace(currentQuestion) || isLoading) return; errorMessage = string.Empty; var question = currentQuestion.Trim(); currentQuestion = string.Empty; var entry = new ConversationEntry { Question = question }; conversations.Add(entry); try { isLoading = true; StateHasChanged(); var answer = await AgentService.AskAsync(question); entry.Answer = answer; } catch (Exception ex) { errorMessage = "Sorry, something went wrong. Please try again or contact our support team."; Console.Error.WriteLine($"Agent error: {ex.Message}"); } finally { isLoading = false; StateHasChanged(); } } }The
@codeblock contains all of the component’s logic:-
ConversationEntryis a simple inner class that pairs each user question with the agent’s answer, forming the chat history. - The component state consists of four fields:
conversations(the full chat history),currentQuestion(the text input binding),isLoading(prevents duplicate submissions and shows the “Thinking…” indicator), anderrorMessage(displays errors below the input). -
HandleKeyDownenables submitting questions by pressing Enter — it checks the same guards as the send button (not loading, not empty). -
SubmitQuestionorchestrates the full send flow: it clears the error state, captures and clears the input text, adds a new conversation entry immediately (so the user’s question appears right away), then callsAgentService.AskAsyncto get the agent’s response. TheStateHasChanged()calls force Blazor to re-render the UI — once when “Thinking…” appears and again when the response arrives or an error occurs. Thetry/finallypattern ensuresisLoadingis always reset, even if the API call fails.
-
-
Verify that your completed Support.razor file has the following structure:
- Page directives (
@page,@using,@attribute,@inject) at the top - A container layout with a centered column
- Three cards: AI Chat Support (with messages area, input area, and error display), Contact Information (email and phone), and Quick Links (navigation shortcuts)
- An
@codeblock withConversationEntryclass, state fields,HandleKeyDown, andSubmitQuestionmethods
- Page directives (
-
Open the ContosoShop.Server directory in the terminal, and then enter the following command:
dotnet buildThe build should succeed without errors.
Test the end-to-end AI agent experience
In this task, you run the application and test the AI agent with various support queries to verify it functions correctly.
Use the following steps to complete this task:
-
To start the server application from the terminal, enter the following command:
dotnet runWatch the console output for any errors during startup. You should see the application listening on the HTTPS and HTTP ports.
If you see errors, verify that you completed each step in the previous tasks and that you entered the code correctly.
-
Open a browser and navigate to
http://localhost:5266. -
Sign in with the demo credentials.
Enter
mateo@contoso.comfor the email andPassword123!for the password, and then select Login. -
Navigate to the Contact Support page.
-
Take a moment to review the page.
You should now see the interactive AI Chat Support interface instead of the “Coming Soon” placeholder. The chat area displays example prompts to help you get started.
-
To test the agent’s ability to Check order status, enter the following question and select Send (or press Enter):
What's the status of order #1001?The agent should respond with details about order #1001, including its status, order date, items, and total amount. The response should reflect the actual data in the database.
Verify the response matches what you see on the Orders page for order #1001.
-
To test the agent’s ability to List all orders, enter the following question:
Show me all my ordersThe agent should use the
get_user_orderstool and return a summary list of all 10 of Mateo’s orders with their statuses and amounts. -
To test the agent’s ability to Process a return, enter the following question:
I want to return order #1008The agent should process the return for order #1008 (which was Delivered) and confirm the refund amount.
After the AI response is displayed:
- Navigate to the Orders page and verify that order #1008 now shows a “Returned” status.
- Check the server console output for the email notification log from
EmailServiceDev.
-
To test the agent’s ability to Process a return for a single item within an order, enter the following question:
I want to return 1 Monitor from order #1001The agent should process the return for the specified item within order #1001 and confirm the refund amount.
After the AI response is displayed:
- Navigate to the Orders page and verify that order #1001 now shows a “Partially Returned” status.
- Open the order details for #1001 and verify that the returned item shows a “Returned” status while the other items still show “Delivered”.
- Check the server console output for the email notification log from
EmailServiceDev.
-
To test the agent’s ability to Handle an order that can’t be returned, enter the following question:
Can I return order #1010?Order #1010 has “Processing” status and cannot be returned. The agent should explain that the order must be delivered before it can be returned.
-
To test the agent’s ability to Handle a non-existent order, enter the following question:
Where is my order #9999?The agent should respond that it could not find order #9999 associated with the user’s account.
-
To test the agent’s ability to Handle an off-topic question, enter the following question:
What's the weather like today?The agent should politely explain that it can only help with order-related inquiries and suggest contacting support through other channels.
-
When you are done testing, return to the terminal and press Ctrl+C to stop the application.
Troubleshooting tips:
- If the agent’s responses seem odd or it doesn’t use the tools, check the server console for error messages. Common issues include:
-
CopilotClient connection failures: Ensure the Copilot CLI is installed and you are signed in. Run
copilot --versionto verify. -
Authentication errors: Ensure you are logged in to the Copilot CLI. Run
copilot auth statusto check. - Timeout errors: The agent has a 30-second timeout. If responses are slow, check your network connection.
-
Tool invocation errors: Check the server logs for messages from
SupportAgentTools. The log output shows which tools are being called and what data they return.
-
CopilotClient connection failures: Ensure the Copilot CLI is installed and you are signed in. Run
Clean up
Now that you’ve finished the exercise, take a minute to clean up your environment:
- Stop the server application if it’s still running (press Ctrl+C in the terminal).
- Ensure that you haven’t made changes to your GitHub account or GitHub Copilot subscription that you don’t want to keep.
- Optionally archive or delete the local clone of the repository.
Summary
In this exercise, you successfully integrated an AI-powered customer support agent into the ContosoShop E-commerce Support Portal using the GitHub Copilot SDK. You:
-
Created backend tools (
SupportAgentTools) that the AI agent can invoke to look up orders and process returns, leveraging the existing application services. -
Configured the Copilot SDK with a
CopilotClientsingleton and created sessions with a custom system prompt and tool definitions usingAIFunctionFactory.Create. -
Built an API endpoint (
SupportAgentController) that accepts user questions, creates agent sessions, and returns AI-generated responses. - Updated the Blazor frontend with an interactive chat interface on the Support page.
- Tested the integration with real-world scenarios including order lookups, returns, error handling, and off-topic deflection.
This pattern — defining business logic as tools, registering them with an AI agent runtime, and exposing the agent via an API — is applicable to many domains beyond e-commerce support. You can apply the same approach to IT helpdesk automation, CRM assistants, or any scenario where an AI agent needs to take actions on behalf of users.