DEV Community

Build a Two-Phase Tool Boundary for a Tencent RTC Voice Companion

A voice companion becomes genuinely useful when it can do something: post a room message, add an item to a queue, update a profile, or call another service. That is also where a convincing demo can become an unreliable product. Suppose a user says: Tell the room I’ll leave at eight-actually, don’t send that. The model may have produced the correct tool arguments before the interruption arrived. If tool execution is coupled directly to model output, the message can be published while the companion is still saying, β€œSure.” A better prompt might reduce the frequency, but it cannot create a transaction boundary. The uncomfortable engineering reality is not that you are β€œbad at prompting.” The system is missing an enforceable state between the model proposing an action and the application committing it. In this tutorial, we will build that boundary for a Tencent RTC conversational AI scenario. The companion may use an OpenAI-compatible model or an agent platform such as Dify, but neither provider receives direct authority to commit the action. The invariant we want Our application will enforce one rule: Model output may prepare an action, but only a fresh, explicit user confirmation may commit it. The resulting path is: RTC audio -> speech recognition -> application turn coordinator -> LLM or Dify -> validated action proposal -> server-side prepared ticket -> spoken preview -> explicit user confirmation -> permission recheck -> idempotent commit Tencent RTC documents its Conversational AI scenario as real-time voice interaction that can connect with multiple LLM providers. Its LLM configuration documentation covers OpenAI-compatible models and agent platforms including Dify, as well as request identifiers useful for routing and observability: The media, speech, model, and application authorization layers remain distinct. The code below lives in the application orchestration layer; it does not invent a new RTC or messaging API. Decide which tools need this boundary Not every model operation needs spoken confirmation. Classify each application-defined tool before exposing it to the model: | Tool effect | Example | Default policy | |---|---|---| | Read-only | Search a public catalog | Allow with normal validation | | Local and reversible | Change the companion’s temporary voice style | Allow or provide Undo | | Shared and reversible | Add a track to a room queue | Confirm when social impact is meaningful | | External or audience-visible | Publish a room message | Require explicit confirmation | | Sensitive or difficult to reverse | Purchase, delete, invite, disclose private data | Strong confirmation or do not expose to the agent | This tutorial uses an application-defined publishRoomMessage action. Tencent RTC’s social entertainment material includes AI companions, voice rooms, communities, and character dialogue as relevant scenarios, but the tool and authorization policy remain ours: Social Entertainment solution. Create the TypeScript project mkdir voice-action-boundary cd voice-action-boundary npm init -y npm install --save-dev typescript tsx @types/node npx tsc --init mkdir src Add scripts to package.json : { "scripts": { "start": "tsx src/demo.ts", "test": "tsx --test src/*.test.ts" } } Start with explicit action state Do not represent the whole interaction with booleans such as isLoading , isTalking , and isConfirmed . Their invalid combinations multiply quickly. // src/types.ts export type MessageProposal = { kind: 'publishRoomMessage'; roomId: string; text: string; }; export type PreparedAction = { ticket: string; turnId: string; proposal: MessageProposal; preview: string; expiresAt: number; }; export type VoiceActionState = | { kind: 'idle' } | { kind: 'requesting-model'; turnId: string } | { kind: 'previewing'; action: PreparedAction } | { kind: 'awaiting-confirmation'; action: PreparedAction } | { kind: 'executing'; action: PreparedAction } | { kind: 'completed'; turnId: string } | { kind: 'recovery'; turnId: string; reason: 'expired' | 'denied' | 'provider-error' | 'commit-uncertain'; }; This union makes several forbidden states unrepresentable. An action cannot simultaneously be awaiting confirmation and completed, for example. The ticket is important. It identifies one prepared action rather than giving the model a reusable tool credential. Validate proposals outside the model A prompt can tell the model to return JSON, but the application still has to treat that JSON as untrusted input. // src/proposal.ts import type { MessageProposal } from './types.js'; export function parseProposal(value: unknown): MessageProposal { if (!value || typeof value !== 'object') { throw new Error('Proposal must be an object'); } const candidate = value as Record ; if (candidate.kind !== 'publishRoomMessage') { throw new Error('Unsupported action kind'); } if (typeof candidate.roomId !== 'string' || !candidate.roomId.trim()) { throw new Error('Invalid room ID'); } if (typeof candidate.text !== 'string') { throw new Error('Message text is required'); } const text = candidate.text.trim(); if (text.length === 0 || text.length > 280) { throw new Error('Message must contain between 1 and 280 characters'); } return { kind: 'publishRoomMessage', roomId: candidate.roomId, text }; } The room ID should not normally come from model imagination. Compare it with trusted session context before preparing the action. Put the commit capability behind a broker The broker issues short-lived tickets, rechecks permissions at commit time, and prevents the same ticket from being committed concurrently. // src/broker.ts import { randomUUID } from 'node:crypto'; import type { MessageProposal, PreparedAction } from './types.js'; type SessionContext = { userId: string; roomId: string; }; type RecordState = | 'prepared' | 'executing' | 'committed' | 'cancelled' | 'uncertain'; type StoredAction = { ownerId: string; action: PreparedAction; state: RecordState; }; export interface RoomPublisher { publish( roomId: string, text: string, options: { idempotencyKey: string } ): Promise ; } export class ActionBroker { private records = new Map (); constructor( private readonly publisher: RoomPublisher, private readonly canPublish: (context: SessionContext) => Promise , private readonly now: () => number = Date.now ) {} prepare( proposal: MessageProposal, turnId: string, context: SessionContext ): PreparedAction { if (proposal.roomId !== context.roomId) { throw new Error('Proposal targeted a different room'); } const ticket = randomUUID(); const action: PreparedAction = { ticket, turnId, proposal, preview: Post this message to the room: ${proposal.text}, expiresAt: this.now() + 30_000 }; this.records.set(ticket, { ownerId: context.userId, action, state: 'prepared' }); return action; } cancel(ticket: string): void { const record = this.records.get(ticket); if (record?.state === 'prepared') record.state = 'cancelled'; } async commit(ticket: string, context: SessionContext): Promise { const record = this.records.get(ticket); if (!record) throw new Error('Unknown action ticket'); if (record.ownerId !== context.userId) { throw new Error('Ticket belongs to another user'); } if (record.action.proposal.roomId !== context.roomId) { throw new Error('Room context changed'); } if (record.state === 'committed') return; if (record.state !== 'prepared') { throw new Error(Action cannot commit from ${record.state}); } if (this.now() >= record.action.expiresAt) { record.state = 'cancelled'; throw new Error('Action ticket expired'); } if (!(await this.canPublish(context))) { record.state = 'cancelled'; throw new Error('Permission denied at commit time'); } record.state = 'executing'; try { await this.publisher.publish( context.roomId, record.action.proposal.text, { idempotencyKey: ticket } ); record.state = 'committed'; } catch (error) { record.state = 'uncertain'; throw error; } } } The Map keeps the tutorial easy to run. In production, prepared tickets and transitions should use durable storage or another atomic coordination mechanism. Otherwise, a process restart can erase whether an external operation succeeded. The publishing adapter must also honor the idempotency key if automatic retries are allowed. If the downstream service times out and provides no idempotency guarantee, the correct state is uncertain, not β€œfailed.” Retrying blindly could publish twice. Keep confirmation deliberately boring Do not ask the LLM whether the user confirmed its own proposal. Use a narrow recognizer for the authorization event. // src/confirmation.ts export type Confirmation = 'yes' | 'no' | 'ambiguous'; export function classifyConfirmation(transcript: string): Confirmation { const normalized = transcript .toLowerCase() .replace(/[^a-z\s]/g, '') .replace(/\s+/g, ' ') .trim(); if (['yes', 'yes post it', 'confirm', 'send it'].includes(normalized)) { return 'yes'; } if (['no', 'cancel', 'dont send it', 'do not send it'].includes(normalized)) { return 'no'; } return 'ambiguous'; } A strict vocabulary adds conversational friction, especially when speech recognition is uncertain. That is an intentional trade-off for consequential actions. A visible Confirm/Cancel control is a useful fallback and should feed the same state machine rather than bypassing it. Coordinate preview, interruption, and commit The voice controller receives transcripts and speech lifecycle events from adapters. Exact SDK wiring depends on the client platform, so the interfaces below mark the integration seams without inventing product API names. // src/controller.ts import { classifyConfirmation } from './confirmation.js'; import type { ActionBroker } from './broker.js'; import type { PreparedAction, VoiceActionState } from './types.js'; type Context = { userId: string; roomId: string }; type SpeechOutput = { speak(text: string): Promise ; stop(): void; }; export class VoiceActionController { private state: VoiceActionState = { kind: 'idle' }; constructor( private readonly broker: ActionBroker, private readonly speech: S

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.