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:
- AI participation requires explicit user consent.
- Only the AI or a human agent may hold reply authority at one time.
- A handoff immediately invalidates unfinished model work.
- AI drafts pass moderation before delivery.
- Unknown delivery outcomes do not trigger blind retries.
- Returning from a human to the AI requires fresh consent.
- Every visible message has an explicit author type.
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.
Start with the invariant
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.
Create the reproducible project
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.
Prove the authority boundary
Create src/coordinator.test.ts:
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.
Put side effects behind narrow ports
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.
1. Keep authorship visible
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:
- “AI assistance is on.”
- “A person has been requested.”
- “A person joined the DM.”
- “Human assistance ended. Choose whether to use AI again.”
These are not decorative status messages. They expose the authority state users otherwise have to guess.
2. Treat human claiming as a backend compare-and-set
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.
3. Compile the smallest consented context
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.
Decide when AI loses authority
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:
- User choice: pressing “Talk to a person” always wins.
- Account and safety policy: known sensitive workflows route according to application rules.
- System health: unavailable moderation or unresolved delivery moves away from autonomous replies.
- Model recommendation: the model may suggest that the request is ambiguous or outside its supported scope.
- Human override: an authorized moderator or agent may claim the thread atomically.
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.
Translation belongs in the recipient's view
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 before enabling the control.
For AI context, choose one explicit policy:
- send the source text when the selected model supports that language;
- send a clearly labeled translation while retaining a reference to the source;
- or hand the conversation to a multilingual person.
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.
Failure drills to run in staging
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.
The user requests a person while generation is running
Expected result:
- The epoch increments.
- The UI enters
handoff_pendingimmediately. - A late AI completion produces no moderation or send effect.
- The user sees that a person was requested.
Moderation times out
Expected result:
- The unreviewed draft is never displayed as a chat message.
- The conversation moves to handoff rather than repeatedly calling the model.
- Internal logs distinguish
unavailablefromblocked. - The user receives neutral wording; do not expose sensitive moderation internals.
The send request times out after reaching the server
Expected result:
- The state becomes
delivery_unknown. - The same deterministic
clientMessageIdis used for reconciliation. - No second model response is generated.
- If delivery cannot be established, the workflow hands off instead of guessing.
Two agents claim the same DM
Expected result:
- The backend compare-and-set grants one lease.
- The losing client refreshes to show the actual owner.
- Only the lease holder can send a human-authored reply.
The human leaves the conversation
Expected result:
- The AI does not resume from the old consent automatically.
- The state returns to
consent_required. - Unfinished agent drafts remain human drafts and never enter model context by default.
A translation is displayed
Expected result:
- The original message remains available.
- The translation is visibly labeled as derived content.
- Unsupported content or language combinations fail visibly according to the documented constraints.
- The context compiler records whether source or translated text was supplied to the model.
Release checklist
Before connecting the workflow to production DMs, verify:
- [ ] AI participation has an explicit opt-in control.
- [ ] A user can request a person during every AI state.
- [ ] Every asynchronous callback carries an epoch and turn ID.
- [ ] Late callbacks cannot create moderation or send effects.
- [ ] AI drafts are moderated before becoming visible messages.
- [ ] Moderation failure has a defined non-AI path.
- [ ] AI and human messages have distinct authorship metadata.
- [ ] Human claims are serialized on the backend, not only in the UI.
- [ ] Outgoing AI messages use deterministic client message IDs.
- [ ] Unknown delivery is reconciled before any retry.
- [ ] Human release returns to consent rather than silently enabling AI.
- [ ] Only consented, policy-approved messages enter model context.
- [ ] Translation remains a derived view tied to its source.
- [ ] Logs contain state identifiers without unnecessarily copying private message text.
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.
Discussion
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?
Top comments (0)