One Model Call, Then Deterministic Code: Build a Controllable Tencent RTC Voice Companion A developer built a controllable voice companion using Tencent RTC's Conversational AI, replacing an autonomous agent loop with deterministic state-machine control to avoid unintended actions. The architecture separates LLM text generation from exact decision-making, using a confirmation state and idempotent action execution for a focus timer example. A voice companion creates an uncomfortable engineering tension: users expect it to feel flexible, but they also expect a spoken “maybe” not to become an action. An autonomous agent loop can make a compelling demo. In a real-time conversation, however, every extra planning step adds another place where the response can become stale, fail, or choose an action the user did not intend. Replacing that loop with deterministic control is not an admission that the AI is fake. It is a decision about where uncertainty is useful. This tutorial builds a narrower architecture: The example action is intentionally modest: setting a local focus timer. The same control pattern can sit in front of higher-impact operations, but those would need their own authorization, reconciliation, and audit policies. Tencent RTC’s Conversational AI scenario supports real-time voice interaction with LLM providers. Its LLM configuration documentation describes connecting OpenAI-compatible models and agent platforms such as Dify or Coze, including request identifiers that can be used for routing and observability: Tencent RTC’s Social Entertainment solution https://trtc.io/solutions/social-entertainment also identifies AI virtual companions and character dialogue as relevant experience patterns. Those capabilities do not decide how much authority your model should receive. Keep these layers conceptually separate: microphone / RTC media ↓ speech recognition ↓ application turn controller ← user interruption ↓ LLM route ← generates text or a proposal ↓ application policy ← validates and requests confirmation ↓ action executor ← performs an idempotent side effect ↓ speech synthesis / playback The model is useful where language is fuzzy. It is deliberately excluded from decisions that should be exact: whether “not yet” means yes, whether an expired proposal remains valid, and whether an uncertain operation should be repeated. Our companion understands two model outputs: type ModelDecision = | { type: "reply"; text: string } | { type: "propose"; text: string; action: { kind: "set timer"; minutes: number }; }; A proposal is not an action. It moves the conversation into a confirmation state. | Current condition | Input | Result | |---|---|---| | Waiting for model | Matching model result | Reply or request confirmation | | Waiting for model | Old request result | Ignore it | | Waiting for confirmation | yes , confirm , or do it | Execute once | | Waiting for confirmation | no or cancel | Discard proposal | | Waiting for confirmation | Ambiguous phrase | Ask for yes or no | | Waiting for confirmation | Deadline passes | Expire proposal | | Executing | Duplicate confirmation | Do not execute again | | Executing | Outcome unknown | Stop and request reconciliation | This table is the real orchestration policy. The prompt helps the model fit into it, but the prompt does not enforce it. The controller has no microphone or vendor dependency, so races can be reproduced from ordinary tests. mkdir controlled-voice-companion cd controlled-voice-companion npm init -y npm install --save-dev typescript tsx @types/node mkdir src Update package.json : { "type": "module", "scripts": { "test": "tsx --test src/core.test.ts", "check": "tsc --noEmit" }, "devDependencies": { "@types/node": "latest", "tsx": "latest", "typescript": "latest" } } Add tsconfig.json : { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noUncheckedIndexedAccess": true }, "include": "src" } Create src/core.ts : export type Action = { kind: "set timer"; minutes: number; }; export type DialogState = | { phase: "idle" } | { phase: "waiting model"; turnId: string; requestId: string } | { phase: "waiting confirmation"; turnId: string; action: Action; expiresAt: number; } | { phase: "executing"; turnId: string; action: Action; executionKey: string; } | { phase: "reconciliation required"; executionKey: string }; export type SessionState = { dialog: DialogState; output: null | { speechId: string; text: string }; }; export type Effect = | { type: "call model"; turnId: string; requestId: string; transcript: string; } | { type: "speak"; speechId: string; text: string } | { type: "cancel speech"; speechId: string } | { type: "execute"; executionKey: string; action: Action }; const id = = crypto.randomUUID ; function parseDecision raw: string : | { type: "reply"; text: string } | { type: "propose"; text: string; action: Action } | null { try { const value: unknown = JSON.parse raw ; if value || typeof value == "object" return null; const record = value as Record