CrestApps.Core External Chat Relays
Overview
External Chat Relays provide persistent bidirectional connections between your AI chat system and external live-agent platforms (e.g., customer service desks, human escalation systems). Unlike webhook-based integrations where the external system calls back into your application, relays maintain a persistent connection so that events such as typing indicators, agent-connected notifications, wait-time updates, and chat messages flow in real time without polling.
The relay system is protocol-agnostic. Implementations can use WebSocket, SSE (Server-Sent Events), gRPC streaming, WebRTC data channels, message queues, or any other transport.
Key Interfaces
| Interface | Purpose |
|---|---|
IExternalChatRelay |
Persistent connection to an external system. Methods for connect, disconnect, send prompts, and send signals. Extends IAsyncDisposable. |
IExternalChatRelayManager |
Singleton that tracks active relay instances by session ID. Creates, retrieves, and closes relays. |
IExternalChatRelayEventHandler |
Routes incoming relay events to keyed notification builders by event type. |
IExternalChatRelayNotificationBuilder |
Keyed scoped service that builds a ChatNotification for a specific event type. |
IExternalChatRelayNotificationHandler |
Sends or removes notifications produced by the builder. |
Event Flow
- External system sends event to relay connection
IExternalChatRelayEventHandler.HandleEventAsyncreceives theExternalChatRelayEvent- Handler resolves a keyed
IExternalChatRelayNotificationBuilderbyEventType - Builder populates a
ChatNotificationandExternalChatRelayNotificationResult IExternalChatRelayNotificationHandler.HandleAsyncremoves old notifications and sends new ones
Well-Known Event Types
Use constants from ExternalChatRelayEventTypes:
| Constant | Value | Description |
|---|---|---|
AgentTyping |
agent-typing |
Agent is typing a response |
AgentStoppedTyping |
agent-stopped-typing |
Agent stopped typing |
AgentConnected |
agent-connected |
Live agent joined the session |
AgentDisconnected |
agent-disconnected |
Live agent left the session |
AgentReconnecting |
agent-reconnecting |
Agent reconnecting after disruption |
ConnectionLost |
connection-lost |
Connection to external system lost |
ConnectionRestored |
connection-restored |
Connection restored after loss |
Message |
message |
External system sent a chat message |
WaitTimeUpdated |
wait-time-updated |
Estimated wait time updated |
SessionEnded |
session-ended |
External session ended |
Event types are strings (not enums) so third-party integrations can define custom types without modifying the framework.
Implement a Custom Relay
Create a class that implements IExternalChatRelay. Handle connection establishment, prompt forwarding, signal sending, and graceful disconnection.
public sealed class LiveAgentRelay : IExternalChatRelay
{
private readonly IExternalChatRelayEventHandler _eventHandler;
private WebSocket _socket;
private ExternalChatRelayContext _context;
public LiveAgentRelay(IExternalChatRelayEventHandler eventHandler)
{
_eventHandler = eventHandler;
}
public Task<bool> IsConnectedAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(_socket?.State == WebSocketState.Open);
}
public async Task ConnectAsync(ExternalChatRelayContext context, CancellationToken cancellationToken = default)
{
_context = context;
_socket = new ClientWebSocket();
await ((ClientWebSocket)_socket).ConnectAsync(
new Uri("wss://live-agent.example.com/relay"),
cancellationToken);
// Start background listener for incoming events.
_ = ListenForEventsAsync(cancellationToken);
}
public async Task SendPromptAsync(string text, CancellationToken cancellationToken = default)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(text);
await _socket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken);
}
public async Task SendSignalAsync(
string signalName,
IDictionary<string, string> data = null,
CancellationToken cancellationToken = default)
{
var payload = System.Text.Json.JsonSerializer.Serialize(new { signal = signalName, data });
var bytes = System.Text.Encoding.UTF8.GetBytes(payload);
await _socket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken);
}
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_socket?.State == WebSocketState.Open)
{
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", cancellationToken);
}
}
public async ValueTask DisposeAsync()
{
if (_socket != null)
{
_socket.Dispose();
_socket = null;
}
}
private async Task ListenForEventsAsync(CancellationToken cancellationToken)
{
var buffer = new byte[4096];
while (_socket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var result = await _socket.ReceiveAsync(buffer, cancellationToken);
var json = System.Text.Encoding.UTF8.GetString(buffer, 0, result.Count);
var relayEvent = System.Text.Json.JsonSerializer.Deserialize<ExternalChatRelayEvent>(json);
if (relayEvent != null)
{
await _eventHandler.HandleEventAsync(
_context.SessionId,
_context.ChatType,
relayEvent,
cancellationToken);
}
}
}
}
Connection Lifecycle Management
The IExternalChatRelayManager (registered as a singleton) manages relay instances using a thread-safe concurrent dictionary keyed by session ID.
Get or Create a Relay
Use GetOrCreateAsync with a factory function. The manager checks for an existing connected relay before creating a new one and handles race conditions between concurrent callers.
public sealed class ChatRelayService
{
private readonly IExternalChatRelayManager _relayManager;
private readonly IExternalChatRelayEventHandler _eventHandler;
public ChatRelayService(
IExternalChatRelayManager relayManager,
IExternalChatRelayEventHandler eventHandler)
{
_relayManager = relayManager;
_eventHandler = eventHandler;
}
public async Task<IExternalChatRelay> ConnectToLiveAgentAsync(
string sessionId,
ChatContextType chatType,
CancellationToken cancellationToken)
{
var context = new ExternalChatRelayContext
{
SessionId = sessionId,
ChatType = chatType,
};
return await _relayManager.GetOrCreateAsync(
sessionId,
context,
() => new LiveAgentRelay(_eventHandler),
cancellationToken);
}
}
Close a Relay
Call CloseAsync when the session ends. The manager gracefully disconnects and disposes the relay.
await relayManager.CloseAsync(sessionId, cancellationToken);
Custom Notification Builder
Register a keyed IExternalChatRelayNotificationBuilder to handle specific event types. The key must match the event type string.
public sealed class AgentConnectedNotificationBuilder : IExternalChatRelayNotificationBuilder
{
public string NotificationType => "agent-connected-notice";
public void Build(
ExternalChatRelayEvent relayEvent,
ChatNotification notification,
ExternalChatRelayNotificationResult result,
IStringLocalizer T)
{
notification.Message = T["Agent {0} has joined the conversation.", relayEvent.AgentName ?? "Support"];
// Remove any pending transfer or wait-time notifications.
result.RemoveNotificationTypes.Add("transfer");
result.RemoveNotificationTypes.Add("wait-time");
}
}
Register the Builder
Use keyed service registration with the event type as the key:
services.AddKeyedScoped<IExternalChatRelayNotificationBuilder, AgentConnectedNotificationBuilder>(
ExternalChatRelayEventTypes.AgentConnected);
Models
ExternalChatRelayContext
Provides the session identity for establishing a relay connection.
var context = new ExternalChatRelayContext
{
SessionId = "session-123",
ChatType = ChatContextType.AIChatSession,
};
ExternalChatRelayEvent
Represents an event from the external system with EventType, Content, AgentName, and extensible Metadata.
var relayEvent = new ExternalChatRelayEvent
{
EventType = ExternalChatRelayEventTypes.Message,
Content = "Hello, how can I help you?",
AgentName = "Sarah",
Metadata = new Dictionary<string, string>
{
["department"] = "billing",
},
};
ExternalChatRelayNotificationResult
Describes which notifications to remove and which to send. Set IsUpdate to true to replace an existing notification instead of adding a new one.
Service Registration
The default ExternalChatRelayConnectionManager is registered as a singleton:
services.TryAddSingleton<IExternalChatRelayManager, ExternalChatRelayConnectionManager>();