diff --git a/HandoverToLiveAgent/.img/app_registration_setup.png b/HandoverToLiveAgent/.img/app_registration_setup.png
new file mode 100644
index 00000000..a944a94b
Binary files /dev/null and b/HandoverToLiveAgent/.img/app_registration_setup.png differ
diff --git a/HandoverToLiveAgent/.img/solution_import.png b/HandoverToLiveAgent/.img/solution_import.png
new file mode 100644
index 00000000..d48bdc3a
Binary files /dev/null and b/HandoverToLiveAgent/.img/solution_import.png differ
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/ContosoLiveChatApp.csproj b/HandoverToLiveAgent/ContosoLiveChatApp/ContosoLiveChatApp.csproj
new file mode 100644
index 00000000..6568b3dc
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/ContosoLiveChatApp.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/Controllers/ChatController.cs b/HandoverToLiveAgent/ContosoLiveChatApp/Controllers/ChatController.cs
new file mode 100644
index 00000000..8f04d81d
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/Controllers/ChatController.cs
@@ -0,0 +1,126 @@
+using System.Runtime.ExceptionServices;
+using System.Security;
+using Microsoft.AspNetCore.Mvc;
+
+[ApiController]
+[Route("api/[controller]")]
+public class ChatController : ControllerBase
+{
+ private readonly ChatStorageService _chatStorage;
+ private readonly WebhookService _webhookService;
+ private readonly ILogger _logger;
+
+ public ChatController(ChatStorageService chatStorage, WebhookService webhookService, ILogger logger)
+ {
+ _chatStorage = chatStorage;
+ _webhookService = webhookService;
+ _logger = logger;
+ }
+
+ // GET: api/chat/messages - Get all chat messages
+ [HttpGet("messages")]
+ public ActionResult> GetMessages(string? conversationId = null)
+ {
+ var messages = _chatStorage.GetAllMessages(conversationId);
+ return Ok(messages);
+ }
+
+ // POST: api/chat/start - Start a new conversation and return conversation ID
+ [HttpPost("start")]
+ public ActionResult StartConversation()
+ {
+ try
+ {
+ var conversationId = Guid.NewGuid().ToString()[..5];
+ _logger.LogInformation("Started new conversation with ID: {ConversationId}", conversationId);
+
+ _chatStorage.StartConversation(conversationId);
+
+ return Ok(new { conversationId });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting conversation");
+ return StatusCode(500, new { error = ex.Message });
+ }
+ }
+
+ // POST: api/chat/end - End a conversation
+ [HttpPost("end")]
+ public ActionResult EndConversation([FromBody] MessageRequest request)
+ {
+ try
+ {
+ _logger.LogInformation("Ending conversation with ID: {ConversationId}", request.ConversationId);
+ _chatStorage.EndConversation(request.ConversationId);
+ return Ok(new { message = "Conversation ended successfully" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error ending conversation");
+ return StatusCode(500, new { error = ex.Message });
+ }
+ }
+
+ // POST: api/chat/send - Send a message (from Live Chat to Copilot Studio webhook)
+ [HttpPost("send")]
+ public async Task SendMessage([FromBody] MessageRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.Message))
+ {
+ return BadRequest(new { error = "Message text cannot be empty" });
+ }
+
+ var message = new ChatMessage
+ {
+ ConversationId = request.ConversationId,
+ Message = request.Message,
+ Sender = "Contoso Support",
+ Timestamp = DateTime.UtcNow
+ };
+
+ // Send to webhook first
+ var (statusCode, errorMessage) = await _webhookService.SendMessageAsync(message);
+
+ if (statusCode.HasValue && statusCode >= 200 && statusCode < 300)
+ {
+ // Only add to chat history if webhook send was successful
+ _chatStorage.AddMessage(message.ConversationId, message);
+ return Ok(new { message = "Message sent successfully", messageId = message.Id, timestamp = message.Timestamp, sender = message.Sender, conversationId = message.ConversationId });
+ }
+ else
+ {
+ return StatusCode(statusCode ?? 500, new { error = errorMessage });
+ }
+ }
+
+ // POST: api/chat/receive - Receive a message (from Copilot Studio )
+ [HttpPost("receive")]
+ public ActionResult ReceiveMessage([FromBody] MessageRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.Message))
+ {
+ return BadRequest(new { error = "Message text cannot be empty" });
+ }
+
+ var message = new ChatMessage
+ {
+ ConversationId = request.ConversationId,
+ Message = request.Message,
+ Sender = request.Sender ?? "Remote",
+ Timestamp = DateTime.UtcNow
+ };
+
+ _chatStorage.AddMessage(message.ConversationId, message);
+ _logger.LogInformation("Received message from {Sender}: {Text}", message.Sender, message.Message);
+
+ return Ok(new { message = "Message received successfully", messageId = message.Id });
+ }
+}
+
+public class MessageRequest
+{
+ public string ConversationId { get; set; } = string.Empty;
+ public string Message { get; set; } = string.Empty;
+ public string? Sender { get; set; }
+}
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/Models/ChatMessage.cs b/HandoverToLiveAgent/ContosoLiveChatApp/Models/ChatMessage.cs
new file mode 100644
index 00000000..420ee507
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/Models/ChatMessage.cs
@@ -0,0 +1,8 @@
+public class ChatMessage
+{
+ public string ConversationId { get; set; } = string.Empty;
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Message { get; set; } = string.Empty;
+ public string Sender { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+}
\ No newline at end of file
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/Program.cs b/HandoverToLiveAgent/ContosoLiveChatApp/Program.cs
new file mode 100644
index 00000000..a9a40256
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/Program.cs
@@ -0,0 +1,23 @@
+var builder = WebApplication.CreateBuilder(args);
+
+builder.WebHost.ConfigureKestrel(serverOptions =>
+{
+ serverOptions.ListenAnyIP(5000);
+});
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSingleton();
+builder.Services.AddHttpClient();
+
+var app = builder.Build();
+
+var webhookUrl = app.Configuration["WebhookSettings:OutgoingWebhookUrl"];
+app.Logger.LogInformation("Webhook URL configured: {WebhookUrl}", webhookUrl);
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+app.UseRouting();
+app.MapControllers();
+
+app.Run();
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/README.md b/HandoverToLiveAgent/ContosoLiveChatApp/README.md
new file mode 100644
index 00000000..82bf962f
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/README.md
@@ -0,0 +1,92 @@
+# Contoso Live Chat App
+
+A simple live chat application designed as mock live agent handover scenarios with Copilot Studio. This application simulates a live chat support system that can receive conversations from Copilot Studio and send messages back.
+
+## Project Structure
+
+```
+ContosoLiveChatApp/
+├── Controllers/
+│ └── ChatController.cs # API endpoints for chat operations (used by Copilot Studio agent)
+├── Models/
+│ └── ChatMessage.cs # Chat message data model
+├── Services/
+│ ├── ChatStorageService.cs # Conversation-based message storage
+│ └── WebhookService.cs # Outgoing webhook sender (send messages to Copilot Studio agent)
+├── wwwroot/
+│ └── index.html # Chat UI with conversation management
+├── Program.cs # Application entry point
+├── appsettings.json # Configuration (webhook URL)
+```
+
+## Configuration
+
+Configure the webhook URL in `appsettings.json`:
+
+```json
+{
+ "WebhookSettings": {
+ "OutgoingWebhookUrl": "http://localhost:5001/api/livechat/messages"
+ }
+}
+```
+This endpoint points to the Copilot Studio agent skill URL. The default configuration assumes the HandoverToLiveAgentSample is running on port 5001.
+
+## Running the Application
+
+1. Navigate to the project directory:
+```powershell
+cd CopilotStudioSamples\HandoverToLiveAgent\ContosoLiveChatApp
+```
+
+2. Restore dependencies and run:
+```powershell
+dotnet run
+```
+
+3. Open your browser and navigate to:
+```
+http://localhost:5000
+```
+
+## API Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `POST` | `/api/chat/start` | Start a new conversation, returns `conversationId` |
+| `GET` | `/api/chat/messages?conversationId={id}` | Get messages for a conversation |
+| `POST` | `/api/chat/send` | Send message to Copilot Studio via webhook |
+| `POST` | `/api/chat/receive` | Receive message from Copilot Studio |
+| `POST` | `/api/chat/end` | End conversation and clear from memory |
+
+## Architecture
+
+### API Flow
+
+```mermaid
+sequenceDiagram
+ participant CS as Copilot Studio
+ participant API as Chat API
+ participant Storage as ChatStorageService
+ participant UI as Live Chat UI
+
+ CS->>API: POST /api/chat/start
+ API-->>CS: conversationId
+
+ Note over API,Storage: Conversation Active
+
+ CS->>API: POST /api/chat/receive (from MCS)
+ API->>Storage: Store message
+ Storage-->>UI: Display message
+
+ UI->>API: POST /api/chat/send (message)
+ API->>Storage: Store message
+ API->>CS: Forward via webhook (to MCS)
+ CS-->>UI: Message delivered
+
+ Note over CS,UI: Messages exchanged...
+
+ CS->>API: POST /api/chat/end
+ API->>Storage: Clear conversation
+ API-->>CS: Conversation ended
+```
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/Services/ChatStorageService.cs b/HandoverToLiveAgent/ContosoLiveChatApp/Services/ChatStorageService.cs
new file mode 100644
index 00000000..789c8ad2
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/Services/ChatStorageService.cs
@@ -0,0 +1,58 @@
+using System.Collections.Concurrent;
+
+public class ChatStorageService
+{
+ private readonly ConcurrentDictionary> _activeConversations = new();
+ private readonly ILogger _logger;
+
+ public ChatStorageService(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public void AddMessage(string conversationId, ChatMessage message)
+ {
+ _logger.LogInformation("Adding message to conversation ID: {ConversationId}", conversationId);
+ if (_activeConversations.TryGetValue(conversationId, out var messages))
+ {
+ messages.Add(message);
+ }
+ else
+ {
+ _logger.LogWarning("No active conversation found with ID: {ConversationId}", conversationId);
+ }
+ }
+
+ public void StartConversation(string conversationId)
+ {
+ _activeConversations[conversationId] = new List();
+ _logger.LogInformation("Started conversation with ID: {ConversationId}", conversationId);
+ }
+
+ public void EndConversation(string conversationId)
+ {
+ _activeConversations.TryRemove(conversationId, out _);
+ _logger.LogInformation("Ended conversation with ID: {ConversationId}", conversationId);
+ }
+
+ public IEnumerable GetAllMessages(string? conversationId)
+ {
+ if (string.IsNullOrEmpty(conversationId))
+ {
+ return _activeConversations.Values
+ .SelectMany(messages => messages)
+ .OrderBy(m => m.Timestamp);
+ }
+
+ _logger.LogInformation("Retrieving messages for conversation ID: {ConversationId}", conversationId);
+ if (_activeConversations.TryGetValue(conversationId, out var messages))
+ {
+ return messages.OrderBy(m => m.Timestamp);
+ }
+ else
+ {
+ _logger.LogWarning("No active conversation found with ID: {ConversationId}", conversationId);
+ return Enumerable.Empty();
+ }
+ }
+}
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/Services/WebhookService.cs b/HandoverToLiveAgent/ContosoLiveChatApp/Services/WebhookService.cs
new file mode 100644
index 00000000..bc9af236
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/Services/WebhookService.cs
@@ -0,0 +1,52 @@
+using System.Text;
+using System.Text.Json;
+
+public class WebhookService
+{
+ private readonly HttpClient _httpClient;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+
+ public WebhookService(HttpClient httpClient, IConfiguration configuration, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ public async Task> SendMessageAsync(ChatMessage message)
+ {
+ try
+ {
+ var webhookUrl = _configuration["WebhookSettings:OutgoingWebhookUrl"];
+
+ if (string.IsNullOrEmpty(webhookUrl))
+ {
+ _logger.LogWarning("Webhook URL is not configured");
+ return new Tuple(null, "Webhook URL is not configured");
+ }
+
+ var json = JsonSerializer.Serialize(message);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync(webhookUrl, content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ _logger.LogInformation("Message sent successfully to webhook: {MessageId}", message.Id);
+ return new Tuple((int)response.StatusCode, string.Empty);
+ }
+ else
+ {
+ _logger.LogWarning("Failed to send message to webhook. Status: {StatusCode}", response.StatusCode);
+ var errorMessage = await response.Content.ReadAsStringAsync();
+ return new Tuple((int)response.StatusCode, errorMessage);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error sending message to webhook");
+ return new Tuple(null, ex.Message);
+ }
+ }
+}
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/appsettings.json b/HandoverToLiveAgent/ContosoLiveChatApp/appsettings.json
new file mode 100644
index 00000000..22c4e051
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/appsettings.json
@@ -0,0 +1,12 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "WebhookSettings": {
+ "OutgoingWebhookUrl": "http://localhost:5001/api/livechat/messages"
+ }
+}
diff --git a/HandoverToLiveAgent/ContosoLiveChatApp/wwwroot/index.html b/HandoverToLiveAgent/ContosoLiveChatApp/wwwroot/index.html
new file mode 100644
index 00000000..90762e44
--- /dev/null
+++ b/HandoverToLiveAgent/ContosoLiveChatApp/wwwroot/index.html
@@ -0,0 +1,454 @@
+
+
+
+
+
+ Contoso Live Chat
+
+
+
+
+
+
+
+
diff --git a/HandoverToLiveAgent/HandoverAgentSample.zip b/HandoverToLiveAgent/HandoverAgentSample.zip
new file mode 100644
index 00000000..b3711413
Binary files /dev/null and b/HandoverToLiveAgent/HandoverAgentSample.zip differ
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/ConversationManager.cs b/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/ConversationManager.cs
new file mode 100644
index 00000000..911952a5
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/ConversationManager.cs
@@ -0,0 +1,137 @@
+
+using System.Data;
+using Microsoft.Agents.Core.Models;
+
+namespace HandoverToLiveAgent.CopilotStudio;
+
+public interface IConversationManager
+{
+ Task GetMapping(string id);
+ Task UpsertMappingByCopilotConversationId(IActivity activity, string liveChatConversationId);
+ Task RemoveMappingByCopilotConversationId(string id);
+}
+
+public class ConversationManager : IConversationManager
+{
+ private readonly ILogger _logger;
+ private readonly IConfiguration _config;
+ private static readonly Dictionary _mappingsByCopilotId = new();
+ private static readonly Dictionary _mappingsByLiveChatId = new();
+ public ConversationManager(IConfiguration config, ILogger logger)
+ {
+ _config = config;
+ _logger = logger;
+ }
+
+ public async Task GetMapping(string id)
+ {
+ _logger.LogInformation("Retrieving mapping for CopilotConversationId={CopilotConversationId}", id);
+ if (_mappingsByCopilotId.TryGetValue(id, out var mapping))
+ {
+ return await Task.FromResult(mapping);
+ }
+ if (_mappingsByLiveChatId.TryGetValue(id, out mapping))
+ {
+ return await Task.FromResult(mapping);
+ }
+ return await Task.FromResult(null);
+ }
+
+ public Task RemoveMappingByCopilotConversationId(string id)
+ {
+ _logger.LogInformation("Removing mapping for CopilotConversationId={CopilotConversationId}", id);
+ _mappingsByCopilotId.Remove(id);
+ _mappingsByLiveChatId.Remove(id);
+ return Task.CompletedTask;
+ }
+
+ public async Task UpsertMappingByCopilotConversationId(IActivity activity, string liveChatConversationId)
+ {
+ _logger.LogInformation("Storing mapping: CopilotConversationId={CopilotConversationId}, LiveChatConversationId={LiveChatConversationId}", activity.Conversation?.Id, liveChatConversationId);
+
+ var mapping = await UpsertProactiveConversation(activity.Conversation!.Id, activity);
+ mapping!.LiveChatConversationId = liveChatConversationId;
+ _mappingsByCopilotId[activity.Conversation!.Id] = mapping;
+ _mappingsByLiveChatId[liveChatConversationId] = mapping;
+
+ return mapping;
+ }
+
+ private async Task UpsertProactiveConversation(string copilotConversationId, IActivity activity)
+ {
+ var mapping = new ConversationMapping
+ {
+ CopilotConversationId = copilotConversationId
+ };
+
+ var userId = activity.From?.Id ?? "unknown-user";
+ var serviceUrl = !string.IsNullOrWhiteSpace(activity.ServiceUrl)
+ ? activity.ServiceUrl
+ : activity.RelatesTo?.ServiceUrl;
+
+ var region = ResolveSmbaRegion(serviceUrl);
+ var tenantId = ResolveTenantId();
+
+ if (string.IsNullOrWhiteSpace(mapping.UserId) && !string.IsNullOrWhiteSpace(userId))
+ {
+ mapping.UserId = userId;
+ }
+ if (string.IsNullOrWhiteSpace(mapping.ChannelId) && !string.IsNullOrWhiteSpace(activity.ChannelId))
+ {
+ mapping.ChannelId = activity.ChannelId;
+ }
+ if (string.IsNullOrWhiteSpace(mapping.BotId) && !string.IsNullOrWhiteSpace(activity.Recipient?.Id))
+ {
+ mapping.BotId = activity.Recipient!.Id;
+ }
+ if (string.IsNullOrWhiteSpace(mapping.BotName) && !string.IsNullOrWhiteSpace(activity.Recipient?.Name))
+ {
+ mapping.BotName = activity.Recipient!.Name;
+ }
+ if (string.IsNullOrWhiteSpace(mapping.ServiceUrl))
+ {
+ var su = serviceUrl;
+ // If Teams channel is reporting a PVA runtime URL, prefer SMBA for proactive continuation
+ if (!string.IsNullOrWhiteSpace(su)
+ && string.Equals(activity.ChannelId, "msteams", StringComparison.OrdinalIgnoreCase)
+ && su.Contains("pvaruntime", StringComparison.OrdinalIgnoreCase)
+ && !su.Contains("smba.trafficmanager.net", StringComparison.OrdinalIgnoreCase))
+ {
+ var smba = !string.IsNullOrWhiteSpace(tenantId)
+ ? $"https://smba.trafficmanager.net/{region}/{tenantId}/"
+ : "https://smba.trafficmanager.net/teams/";
+ _logger.LogInformation("[Proactive][RefCapture] Overriding PVA ServiceUrl to SMBA for Teams channel. From={From} To={To} ConvId={ConversationId}", su, smba, mapping.CopilotConversationId);
+ su = smba;
+ }
+ if (!string.IsNullOrWhiteSpace(su)) mapping.ServiceUrl = su;
+ }
+ return await Task.FromResult(mapping);
+ }
+
+ private string ResolveSmbaRegion(string? url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return "amer";
+ var u = url.ToLowerInvariant();
+ if (u.Contains("-us") || u.Contains(".us-")) return "amer";
+ if (u.Contains("-eu") || u.Contains(".eu-") || u.Contains(".uk")) return "emea";
+ if (u.Contains("-ap") || u.Contains(".ap-") || u.Contains("asia") || u.Contains("-jp")) return "apac";
+ return "amer";
+ }
+
+ private string? ResolveTenantId()
+ {
+ var tid = _config["Connections:default:Settings:TenantId"];
+ return string.IsNullOrWhiteSpace(tid) ? null : tid;
+ }
+}
+
+public class ConversationMapping
+{
+ public string CopilotConversationId { get; set; } = string.Empty;
+ public string LiveChatConversationId { get; set; } = string.Empty;
+ public string UserId { get; set; } = string.Empty;
+ public string? ChannelId { get; set; }
+ public string? ServiceUrl { get; set; }
+ public string? BotId { get; set; }
+ public string? BotName { get; set; }
+}
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/CopilotStudioAgent.cs b/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/CopilotStudioAgent.cs
new file mode 100644
index 00000000..7b7787de
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/CopilotStudioAgent.cs
@@ -0,0 +1,121 @@
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Builder.App;
+using Microsoft.Agents.Builder.State;
+using Microsoft.Agents.Core.Models;
+using HandoverToLiveAgent.LiveChat;
+
+namespace HandoverToLiveAgent.CopilotStudio;
+
+// NOTE: Avoid injecting scoped services directly because the Agents SDK registers the agent as a singleton.
+// We instead create a scope per turn to resolve required scoped dependencies.
+public class CopilotStudioAgent : AgentApplication
+{
+ private readonly ILogger _logger;
+ private readonly IServiceScopeFactory _scopeFactory;
+
+ public CopilotStudioAgent(AgentApplicationOptions options, ILogger logger, IServiceScopeFactory scopeFactory) : base(options)
+ {
+ _logger = logger;
+ _scopeFactory = scopeFactory;
+
+
+ OnActivity(ActivityTypes.Message, OnMessageAsync);
+ OnActivity(ActivityTypes.Event, OnEventAsync);
+ }
+
+ private async Task OnEventAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
+ {
+ _logger.LogInformation("Copilot event received: {EventName}", turnContext.Activity.Name);
+ using var scope = _scopeFactory.CreateScope();
+ var liveChatService = scope.ServiceProvider.GetRequiredService();
+ var conversationManager = scope.ServiceProvider.GetRequiredService();
+
+ if (turnContext.Activity.Name == "startConversation")
+ {
+ _logger.LogInformation("StartConversation event received. Initiating live chat conversation.");
+ var liveChatConversationId = await liveChatService.StartConversationAsync();
+ if (string.IsNullOrEmpty(liveChatConversationId))
+ {
+ _logger.LogError("Failed to start live chat conversation.");
+ throw new Exception("Failed to start live chat conversation.");
+ }
+ _logger.LogInformation("Live chat conversation started with ID: {LiveChatConversationId}", liveChatConversationId);
+
+ // update mapping in ConversationManager with current activity state
+ await conversationManager.UpsertMappingByCopilotConversationId(turnContext.Activity, liveChatConversationId);
+
+ //sending EndConversation activity back to Copilot Studio. Every activity must have a response to allow topical flow to complete.
+ await turnContext.SendActivityAsync(new Activity
+ {
+ Type = ActivityTypes.EndOfConversation,
+ Name = "startConversation",
+ Text = string.Empty,
+ Code = EndOfConversationCodes.CompletedSuccessfully,
+ Value = new
+ {
+ LiveChatConversationId = liveChatConversationId
+ }
+ }, ct);
+ }
+ else if (turnContext.Activity.Name == "endConversation")
+ {
+ _logger.LogInformation("EndOfConversation event received. Performing any necessary cleanup.");
+
+ //sending EndOfConversation activity back to Copilot Studio. Every activity must have a response to allow topical flow to complete.
+ await turnContext.SendActivityAsync(new Activity
+ {
+ Type = ActivityTypes.EndOfConversation,
+ Name = "endConversation",
+ Text = string.Empty,
+ Code = EndOfConversationCodes.CompletedSuccessfully
+ }, ct);
+
+ var mapping = await conversationManager.GetMapping(turnContext.Activity.Conversation!.Id);
+ if (mapping == null)
+ {
+ _logger.LogWarning("No mapping found for Copilot conversation ID: {ConversationId}", turnContext.Activity.Conversation?.Id);
+ return;
+ }
+ await liveChatService.SendMessageAsync(mapping.LiveChatConversationId, message: "The conversation ended by user.", sender: "System");
+ await liveChatService.EndConversationAsync(mapping.LiveChatConversationId);
+ await conversationManager.RemoveMappingByCopilotConversationId(turnContext.Activity.Conversation!.Id);
+ await turnState.Conversation.DeleteStateAsync(turnContext, ct);
+ _logger.LogInformation("Conversation ended and state cleared.");
+ }
+ else
+ {
+ _logger.LogError("Unhandled event type: {EventName}", turnContext.Activity.Name);
+ throw new NotImplementedException($"Event '{turnContext.Activity.Name}' not implemented.");
+ }
+ await Task.CompletedTask;
+ }
+
+ private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
+ {
+ _logger.LogInformation("Copilot message received: {Message}", turnContext.Activity.Text);
+ var userName = turnContext.Activity.From?.Name ?? "unknown-user";
+ var message = turnContext.Activity.Text ?? string.Empty;
+
+ using var scope = _scopeFactory.CreateScope();
+ var liveChatService = scope.ServiceProvider.GetRequiredService();
+ var conversationManager = scope.ServiceProvider.GetRequiredService();
+
+
+ if (turnContext.Activity.ChannelId != "msteams")
+ {
+ _logger.LogError("Unsupported channel ID for proactive messages: {ChannelId}", turnContext.Activity.ChannelId);
+ throw new NotImplementedException($"Channel '{turnContext.Activity.ChannelId}' not supported for proactive messages.");
+ }
+
+ var mapping = await conversationManager.GetMapping(turnContext.Activity.Conversation!.Id);
+ if (mapping == null)
+ {
+ _logger.LogError("No mapping found for Copilot conversation ID: {ConversationId}", turnContext.Activity.Conversation?.Id);
+ throw new Exception("No mapping found for conversation. Make sure a live chat conversation has been started.");
+ }
+ mapping = await conversationManager.UpsertMappingByCopilotConversationId(turnContext.Activity, mapping.LiveChatConversationId);
+
+ await liveChatService.SendMessageAsync(mapping!.LiveChatConversationId, message, userName);
+ _logger.LogInformation("Message sent to live chat");
+ }
+}
\ No newline at end of file
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/MsTeamsProactiveMesssage.cs b/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/MsTeamsProactiveMesssage.cs
new file mode 100644
index 00000000..d7b623a4
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/CopilotStudio/MsTeamsProactiveMesssage.cs
@@ -0,0 +1,108 @@
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Core.Models;
+using ChannelAccount = Microsoft.Agents.Core.Models.ChannelAccount;
+using ConversationAccount = Microsoft.Agents.Core.Models.ConversationAccount;
+
+
+namespace HandoverToLiveAgent.CopilotStudio;
+
+public interface IProactiveMessenger
+{
+ Task SendTextAsync(ConversationMapping reference, string message, string? userName = null, CancellationToken ct = default);
+}
+
+public class MsTeamsProactiveMessage : IProactiveMessenger
+{
+ private readonly ILogger _logger;
+ private readonly IChannelAdapter? _channelAdapter;
+ private readonly IConfiguration? _configuration;
+
+ public MsTeamsProactiveMessage(ILogger logger,
+ IServiceProvider serviceProvider)
+ {
+ _logger = logger;
+ _channelAdapter = serviceProvider.GetService(typeof(IChannelAdapter)) as IChannelAdapter;
+ _configuration = serviceProvider.GetService(typeof(IConfiguration)) as IConfiguration;
+ }
+
+ public async Task SendTextAsync(ConversationMapping reference, string message, string? userName = null, CancellationToken ct = default)
+ {
+ if (_channelAdapter == null)
+ {
+ _logger.LogWarning("Channel adapter is not available. Cannot send proactive message.");
+ return;
+ }
+
+ var effectiveServiceUrl = reference.ServiceUrl!;
+ var channelId = reference.ChannelId;
+
+ var appId = ResolveAppIdForServiceUrl(effectiveServiceUrl);
+ if (appId == null)
+ {
+ _logger.LogWarning("Could not resolve App ID for service URL: {ServiceUrl}", effectiveServiceUrl);
+ return;
+ }
+
+ if (!string.Equals(channelId, "msteams", StringComparison.OrdinalIgnoreCase)
+ && effectiveServiceUrl.Contains("smba.trafficmanager.net", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogWarning("Non-Teams channel with SMBA ServiceUrl. Using as-is. Conv={ConversationId} Ch={ChannelId} ServiceUrl={ServiceUrl}", reference.CopilotConversationId, channelId, effectiveServiceUrl);
+ }
+ _logger.LogInformation("Sending proactive message to Teams user: {UserName}, Conv={ConversationId} Ch={ChannelId} ServiceUrl={ServiceUrl}", userName, reference.CopilotConversationId, channelId, effectiveServiceUrl);
+
+ try
+ {
+ var sdkRef = new ConversationReference
+ {
+ Agent = new ChannelAccount { Id = reference.BotId },
+ ChannelId = channelId!,
+ ServiceUrl = effectiveServiceUrl,
+ Conversation = new ConversationAccount { Id = reference.CopilotConversationId }
+ };
+ await _channelAdapter.ContinueConversationAsync(
+ appId,
+ sdkRef,
+ async (turnContext, token) =>
+ {
+ var msg = $"**{userName}**: {message}";
+ _logger.LogInformation("Proactive message content: {Message}", msg);
+ await turnContext.SendActivityAsync(msg, cancellationToken: token);
+ },
+ ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error sending proactive message to Teams user: {UserName}", reference.CopilotConversationId);
+ throw;
+ }
+
+ }
+
+ private string? ResolveAppIdForServiceUrl(string serviceUrl)
+ {
+ if (_configuration is null) return null;
+ var map = _configuration.GetSection("ConnectionsMap");
+ if (!map.Exists()) return null;
+ foreach (var entry in map.GetChildren())
+ {
+ var pattern = entry.GetValue("ServiceUrl");
+ var connectionName = entry.GetValue("Connection");
+ if (string.IsNullOrWhiteSpace(pattern) || string.IsNullOrWhiteSpace(connectionName)) continue;
+ if (WildcardMatch(serviceUrl, pattern))
+ {
+ var conn = _configuration.GetSection("Connections").GetSection(connectionName);
+ var clientId = conn.GetSection("Settings").GetValue("ClientId");
+ if (!string.IsNullOrWhiteSpace(clientId)) return clientId;
+ }
+ }
+ return null;
+ }
+
+ private bool WildcardMatch(string text, string pattern)
+ {
+ var regex = "^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$";
+ return Regex.IsMatch(text, regex, RegexOptions.IgnoreCase);
+ }
+}
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/HandoverToLiveAgentSample.csproj b/HandoverToLiveAgent/HandoverToLiveAgentSample/HandoverToLiveAgentSample.csproj
new file mode 100644
index 00000000..650136a3
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/HandoverToLiveAgentSample.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/LiveChat/LiveChatService.cs b/HandoverToLiveAgent/HandoverToLiveAgentSample/LiveChat/LiveChatService.cs
new file mode 100644
index 00000000..114df31c
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/LiveChat/LiveChatService.cs
@@ -0,0 +1,157 @@
+
+
+using System.Text;
+using System.Text.Json;
+
+namespace HandoverToLiveAgent.LiveChat;
+
+public interface ILiveChatService
+{
+ Task StartConversationAsync();
+ Task EndConversationAsync(string liveChatConversationId);
+ Task SendMessageAsync(string liveChatConversationId, string message, string sender);
+}
+
+public class LiveChatService : ILiveChatService
+{
+ private readonly HttpClient _httpClient;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger _logger;
+
+ public LiveChatService(HttpClient httpClient, IConfiguration configuration, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ public async Task StartConversationAsync()
+ {
+ try
+ {
+ var baseUrl = _configuration["LiveChatSettings:BaseUrl"];
+ if (string.IsNullOrEmpty(baseUrl))
+ {
+ _logger.LogError("BaseUrl is not configured in LiveChatSettings");
+ throw new Exception("BaseUrl is not configured in LiveChatSettings");
+ }
+
+ var conversationUrl = $"{baseUrl}/api/chat/start";
+ _logger.LogInformation("Starting a new live chat conversation at {Url}", conversationUrl);
+
+ var response = await _httpClient.PostAsync(conversationUrl, null);
+
+ if (response.IsSuccessStatusCode)
+ {
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var result = JsonSerializer.Deserialize(responseContent, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ _logger.LogInformation("Conversation started successfully with conversation ID: {ConversationId}", result?.ConversationId);
+ return result?.ConversationId;
+ }
+ else
+ {
+ _logger.LogWarning("Failed to start conversation. Status: {StatusCode}", response.StatusCode);
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting conversation");
+ return null;
+ }
+ }
+
+ public async Task EndConversationAsync(string liveChatConversationId)
+ {
+ try
+ {
+ var baseUrl = _configuration["LiveChatSettings:BaseUrl"];
+ if (string.IsNullOrEmpty(baseUrl))
+ {
+ _logger.LogError("BaseUrl is not configured in LiveChatSettings");
+ throw new Exception("BaseUrl is not configured in LiveChatSettings");
+ }
+
+ var endConversationUrl = $"{baseUrl}/api/chat/end";
+ _logger.LogInformation("Ending live chat conversation at {Url}", endConversationUrl);
+
+ var payload = new
+ {
+ conversationId = liveChatConversationId
+ };
+
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync(endConversationUrl, content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ _logger.LogInformation("Conversation ended successfully for ID: {ConversationId}", liveChatConversationId);
+ }
+ else
+ {
+ _logger.LogWarning("Failed to end conversation. Status: {StatusCode}", response.StatusCode);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error ending conversation");
+ }
+ }
+
+ public async Task SendMessageAsync(string liveChatConversationId, string message, string sender)
+ {
+ try
+ {
+ var baseUrl = _configuration["LiveChatSettings:BaseUrl"];
+ if (string.IsNullOrEmpty(baseUrl))
+ {
+ _logger.LogWarning("BaseUrl is not configured in LiveChatSettings");
+ return false;
+ }
+
+ var sendMessageUrl = $"{baseUrl}/api/chat/receive";
+ _logger.LogInformation("Sending message to {Url}: {Message}", sendMessageUrl, message);
+
+ var payload = new
+ {
+ conversationId = liveChatConversationId,
+ message,
+ sender
+ };
+
+ var json = JsonSerializer.Serialize(payload);
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ //Sending message to the Live Chat endpoint. The response message response will
+ //come back over a webhook configured in the Live Chat system.
+ var response = await _httpClient.PostAsync(sendMessageUrl, content);
+
+ if (response.IsSuccessStatusCode)
+ {
+ _logger.LogInformation("Message sent successfully");
+ return true;
+ }
+ else
+ {
+ _logger.LogWarning("Failed to send message. Status: {StatusCode}", response.StatusCode);
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error sending message");
+ return false;
+ }
+ }
+}
+
+public class ConversationResponse
+{
+ public string? ConversationId { get; set; }
+}
\ No newline at end of file
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/LiveChat/LiveChatWebhookController.cs b/HandoverToLiveAgent/HandoverToLiveAgentSample/LiveChat/LiveChatWebhookController.cs
new file mode 100644
index 00000000..e8b90a4f
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/LiveChat/LiveChatWebhookController.cs
@@ -0,0 +1,67 @@
+using Microsoft.AspNetCore.Mvc;
+using HandoverToLiveAgent.CopilotStudio;
+
+namespace HandoverToLiveAgent.LiveChat;
+
+[ApiController]
+[Route("api/livechat")]
+public class LiveChatWebhookController : ControllerBase
+{
+ private readonly ILogger _logger;
+ private readonly IConversationManager _conversationManager;
+ private readonly IProactiveMessenger _proactiveMessenger;
+
+ public LiveChatWebhookController(ILogger logger, IConversationManager conversationManager, IProactiveMessenger proactiveMessenger)
+ {
+ _logger = logger;
+ _conversationManager = conversationManager;
+ _proactiveMessenger = proactiveMessenger;
+ }
+
+ // POST: api/livechat/messages
+ // Used to receive webhook messages from the Live Chat system
+ [HttpPost("messages")]
+ public async Task ReceiveMessageAsync([FromBody] MessageRequest request)
+ {
+ _logger.LogDebug("Full message details: {@Request}", request);
+ try
+ {
+
+ var contosoUserName = request.Sender;
+ var contosoMessage = request.Message;
+ var liveChatConversationId = request.ConversationId;
+ _logger.LogInformation("Received message from Live Chat. Sender: {Sender}, Text: {Text}", contosoUserName, contosoMessage);
+
+ var mapping = await _conversationManager.GetMapping(liveChatConversationId);
+ if (mapping == null)
+ {
+ _logger.LogError("No mapping found for Live Chat conversation ID: {LiveChatConversationId}", liveChatConversationId);
+ throw new Exception("No mapping found for conversation. Make sure a Copilot Studio conversation has been started.");
+ }
+ // proactive messages are only supported in MS Teams channel
+ await _proactiveMessenger.SendTextAsync(mapping, contosoMessage, contosoUserName);
+ _logger.LogInformation("Proactive message sent to Copilot Studio for user: {UserName}", contosoUserName);
+
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing live chat message");
+ return StatusCode(500, ex.Message);
+ }
+
+ return Ok(new
+ {
+ message = "Message received successfully",
+ timestamp = DateTime.UtcNow,
+ receivedFrom = request.Sender
+ });
+ }
+}
+
+public class MessageRequest
+{
+ public string ConversationId { get; set; } = string.Empty;
+ public string Message { get; set; } = string.Empty;
+ public string Sender { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; }
+}
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/Program.cs b/HandoverToLiveAgent/HandoverToLiveAgentSample/Program.cs
new file mode 100644
index 00000000..02ec15fc
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/Program.cs
@@ -0,0 +1,57 @@
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.CopilotStudio.Client;
+using Microsoft.Agents.Hosting.AspNetCore;
+using Microsoft.Extensions.Options;
+using HandoverToLiveAgent.LiveChat;
+using Microsoft.Agents.CopilotStudio;
+using HandoverToLiveAgent.CopilotStudio;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.WebHost.ConfigureKestrel(serverOptions =>
+{
+ serverOptions.ListenAnyIP(5001);
+});
+
+builder.Services.AddScoped();
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddHttpClient();
+
+// Add services to the container.
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// Agents SDK setup
+builder.AddAgentApplicationOptions();
+builder.AddAgent();
+// Agents storage for conversation state
+builder.Services.AddSingleton();
+// Ensure AgentApplicationOptions is available for AgentApplication-based skills
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+// Only enforce HTTPS when an HTTPS binding is configured (avoid warnings when running HTTP-only locally)
+var hasHttpsBinding =
+ !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("HTTPS_PORTS")) ||
+ !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_HTTPS_PORT")) ||
+ builder.Configuration.GetSection("Kestrel:Endpoints:Https").Exists();
+
+if (!app.Environment.IsDevelopment() && hasHttpsBinding)
+{
+ app.UseHttpsRedirection();
+}
+
+app.UseStaticFiles();
+app.UseDefaultFiles();
+app.UseRouting();
+app.MapControllers();
+
+// Agents endpoint: /api/messages for incoming messages and activities from Copilot Studio skills
+var incomingRoute = app.MapPost("/api/messages", async (HttpRequest request,
+ HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken ct) =>
+{
+ await adapter.ProcessAsync(request, response, agent, ct);
+});
+app.Run();
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/README.md b/HandoverToLiveAgent/HandoverToLiveAgentSample/README.md
new file mode 100644
index 00000000..5f7bdd5d
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/README.md
@@ -0,0 +1,204 @@
+# Handover To Live Agent Sample
+
+A .NET 9.0 Copilot Studio skill that enables seamless handover of conversations from Copilot Studio to a live chat system. This application acts as a bridge between Copilot Studio agents and live support systems, managing bidirectional message flow and conversation state.
+
+## Project Structure
+
+```
+HandoverToLiveAgentSample/
+├── CopilotStudio/
+│ ├── CopilotStudioAgent.cs # Main agent handling Copilot Studio activities via bot skill
+│ ├── ConversationManager.cs # Manages conversation mappings between MCS and Live Chat mockup app
+│ └── MsTeamsProactiveMessage.cs # Sends proactive messages to MS Teams
+├── LiveChat/
+│ ├── LiveChatService.cs # Service to communicate with live chat app
+│ └── LiveChatWebhookController.cs # Receives webhook messages from live chat app
+├── wwwroot/
+│ └── skill-manifest.json # Copilot Studio skill manifest
+├── Program.cs # Application entry point
+├── appsettings.json # Configuration (credentials & URLs)
+```
+
+## Configuration
+
+Configure authentication and live chat settings in `appsettings.json`. This sample supports using 2 separate app registrations for different service URL patterns:
+
+```json
+{
+ "LiveChatSettings": {
+ "BaseUrl": "http://localhost:5000"
+ },
+ "Connections": {
+ "LiveChat": {
+ "ConnectionType": "AzureAD",
+ "Settings": {
+ "TenantId": "your-tenant-id",
+ "ClientId": "your-custom-service-principal-app-id",
+ "ClientSecret": "your-custom-service-principal-client-secret",
+ "Scopes": ["https://api.botframework.com/.default"]
+ }
+ },
+ "CopilotStudioBot": {
+ "ConnectionType": "AzureAD",
+ "Settings": {
+ "TenantId": "your-tenant-id",
+ "ClientId": "your-bot-app-id",
+ "ClientSecret": "your-bot-client-secret",
+ "Scopes": ["https://api.botframework.com/.default"]
+ }
+ }
+ },
+ "ConnectionsMap": [
+ {
+ "ServiceUrl": "https://smba*",
+ "Connection": "CopilotStudioBot"
+ },
+ {
+ "ServiceUrl": "https://pvaruntime*",
+ "Connection": "LiveChat"
+ }
+ ]
+}
+```
+
+### Configuration Details
+
+#### LiveChat Connection (Custom Service Principal)
+- **TenantId**: Your Azure AD tenant ID
+- **ClientId**: App ID of your custom service principal created for the live chat integration
+- **ClientSecret**: Client secret for the custom service principal
+
+#### CopilotStudioBot Connection (Bot App Registration)
+- **TenantId**: Your Azure AD tenant ID (same as above)
+- **ClientId**: Your Copilot Studio bot's App ID
+- **ClientSecret**: Your Copilot Studio bot's client secret
+
+#### General Settings
+- **LiveChatSettings.BaseUrl**: URL of the live chat application (ContosoLiveChatApp), default: `http://localhost:5000`
+
+## Running the Application
+
+1. Navigate to the project directory:
+```powershell
+cd CopilotStudioSamples\HandoverToLiveAgent\HandoverToLiveAgentSample
+```
+
+2. Restore dependencies and run:
+```powershell
+dotnet run
+```
+
+3. The application will start on:
+```
+http://localhost:5001
+```
+
+4. The skill endpoint will be available at:
+```
+http://localhost:5001/api/messages
+```
+
+5. Expose the app over a reverse proxy such as devtunnel. And make sure that the same public endpoint URL is set in the Copilot Studio Sample Agent
+
+
+## API Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `POST` | `/api/messages` | Main Copilot Studio skill endpoint (handles skill activities) |
+| `POST` | `/api/livechat/messages` | Webhook endpoint to receive messages from live chat app |
+
+## Architecture
+
+### API Flow
+
+```mermaid
+sequenceDiagram
+ participant User as MS Teams User
+ participant CS as Copilot Studio
+ participant Skill as Agent Skill Sample
+ participant LC as Live Chat API
+ participant Agent as Live Agent
+
+ User->>CS: Chat with bot
+ CS->>Skill: Event: startConversation
+ Skill->>LC: POST /api/chat/start
+ LC-->>Skill: conversationId (liveChatId)
+ Note over Skill: Store mapping (copilotId ↔ liveChatId)
+ Skill-->>CS: EndOfConversation (success)
+
+ Note over CS,Agent: Conversation Active
+
+ User->>CS: Send message
+ CS->>Skill: Message activity
+ Note over Skill: Update mapping with activity details
+ Skill->>LC: POST /api/chat/receive
+ LC-->>Agent: Display message
+
+ Agent->>LC: Send response
+ LC->>Skill: POST /api/livechat/messages (webhook)
+ Note over Skill: Get conversation mapping
+ Skill->>CS: Proactive message (MS Teams)
+ CS-->>User: Display response
+
+ Note over User,Agent: Messages exchanged...
+
+ User->>CS: End conversation
+ CS->>Skill: Event: endConversation
+ Skill->>LC: POST /api/chat/end
+ Note over Skill: Remove mapping
+ Skill-->>CS: EndOfConversation (success)
+```
+
+### Key Components
+
+#### CopilotStudioAgent
+Main agent class that handles incoming activities from Copilot Studio:
+- **OnEventAsync**: Handles `startConversation` and `endConversation` events
+- **OnMessageAsync**: Forwards user messages to live chat system
+- Uses scoped services to resolve dependencies per turn
+
+#### ConversationManager
+Manages bidirectional conversation mappings:
+- Stores mapping between Copilot conversation IDs and live chat conversation IDs
+- Tracks conversation metadata (user ID, channel ID, service URL)
+- Resolves SMBA regions for MS Teams proactive messaging
+- In-memory storage using static dictionaries
+
+#### LiveChatService
+Communicates with the live chat system:
+- **StartConversationAsync**: Initiates a new conversation in live chat app
+- **SendMessageAsync**: Forwards messages from Copilot Studio to live chat app
+- **EndConversationAsync**: Terminates the live chat conversation
+
+#### MsTeamsProactiveMessage
+Sends proactive messages back to MS Teams users:
+- Uses IChannelAdapter for proactive messaging
+- Resolves App ID based on service URL patterns
+- Supports SMBA (MS Teams) runtime URLs
+- Formats messages with sender name
+
+#### LiveChatWebhookController
+Receives webhook callbacks from live chat:
+- Accepts messages from live agents
+- Looks up conversation mapping
+- Sends proactive messages back to Copilot Studio conversation
+
+## Skill Manifest
+
+The `skill-manifest.json` defines the skill's capabilities for Copilot Studio:
+
+**Activities:**
+- **startConversation** (event): Initiates a live chat session
+- **sendMessage** (message): Sends user messages to live chat
+- **endConversation** (event): Terminates the live chat session
+
+**Endpoint Configuration:**
+```json
+{
+ "endpointUrl": "https://your-tunnel-url.com/api/messages",
+ "msAppId": "your-bot-app-id"
+}
+```
+
+Update the `endpointUrl` to your deployed URL or dev tunnel.
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/appsettings.json b/HandoverToLiveAgent/HandoverToLiveAgentSample/appsettings.json
new file mode 100644
index 00000000..e3bde2d7
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/appsettings.json
@@ -0,0 +1,48 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "LiveChatSettings": {
+ "BaseUrl": "http://localhost:5000"
+ },
+ "Connections": {
+ "LiveChat": {
+ "ConnectionType": "AzureAD",
+ "Settings": {
+ "TenantId": "", //Your Tenant ID
+ "ClientId": "", //Your custom Service Principal's App ID
+ "ClientSecret": "", //Your custom Service Principal's Client Secret
+ "Scopes": [
+ "https://api.botframework.com/.default"
+ ]
+ }
+ },
+ "CopilotStudioBot": {
+ "ConnectionType": "AzureAD",
+ "Settings": {
+ "TenantId": "", // Your Tenant ID
+ "ClientId": "", // Your Agent Bot's App ID (Same as Service Principal ID)
+ "ClientSecret": "", // Your Agent Bot's Client Secret
+ "Scopes": [
+ "https://api.botframework.com/.default"
+ ]
+ }
+ }
+ },
+ "ConnectionsMap": [
+ {
+ // SMBA runtime URL pattern to handle proactive messages to MS Teams
+ "ServiceUrl": "https://smba*",
+ "Connection": "CopilotStudioBot"
+ },
+ {
+ // PVA runtime URL pattern to handle non-proactive messages back to MCS
+ "ServiceUrl": "https://pvaruntime*",
+ "Connection": "LiveChat"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/HandoverToLiveAgent/HandoverToLiveAgentSample/wwwroot/skill-manifest.json b/HandoverToLiveAgent/HandoverToLiveAgentSample/wwwroot/skill-manifest.json
new file mode 100644
index 00000000..7e6989b6
--- /dev/null
+++ b/HandoverToLiveAgent/HandoverToLiveAgentSample/wwwroot/skill-manifest.json
@@ -0,0 +1,76 @@
+{
+ "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.0.0.json",
+ "$id": "handoff-skill",
+ "name": "Handoff Skill",
+ "version": "1.0.0",
+ "description": "Handoff skill for Copilot Studio sample",
+ "publisherName": "Microsoft",
+ "copyright": "Copyright (c) Microsoft. All rights reserved.",
+ "license": "",
+ "tags": [
+ "handoff"
+ ],
+ "endpoints": [
+ {
+ "name": "default",
+ "protocol": "BotFrameworkV3",
+ "description": "Default endpoint for the Handoff Skill",
+ "endpointUrl": "https://-5001.euw.devtunnels.ms/api/messages",
+ "msAppId": "",
+ }
+ ],
+ "activities": {
+ "sendMessage": {
+ "type": "message",
+ "description": "Sends a message to the Contoso Live Chat",
+ "value": {
+ "$ref": "#/definitions/messageInput"
+ }
+ },
+ "endConversation": {
+ "name": "endConversation",
+ "type": "event",
+ "description": "End a conversation with the Contoso Live Chat",
+ "value": {
+ "$ref": "#/definitions/endConversationInput"
+ }
+ },
+ "startConversation": {
+ "name": "startConversation",
+ "type": "event",
+ "description": "Start a conversation with the Contoso Live Chat",
+ "resultValue": {
+ "$ref": "#/definitions/startConversationOutput"
+ }
+ }
+ },
+ "definitions": {
+ "messageInput": {
+ "type": "object",
+ "properties": {
+ "LiveChatConversationId": {
+ "type": "string"
+ },
+ "ProblemDescription": {
+ "type": "string"
+ }
+ }
+ },
+ "endConversationInput": {
+ "type": "object",
+ "properties": {
+ "LiveChatConversationId": {
+ "type": "string"
+ }
+ }
+ },
+ "startConversationOutput": {
+ "type": "object",
+ "properties": {
+ "LiveChatConversationId": {
+ "type": "string"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/HandoverToLiveAgent/README.md b/HandoverToLiveAgent/README.md
new file mode 100644
index 00000000..d9e210a2
--- /dev/null
+++ b/HandoverToLiveAgent/README.md
@@ -0,0 +1,97 @@
+# Copilot Studio Handover To Live Agent Sample
+
+The solution shows how to create a handoff process from Copilot Studio agent to a 3rd party customer service system.
+
+## Overview
+
+This solution demonstrates how Copilot Studio agent can hand over a conversation to an external 3rd party application and return the conversation back to the agent. The solution uses [Microsoft Teams proactive messaging](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages?tabs=dotnet) feature to allow sending multiple asynchronous messages back to the human-to-agent conversation.
+
+## Solution
+
+The sample consists of the following elements:
+
+- ContosoLiveChatApp mimicking a 3rd party customer service app. More details on that project can be found in [./ContosoLiveChatApp/Readme.md](./ContosoLiveChatApp/README.md)
+
+- HandoverToLiveAgentSample that is the backend for [Copilot Studio skill](https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-use-skills) as a communication layer with ContosoLiveChatApp. More details on that project can be found in [./HandoverToLiveAgentSample/README.md](./HandoverToLiveAgentSample/README.md)
+
+- [HandoverAgentSample.zip](./HandoverAgentSample.zip) solution with an example agent using previously mentioned Copilot Studio skill
+
+## Installation
+
+1. In order to communicate MCS agent with locally running HandoverToLiveAgentSample app, a reverse proxy is required to expose the app over the internet. You can install [devtunnel](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started?tabs=windows) and run the following commands:
+
+ ```powershell
+ devtunnel login
+ devtunnel create --allow-anonymous
+ devtunnel port create -p 5001
+ devtunnel host
+ ```
+
+ Take note of the `connect via browser` endpoint, it should look like `https://-5001.euw.devtunnels.ms`
+
+1. Create new App Registration `LiveChatSample` in your Micrsofot Entra ID and note its AppId and new Secret
+
+1. In a new terminal, run the `.\HandoverToLiveAgentSample\HandoverToLiveAgentSample.csproj` skill hosting app
+
+ ```powershell
+ dotnet run --project .\HandoverToLiveAgentSample\HandoverToLiveAgentSample.csproj
+ ```
+
+1. Validate that the app is running by going to the devtunnel URL: `https://-5001.euw.devtunnels.ms/skill-manifest.json`
+
+1. In another terminal, run the `.\ContosoLiveChatApp\ContosoLiveChatApp.csproj` Contoso Live Chat app and in your browser open the following URL: `http://localhost:5000/` and validate that the app is running
+
+ ```powershell
+ dotnet run --project .\ContosoLiveChatApp\ContosoLiveChatApp.csproj
+ ```
+
+1. Import HandoverAgentSample.zip to your Dataverse environment.
+During the import you will be asked to fill in environment variables:
+ - `[Contoso Agent] Handoff Skill endpointUrl`: override value with `https://-5001.euw.devtunnels.ms/api/messages`
+ - `[Contoso Agent] Handoff Skill msAppId`: use your new app registration AppId
+
+ 
+
+1. After solution import is finished, go to `https://copilotstudio.microsoft.com/`, open `Contoso Agent`. Go to "Settings" > "Advanced" > "Metadata" and note the `Agent App ID`
+
+1. In the same app registration, create and note a new secret in "Certificates & secrets"
+
+1. Go to [appsettings.json](./HandoverToLiveAgentSample/appsettings.json) and set "TenantId", "ClientId" and "Secret" under `CopilotStudioBot` connection from the previous app registration (automatically created Copilot app registration)
+
+1. In the same [appsettings.json](./HandoverToLiveAgentSample/appsettings.json) "TenantId", "ClientId" and "Secret" under `LiveChat` connection using you newly created app registration `LiveChatSample`
+
+1. For the `LiveChatSample` App Registration, go to "Branding & properties" and in the "Home page URL" save the same value as in the environment variable (`https://-5001.euw.devtunnels.ms/api/messages`)
+
+ 
+
+1. Go to [skill-manifest.json](./HandoverToLiveAgentSample/wwwroot/skill-manifest.json) and replace "endpointUrl" and "msAppId" with `https://-5001.euw.devtunnels.ms/api/messages` and the `LiveChatSample` app registration AppId. Don't forget to restart the app after changing config files.
+
+1. On the Copilot Studio page, publish the agent and add it to the "Teams and Microsoft 365 Copilot" channel.
+
+## Usage
+
+When chatting with your agent you can type:
+
+"I want to talk with a person" - to escalate your conversation to the Contoso Live Chat app.
+
+"Good bye" - to end escalation with the Contoso Live Chat app
+
+## Agent architecture
+
+Contoso Agent uses skills to communicate with the Contoso Live Chat app. Depending on the integration options of your customer service system, this example may require smaller or larger changes.
+
+Two topics have been customized:
+
+1. "Escalate to Live Chat" - responsible for creating a new handoff using the "endConversation" skill and "sendMessage" skill to maintain communication.
+
+1. "Goodbye Live Chat" - responsible for closing the handoff, so that the user can go seamlessly back to the agent. This is done by allowing the topic to jump into the "sendMessage" skill added in "Escalate to Live Chat". Without that, ending the handoff would be possible only in the backend code by hardcoding specific verbs.
+
+## Extending codebase
+
+You can control how the skills are built by changing the skill-manifest.json and refreshing it in Copilot Studio using the `https://-5001.euw.devtunnels.ms/skill-manifest.json` link.
+
+## Known limitations
+
+1. Proactive Messaging is required, so that the Live Chat user can respond back at any point in time. Without this feature, standard conversations are request-reply based, requiring the Live Chat user to wait for a message to write a reply back. This limits the current scope to only the Microsoft Teams channel (non-Microsoft apps not included).
+
+1. Due to how conversations are mapped between the systems, one MS Teams chat can chat with multiple Live Chat sessions, but that would mean that conversations are simultaneously being received in the same MS Teams agent conversation. Also, if a Teams user would have multiple sessions opened, the sample does not provide a UI that would allow them to pick which session they would like to go back to.