When an AI Leaves the DM, Make the Handoff Atomic A developer has published a TypeScript tutorial for building an atomic handoff coordinator for AI-assisted direct messages on Tencent RTC's Social Messaging platform. The coordinator enforces a single invariant — at most one responder holds reply authority at a time, and any change in authority invalidates all unfinished replies from the previous owner — using a nine-mode state machine that includes consent_required, ai_generating, delivery_unknown, and human_active. The author argues that reply authority, not model personality, is the core engineering problem when an AI and a human agent share a conversation thread. Users may prefer one AI assistant over another for the same reason they prefer a particular coworker: predictable behavior builds trust. That comparison has a dangerous limit in direct messages. A coworker knows when they have handed a conversation to someone else. An AI integration often does not. The bot keeps generating while an agent opens the thread, both responders send a message, and the user can no longer tell who is responsible. The practical problem is not choosing the most personable model. It is controlling reply authority . In this tutorial, we will build a TypeScript coordinator for a Tencent RTC social-messaging experience with these properties: Tencent RTC's Social Messaging solution covers scenarios including 1-to-1 chat, group discussion, communities, and rich media. This tutorial focuses on the 1-to-1 DM case described in the official Social Messaging solution https://trtc.io/solutions/social-messaging . The central invariant is small enough to put in a pull-request description: At most one responder has authority to answer a DM, and changing that authority invalidates all unfinished replies from the previous owner. This separates demonstrated AI utility from the larger promise implied by calling an AI a coworker. A model can draft a routine answer, summarize selected context, or recommend escalation. It cannot independently guarantee that its context is current, that a human has not claimed the thread, or that a timed-out send did not actually arrive. Those are application-state problems. We will represent ownership with the following modes: | Mode | Who may reply? | What the user should see | |---|---|---| | consent required | Neither | A choice to start AI assistance or request a person | | ai ready | AI, after a user message | AI assistance is enabled | | ai generating | Nobody yet | A cancelable working indicator | | moderating | Nobody yet | The draft is not visible | | bot sending | Nobody else | The approved reply is being delivered | | delivery unknown | Nobody | Delivery is being checked; do not regenerate | | handoff pending | Neither | A person has been requested | | human active | The claimed agent | The agent's identity or role is visible | | ended | Neither | The conversation is closed | Notice that handoff pending does not grant authority to the AI just because an agent is slow to arrive. Slow escalation is still escalation. mkdir atomic-dm-handoff cd atomic-dm-handoff npm init -y npm install --save-dev typescript tsx vitest @types/node npx tsc --init --strict mkdir src npm pkg set scripts.test="vitest run" npm pkg set scripts.demo="tsx src/demo.ts" Our core will not import a chat SDK or model client. Keeping the state transition pure lets us reproduce races without waiting for a network. Create src/coordinator.ts : export type Mode = | "consent required" | "ai ready" | "ai generating" | "moderating" | "bot sending" | "delivery unknown" | "handoff pending" | "human active" | "ended"; export type Draft = { turnId: string; text: string; }; export type State = { conversationId: string; mode: Mode; epoch: number; turnId?: string; draft?: Draft; agentId?: string; }; export type Event = | { type: "USER OPTED IN" } | { type: "USER MESSAGE"; messageId: string; requiresHuman: boolean; } | { type: "AI DRAFTED"; epoch: number; turnId: string; text: string } | { type: "MODERATION APPROVED"; epoch: number; turnId: string } | { type: "MODERATION REJECTED"; epoch: number; turnId: string; reason: "blocked" | "unavailable"; } | { type: "BOT DELIVERED"; epoch: number; turnId: string } | { type: "BOT DELIVERY UNKNOWN"; epoch: number; turnId: string } | { type: "DELIVERY RECONCILED"; epoch: number; turnId: string; delivered: boolean; } | { type: "REQUEST HUMAN" } | { type: "HUMAN CLAIMED"; agentId: string } | { type: "HUMAN RELEASED" } | { type: "END" }; export type Effect = | { type: "GENERATE"; epoch: number; turnId: string; sourceMessageId: string; } | { type: "MODERATE"; epoch: number; turnId: string; text: string } | { type: "SEND BOT"; epoch: number; turnId: string; clientMessageId: string; text: string; } | { type: "RECONCILE DELIVERY"; epoch: number; turnId: string; clientMessageId: string; } | { type: "NOTIFY HUMANS"; conversationId: string } | { type: "SHOW STATUS"; text: string }; export type Result = { state: State; effects: Effect ; }; export const initialState = conversationId: string : State = { conversationId, mode: "consent required", epoch: 0, } ; function matchesActiveTurn state: State, event: { epoch: number; turnId: string }, : boolean { return state.epoch === event.epoch && state.turnId === event.turnId; } function requestHandoff state: State : Result { const next: State = { conversationId: state.conversationId, mode: "handoff pending", // Incrementing the epoch makes every unfinished callback stale. epoch: state.epoch + 1, }; return { state: next, effects: { type: "NOTIFY HUMANS", conversationId: state.conversationId }, { type: "SHOW STATUS", text: "A person has been requested." }, , }; } export function reduce state: State, event: Event : Result { if state.mode === "ended" { return { state, effects: }; } if event.type === "END" { return { state: { conversationId: state.conversationId, mode: "ended", epoch: state.epoch + 1, }, effects: , }; } if event.type === "REQUEST HUMAN" { return requestHandoff state ; } if event.type === "USER OPTED IN" && state.mode === "consent required" { return { state: { ...state, mode: "ai ready", epoch: state.epoch + 1 }, effects: { type: "SHOW STATUS", text: "AI assistance is on." } , }; } if event.type === "USER MESSAGE" && state.mode === "ai ready" { if event.requiresHuman return requestHandoff state ; const epoch = state.epoch + 1; const turnId = turn:${event.messageId} ; return { state: { ...state, mode: "ai generating", epoch, turnId }, effects: { type: "GENERATE", epoch, turnId, sourceMessageId: event.messageId, }, , }; } if event.type === "AI DRAFTED" && state.mode === "ai generating" && matchesActiveTurn state, event { return { state: { ...state, mode: "moderating", draft: { turnId: event.turnId, text: event.text }, }, effects: { type: "MODERATE", epoch: event.epoch, turnId: event.turnId, text: event.text, }, , }; } if event.type === "MODERATION APPROVED" && state.mode === "moderating" && state.draft && matchesActiveTurn state, event { const clientMessageId = ai:${state.conversationId}:${event.turnId} ; return { state: { ...state, mode: "bot sending" }, effects: { type: "SEND BOT", epoch: event.epoch, turnId: event.turnId, clientMessageId, text: state.draft.text, }, , }; } if event.type === "MODERATION REJECTED" && matchesActiveTurn state, event { return requestHandoff state ; } if event.type === "BOT DELIVERED" && state.mode === "bot sending" && matchesActiveTurn state, event { return { state: { conversationId: state.conversationId, mode: "ai ready", epoch: state.epoch, }, effects: , }; } if event.type === "BOT DELIVERY UNKNOWN" && state.mode === "bot sending" && matchesActiveTurn state, event { return { state: { ...state, mode: "delivery unknown" }, effects: { type: "RECONCILE DELIVERY", epoch: event.epoch, turnId: event.turnId, clientMessageId: ai:${state.conversationId}:${event.turnId} , }, { type: "SHOW STATUS", text: "Checking message delivery…" }, , }; } if event.type === "DELIVERY RECONCILED" && state.mode === "delivery unknown" && matchesActiveTurn state, event { if event.delivered { return { state: { conversationId: state.conversationId, mode: "ai ready", epoch: state.epoch, }, effects: , }; } // Do not regenerate after an uncertain send. Move to a person instead. return requestHandoff state ; } if event.type === "HUMAN CLAIMED" && state.mode === "handoff pending" { return { state: { conversationId: state.conversationId, mode: "human active", epoch: state.epoch + 1, agentId: event.agentId, }, effects: { type: "SHOW STATUS", text: "A person joined the DM." } , }; } if event.type === "HUMAN RELEASED" && state.mode === "human active" { return { state: { conversationId: state.conversationId, mode: "consent required", epoch: state.epoch + 1, }, effects: { type: "SHOW STATUS", text: "Human assistance ended. Choose whether to use AI again.", }, , }; } // Late, duplicate, or invalid events are deliberately ignored. return { state, effects: }; } The epoch is the cancellation boundary. A model request can still finish after a handoff, but its callback no longer matches the conversation's active epoch and therefore cannot progress to moderation or delivery. This is stronger than trying to cancel an HTTP request. Cancellation is an optimization; rejecting stale results is the correctness mechanism. Create src/coordinator.test.ts : js import { describe, expect, it } from "vitest"; import { initialState, reduce } from "./coordinator"; function startAiTurn { let state = reduce initialState "dm-42" , { type: "USER OPTED IN", } .state; state = reduce state, { type: "USER MESSAGE", messageId: "msg-1", requiresHuman: false, } .state; return state; } describe "DM reply authority", = { it "rejects a model result that arrives after handoff", = { const generating = startAiTurn ; const epoch = generating.epoch; const turnId = generating.turnId ; const handedOff = reduce generating, { type: "REQUEST HUMAN", } .state; const late = reduce handedOff, { type: "AI DRAFTED", epoch, turnId, text: "This must never be sent.", } ; expect late.state.mode .toBe "handoff pending" ; expect late.effects .toEqual ; } ; it "does not send a draft before moderation", = { const generating = startAiTurn ; const drafted = reduce generating, { type: "AI DRAFTED", epoch: generating.epoch, turnId: generating.turnId , text: "Candidate response", } ; expect drafted.state.mode .toBe "moderating" ; expect drafted.effects 0 ?.type .toBe "MODERATE" ; expect drafted.effects.some effect = effect.type === "SEND BOT" .toBe false ; } ; it "hands off when moderation is unavailable", = { const generating = startAiTurn ; const drafted = reduce generating, { type: "AI DRAFTED", epoch: generating.epoch, turnId: generating.turnId , text: "Candidate response", } .state; const failed = reduce drafted, { type: "MODERATION REJECTED", epoch: drafted.epoch, turnId: drafted.turnId , reason: "unavailable", } ; expect failed.state.mode .toBe "handoff pending" ; expect failed.effects 0 ?.type .toBe "NOTIFY HUMANS" ; } ; it "requires new consent after the human leaves", = { let state = startAiTurn ; state = reduce state, { type: "REQUEST HUMAN" } .state; state = reduce state, { type: "HUMAN CLAIMED", agentId: "agent-7", } .state; state = reduce state, { type: "HUMAN RELEASED" } .state; expect state.mode .toBe "consent required" ; } ; it "reconciles an uncertain send instead of sending again", = { let state = startAiTurn ; state = reduce state, { type: "AI DRAFTED", epoch: state.epoch, turnId: state.turnId , text: "Approved later", } .state; state = reduce state, { type: "MODERATION APPROVED", epoch: state.epoch, turnId: state.turnId , } .state; const unknown = reduce state, { type: "BOT DELIVERY UNKNOWN", epoch: state.epoch, turnId: state.turnId , } ; expect unknown.state.mode .toBe "delivery unknown" ; expect unknown.effects 0 ?.type .toBe "RECONCILE DELIVERY" ; expect unknown.effects.some effect = effect.type === "SEND BOT" .toBe false ; } ; } ; Run the suite: npm test These tests verify policy, not model quality. That distinction matters. A fluent answer can still be invalid because it arrived after a person took ownership. The effect names above are application concepts, not claims about Tencent RTC SDK method names. Map them to the SDK and backend interfaces appropriate to your selected platform and documented integration. type MessageAuthor = "user" | "ai" | "human" | "system"; type OutgoingMessage = { conversationId: string; clientMessageId: string; author: MessageAuthor; text: string; replyToMessageId?: string; }; interface ChatPort { send message: OutgoingMessage : Promise< | { outcome: "delivered"; serverMessageId: string } | { outcome: "unknown" } | { outcome: "failed"; retryable: boolean } ; findByClientMessageId conversationId: string, clientMessageId: string, : Promise<{ delivered: boolean } ; } interface ModelPort { draft input: { conversationId: string; sourceMessageId: string; context: readonly ContextMessage ; } : Promise<{ text: string } ; } interface ModerationPort { review text: string : Promise< | { decision: "approved" } | { decision: "blocked" } | { decision: "unavailable" } ; } type ContextMessage = { messageId: string; author: "user" | "ai" | "human"; text: string; consentedForAi: boolean; }; There are three important implementation details here. Do not render every response as a generic account avatar. Store author: "ai" and author: "human" as product data, even if both are delivered into the same DM. The interface should also show transitions such as: These are not decorative status messages. They expose the authority state users otherwise have to guess. The reducer prevents two claims in one local event sequence, but two agents can still click Claim concurrently from separate devices. Persist a lease similar to: type ReplyLease = { conversationId: string; ownerType: "ai" | "human"; ownerId: string; version: number; }; The backend should update the lease only if the stored version still matches the version read by the claimant. The losing agent receives the current owner instead of silently becoming a second responder. Do not rely on a disabled button for this. UI state cannot serialize distributed claims. A handoff does not imply that every historical message should be sent to a model. Build context from an explicit policy: export function compileAiContext messages: readonly ContextMessage , maximumMessages: number, : ContextMessage { return messages .filter message = message.consentedForAi .filter message = message.author == "human" .slice -maximumMessages ; } Excluding human-authored messages by default prevents an agent's private or operational wording from automatically becoming future model context. If your product needs those messages, make that a deliberate consent and retention decision rather than an accidental consequence of loading the transcript. Do not ask the model to make every escalation decision. Use deterministic rules for conditions your product already understands, then let the model recommend handoff only inside the remaining gray area. A practical ordering is: A model recommendation should become an event for the coordinator, not an invisible transfer of control inside a prompt. This is where the “favorite AI” framing becomes useful perspective. Preference can tell you that consistency matters. It does not justify giving a model durable authority over a conversation. Identity, consent, and ownership still belong to the application. Multilingual DMs introduce another tempting shortcut: feeding translated text back into the assistant as though it were the original message. Avoid that. Store the immutable source text and treat translation as a derived view with its own language and status metadata. If the user asks for an on-demand translation, display it alongside the source rather than silently replacing the source record. Tencent RTC documents on-demand text-message translation through TUIChat, including supported content types, languages, and edition conditions. Check the current constraints in the official TUIChat message translation documentation https://trtc.io/document/60772 before enabling the control. For AI context, choose one explicit policy: Do not let a translated view quietly become new canonical evidence. Translation can alter nuance, and a human agent needs to know which text the assistant actually evaluated. A convincing happy-path demo is not enough. Reproduce these cases while recording the state, epoch, turn ID, client message ID, and visible UI status. Expected result: handoff pending immediately. unavailable from blocked . delivery unknown . clientMessageId is used for reconciliation. consent required . Before connecting the workflow to production DMs, verify: The broader lesson is that users do not merely develop a preference for an AI's prose. They develop expectations about who is listening, who is responding, and what happens when automation reaches its limit. A reliable DM assistant earns that predictability through visible state and narrow authority—not through a more convincing personality. Disclosure: I have a content relationship with Tencent RTC. I used the official Tencent RTC Social Messaging and TUIChat documentation as implementation references for this article. Where would you revoke AI reply authority in your own messaging product: only when the user requests a person, or also after uncertainty such as moderation downtime and unknown message delivery?