Reading Along: SupportChat¶
Guide Home | Previous: Reading Along: TicTacToe | Next: Reading Along: DeliveryDispatch
View in another language — C++ · C#/.NET · Java · Kotlin · Node/TypeScript
What you get from this chapter
You can open the SupportChat sample in an editor and follow a message from the customer's request to open a conversation, through agent assignment, to the conversation closing, through the code of each server it passes. The code in this chapter comes from the SupportChat sample in the per-language example repositories.
Picking a Sample introduces this sample. This chapter explains its roles and code locations, main message flows, and run verification in source order. See the SupportChat scenario for requirements and message contracts.
1. What This Sample Demonstrates¶
The customer and the agent each keep a single STREAM connection to the Session server. API handles authentication, and the conversation state is owned by a conversation Spot on Support. One agent handles several conversations at once, so the agent's single connection has a roster Actor and one conversation Actor per conversation bound to it. The agent sends through each room's Actor handle, and Session selects the relay target from the packet's Actor slot.
The flow this chapter follows is authentication and the identity Actor binding → opening a conversation and assignment → the agent joining the conversation → chat relayed by Actor slot → the idle timer and closing.
2. Roles and Where the Code Lives¶
| Role | Processes | Owns | Code |
|---|---|---|---|
| Session | 1 | STREAM connections, authentication and join packets, Actor binding and Actor slot relay | Server/Session |
| API | 1 | Token verification, the request to create a conversation Spot | Server/Api |
| Support | 1 | Entry Spot, conversation Spot, identity, roster and conversation Actors, assignment and pushes | Server/Support/Infrastructure |
| Client | 1 | The customer and agent scenarios, with self-checks | Client |
Participants, MessageSeq, typing and the idle/close transitions live in Server/Support/Domain and
reference no framework type. The agent capacity decision belongs to Server/Support/Application.
Messages are set by the JSON contract in shared.
3. Server Configuration¶
Session calls objects in the Support mesh, calls the API channel, and receives STREAM connections.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/session/SessionApplication.kt
options.addClientServerChannel(SampleNames.ApiChannel).client()
options.addClientServerChannel(SampleNames.SupportChannel).client()
val node = options.addRouteMesh(SampleNames.SupportSpotDiscovery)
node.listen(session.routerEndpoint).setRoutingIdPrefix("support-session")
node.objects().client()
options
.addStreamNode(SampleNames.StreamNode)
.bind(session.streamEndpoint)
.enableActorDispatch()
.registerSession<SupportChatSession>()
Support registers the Entry Spot, the Actor factory and the conversation Spot factory.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/SupportApplication.kt
val node = options.addRouteMesh(SampleNames.SupportSpotDiscovery)
node.listen(support.routerEndpoint).setRoutingIdPrefix("support-owner")
node
.objects()
.server()
.addEntrySpot<SupportEntrySpot>()
.addActorFactory<SupportUserActor, SupportUserActorFactory>(
SampleNames.SupportActorType
) {
preserveStateWith<SupportUserActor, SupportUserActorRelocationAdapter>()
}
.addSpotFactory<ConversationSpot>(SampleNames.ConversationSpotType) {
disableRelocation()
}
4. Authentication and the Identity Actor¶
The first packet the client sends is an authentication request. The session splits packets by name into authentication, conversation join and everything else.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/session/sessions/SupportChatSession.kt
override suspend fun onDispatchSuspending(
dispatch: ZLinkSessionDispatchContext,
payload: ZLinkMessage,
) {
when (dispatch.packetName()) {
"AuthenticateReq" -> authenticate(payload.decode<AuthenticateReq>())
"JoinConversationReq" ->
joinConversation(dispatch, payload.decode<JoinConversationReq>(), payload)
else -> relayConversationPacket(dispatch, payload)
}
}
After authentication the session creates or finds the identity Actor and binds it to the current session. For a customer that Actor is the conversation participant itself; for an agent it is the roster Actor.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/session/sessions/SupportChatSession.kt
val ensured =
kotlinChannels
.requestToChannel<EnsureSupportUserActorRes>(
SampleNames.SupportChannel,
EnsureSupportUserActorReq(
actorId = actorId,
displayName = displayName,
role = role,
participantId = actorId,
),
)
.timeout(SampleTimings.RequestTimeout)
.await()
identityActor = context.actors().bindOrGetActor(ensured.actor.toActorRef())
identityActorId = actorId
identityDisplayName = displayName
identityRole = role
Reconnecting with the same id binds a new session to the same Actor. Binding is covered by Session and Actor.
5. Opening a Conversation and Assigning an Agent¶
The customer's open request is relayed to the identity Actor and arrives at the Entry Spot's actor request handler. The handler asks API to create the conversation and reserves a join into the conversation Spot with the id it receives.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/entryspot/handlers/OpenConversationActorHandler.kt
val opened =
kotlinRoutes
.requestToChannel<OpenConversationApiRes>(
SampleNames.ApiChannel,
OpenConversationApiReq(actor.participantId, actor.displayName, request.subject),
)
.timeout(SampleTimings.RequestTimeout)
.await()
val joined =
actor.scheduleConversationJoin(
opened.conversationId,
request.subject,
JoinConversationReq(
opened.conversationId,
actor.participantId,
actor.role,
actor.displayName,
),
)
return OpenConversationRes(opened.conversationId, joined.state)
API creates the conversation Spot through the Spot manager. The framework issues the id and picks the owner.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/FrameworkConversationStarter.kt
kotlinSpots
.getOrCreate(conversationId, SampleNames.ConversationSpotType)
.request(
ConversationCreateReq(
customerActorId = request.customerActorId,
customerDisplayName = request.customerDisplayName,
subject = request.subject,
createdAtUnixMs = request.createdAtUnixMs,
)
)
.await()
Once the customer Actor's join completes, the conversation Spot picks an agent with capacity left and sends an assignment notify to that roster Actor's bound session. If no agent is left, the conversation waits rather than failing.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/conversationspot/ConversationSpot.kt
private suspend fun assignAgent() {
val conversation = requireConversation()
val assigned = assignment.assignForConversation(conversation.conversationId) ?: return
notifications.publishAssignedToRoster(
directory.get(assigned.rosterActorId),
conversation.snapshot(),
)
logger.info(
"support conversation: assigned. conversation={}, roster={}",
conversation.conversationId,
assigned.rosterActorId,
)
}
The roster Actor lives in the Entry Spot and is not a member of the conversation Spot. Sending to its bound session still needs no knowledge of where the Actor is.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/conversationspot/notifications/ConversationNotificationPublisher.kt
suspend fun publishAssignedToRoster(roster: SupportUserActor, snapshot: ConversationSnapshot) {
val state = ConversationContracts.toState(snapshot)
try {
roster
.context()
.boundSession()
.kotlin()
.send(ConversationAssignedNotify(state.conversationId, state))
.await()
} catch (_: RuntimeException) {
// Notifications are best effort when the participant session is stale.
}
}
Creating a User Spot is covered by Spot, and reserving a join by Actor Membership.
6. The Agent Joining a Conversation — One Connection, Several Actors¶
An Actor belongs to one Spot at a time. The roster Actor has to stay in the Entry Spot, so when the agent enters a conversation the session creates a new per-conversation Actor and binds it to the same session as well.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/session/sessions/SupportChatSession.kt
val ensured =
kotlinChannels
.requestToChannel<EnsureAgentConversationRes>(
SampleNames.SupportChannel,
EnsureAgentConversationReq(
identityActorId,
identityDisplayName,
request.conversationId,
),
)
.timeout(SampleTimings.RequestTimeout)
.await()
val actor = context.actors().bindOrGetActor(ensured.actor.toActorRef())
logger.info(
"session: agent joined conversation. roster={}, conversation={}",
identityActorId,
request.conversationId,
)
actor.kotlin().relay(dispatch, payload).await()
From then on the pushes of that conversation arrive at the conversation Actor's bound session — the agent's same connection. How many may be bound and the rules are covered by How Session Binding Works.
7. Chat — Actor-Based Relay¶
The agent finds a room's Actor handle using JoinConversationRes.actorId and sends chat packets
through that handle. The customer sends through the single Actor bound to its connection.
Authentication and JoinConversationReq(conversationId) are binding packets handled directly by the
Session handler. Session relays the other packets without decoding their payloads. If a packet has an
Actor slot, Session relays it to that Actor bound in the dispatch context; otherwise, it uses the
connection's identity Actor.
Server/Session/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/session/sessions/SupportChatSession.kt
The conversation Spot assigns a MessageSeq to the message and pushes a notify to the bound session
of every participant except the sender.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/conversationspot/notifications/ConversationNotificationPublisher.kt
private suspend fun publishMessage(
event: ConversationEvent,
state: ConversationState,
actors: Map<String, SupportUserActor>,
) {
val message = event.message ?: error("Message event requires a chat message.")
val chatMessage = ConversationContracts.toMessage(message)
publishAll(excludeActor(actors, message.senderActorId).values) { actor ->
actor
.context()
.boundSession()
.kotlin()
.send(ChatMessageNotify(state.conversationId, chatMessage, state))
.await()
}
}
The lifetime of a reply token is covered by How STREAM Works, and the one-at-a-time execution boundary inside a Spot by The Execution Model.
8. The Idle Timer and Closing¶
Some time after the last message, the conversation Spot's timer moves the conversation to idle, and if no message arrives within the grace period it closes it.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/conversationspot/ConversationSpot.kt
suspend fun checkIdle() {
val change = conversation?.markIdle(System.currentTimeMillis()) ?: return
publishChange(change)
}
When a conversation closes, the assigned agent's capacity is returned. When the agent's connection drops, the Entry Spot's disconnect callback withdraws the availability, and the conversation state stays as it is and continues after reconnecting.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/entryspot/SupportEntrySpot.kt
override suspend fun onDisconnectActorSuspending(actor: SupportUserActor) {
if (actor.role == SupportChatRoles.Agent) {
assignment.setAvailable(actor.actorId, actor.displayName, false)
}
}
The agent turns availability on and off with a request.
Server/Support/src/main/kotlin/systems/zlink/samples/kotlin/supportchat/server/support/infrastructure/zlink/spots/entryspot/handlers/SetAgentAvailableHandler.kt
override suspend fun handle(
entrySpot: SupportEntrySpot,
actor: SupportUserActor,
context: ZLinkMessageContext,
request: SetAgentAvailableReq,
): SetAgentAvailableRes {
if (actor.role != SupportChatRoles.Agent) {
throw IllegalStateException("Only agent actors can set availability.")
}
directory.addOrUpdate(actor)
assignment.setAvailable(actor.actorId, actor.displayName, request.isAvailable)
return SetAgentAvailableRes(request.isAvailable)
}
Timers are covered by Timers and Workers, and where the disconnect notification lands by How Session Binding Works.
9. Running and Verifying¶
One runner starts the Redis container, the server processes and the client scenario together.
The client asserts the assignment notify, the MessageSeq order, the typing notify, the idle and close
transitions and the state after reconnecting. The checks and the exact log strings are set by the
SupportChat scenario.
10. Related Documents¶
- Comparison with the other samples and how to choose: Picking a Sample
- Requirements, message contract and completion criteria: SupportChat scenario
- A layout that binds a single Actor per session: Reading Along: Bingo