cd /news/ai-agents/when-an-ai-leaves-the-dm-make-the-ha… · home topics ai-agents article
[ARTICLE · art-127091] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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.

by read12 min views9 publishedSep 11, 2026

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.

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:

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 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 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?

── more in #ai-agents 4 stories · sorted by recency
── more on @tencent rtc 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/when-an-ai-leaves-th…] indexed:0 read:12min 2026-09-11 ·