{"slug": "pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion", "title": "Pin the Prompt: Safe Prompt Releases for a Tencent RTC Voice Companion", "summary": "A developer detailed a TypeScript-based prompt-release service for Tencent RTC's Conversational AI voice companion, emphasizing the need for versioned prompts and application-level enforcement of safety and timing controls. The tutorial demonstrates a lifecycle for prompt releases, from draft to active, with digest-based integrity checks and human approval gates.", "body_md": "A voice companion prompt can be edited in seconds. That does not make it a harmless change.\n\nA small wording adjustment can alter how the companion handles hesitation, recovery, or uncertainty. If the prompt changes during an active conversation, two consecutive turns may follow different instructions even though the user never changed sessions.\n\nThat creates an uncomfortable engineering tension: AI makes iteration faster, but faster editing does not remove the need for release discipline. The durable skill is not writing the cleverest prompt. It is deciding which behavior can be suggested by a prompt, which behavior must be enforced by application code, and how a human approves the change.\n\nIn this tutorial, we will build a local TypeScript prompt-release service for a Tencent RTC Conversational AI voice companion. It will:\n\nThe example is deliberately independent of a particular model SDK. Tencent RTC's [Large Language Model configuration](https://trtc.io/document/68338) documents connections to OpenAI-compatible models and agent platforms such as Dify or Coze, including request identifiers for routing and observability. We will put that integration behind a port so the release logic remains testable locally.\n\nBefore writing code, define what a prompt is—and is not—allowed to control.\n\n| Concern | Owner | Reason |\n|---|---|---|\n| Tone, brevity, and conversational style | Versioned prompt | These are model instructions and can be evaluated as response behavior. |\n| Which prompt release a session uses | Application state | A model cannot reliably know whether a deployment changed. |\n| Whether a late answer may be played | Application state | Interruption and cancellation are timing facts, not language tasks. |\n| Provider routing | Configuration and adapter | Routing must remain observable and reviewable. |\n| Safety, consent, mute, and stop controls | Deterministic application policy | A prompt is not an authorization boundary. |\n\nThis separation matters in real-time voice. The prompt may ask the model to be patient, but application code decides whether the resulting audio is still eligible to be spoken.\n\nUse Node.js 20 or later:\n\n```\nmkdir pinned-voice-prompts\ncd pinned-voice-prompts\nnpm init -y\nnpm install --save-dev typescript tsx @types/node\nmkdir src\n```\n\nUpdate `package.json`\n\n:\n\n```\n{\n  \"type\": \"module\",\n  \"scripts\": {\n    \"demo\": \"tsx src/index.ts\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"^20.0.0\",\n    \"tsx\": \"^4.0.0\",\n    \"typescript\": \"^5.0.0\"\n  }\n}\n```\n\nThe demonstration uses a scripted model rather than a paid provider. That lets us reproduce activation and interruption races without a microphone, network, or model account.\n\nCreate `src/index.ts`\n\nand begin with the release data model:\n\n``` python\nimport assert from \"node:assert/strict\";\nimport { createHash, randomUUID } from \"node:crypto\";\n\ntype ReleaseState =\n  | \"draft\"\n  | \"candidate\"\n  | \"approved\"\n  | \"active\"\n  | \"retired\";\n\ntype EvaluationReport = {\n  passed: boolean;\n  fixtureIds: string[];\n  failures: string[];\n};\n\ntype PromptRelease = {\n  id: string;\n  version: string;\n  state: ReleaseState;\n  instructions: string;\n  digest: string;\n  author: string;\n  evaluation?: EvaluationReport;\n  approvedBy?: string;\n};\n\nfunction digest(text: string): string {\n  return createHash(\"sha256\").update(text).digest(\"hex\");\n}\n```\n\nThe digest makes the reviewed artifact identifiable. A version label alone is insufficient because someone could accidentally reuse a label with different content.\n\nNext, implement valid lifecycle transitions:\n\n```\nclass PromptRegistry {\n  private releases = new Map<string, PromptRelease>();\n  private activeId?: string;\n\n  create(version: string, instructions: string, author: string) {\n    const release: PromptRelease = {\n      id: randomUUID(),\n      version,\n      state: \"draft\",\n      instructions,\n      digest: digest(instructions),\n      author\n    };\n\n    this.releases.set(release.id, release);\n    return structuredClone(release);\n  }\n\n  markCandidate(id: string, report: EvaluationReport) {\n    const release = this.require(id);\n    if (release.state !== \"draft\") {\n      throw new Error(`candidate transition rejected from ${release.state}`);\n    }\n    if (!report.passed) {\n      throw new Error(`evaluation failed: ${report.failures.join(\"; \")}`);\n    }\n\n    release.evaluation = report;\n    release.state = \"candidate\";\n    return structuredClone(release);\n  }\n\n  approve(id: string, reviewer: string) {\n    const release = this.require(id);\n    if (release.state !== \"candidate\") {\n      throw new Error(`approval rejected from ${release.state}`);\n    }\n    if (release.author === reviewer) {\n      throw new Error(\"author cannot approve their own prompt release\");\n    }\n\n    release.approvedBy = reviewer;\n    release.state = \"approved\";\n    return structuredClone(release);\n  }\n\n  activate(id: string, expectedActiveId?: string) {\n    if (this.activeId !== expectedActiveId) {\n      throw new Error(\"activation conflict: active release changed\");\n    }\n\n    const next = this.require(id);\n    if (next.state !== \"approved\") {\n      throw new Error(`activation rejected from ${next.state}`);\n    }\n\n    if (this.activeId) {\n      this.require(this.activeId).state = \"retired\";\n    }\n\n    next.state = \"active\";\n    this.activeId = next.id;\n    return structuredClone(next);\n  }\n\n  current() {\n    if (!this.activeId) throw new Error(\"no active prompt release\");\n    return structuredClone(this.require(this.activeId));\n  }\n\n  get(id: string) {\n    return structuredClone(this.require(id));\n  }\n\n  private require(id: string) {\n    const release = this.releases.get(id);\n    if (!release) throw new Error(`unknown release ${id}`);\n    return release;\n  }\n}\n```\n\n`activate`\n\nuses a compare-and-set style precondition. If two operators attempt to activate different releases concurrently, the second operation fails instead of silently overwriting the first.\n\nA retired release remains readable because an existing session may still be pinned to it. “Retired” means “not assigned to new sessions,” not “erase the evidence.”\n\nAn AI reviewer can find issues, but it cannot own the product decision. Models often agree with the framing they receive, and a passing score does not prove that every live utterance will be safe or useful.\n\nUse fixtures as release evidence rather than authorization:\n\n```\ntype CompletionRequest = {\n  systemPrompt: string;\n  userText: string;\n  traceId: string;\n};\n\ntype ModelPort = {\n  complete(request: CompletionRequest): Promise<string>;\n};\n\ntype Fixture = {\n  id: string;\n  userText: string;\n  mustMatch: RegExp;\n  mustNotMatch: RegExp;\n};\n\nasync function evaluatePrompt(\n  instructions: string,\n  model: ModelPort,\n  fixtures: Fixture[]\n): Promise<EvaluationReport> {\n  const failures: string[] = [];\n\n  for (const fixture of fixtures) {\n    const output = await model.complete({\n      systemPrompt: instructions,\n      userText: fixture.userText,\n      traceId: `evaluation:${fixture.id}`\n    });\n\n    if (!fixture.mustMatch.test(output)) {\n      failures.push(`${fixture.id}: required behavior was absent`);\n    }\n    if (fixture.mustNotMatch.test(output)) {\n      failures.push(`${fixture.id}: forbidden behavior was present`);\n    }\n  }\n\n  return {\n    passed: failures.length === 0,\n    fixtureIds: fixtures.map((fixture) => fixture.id),\n    failures\n  };\n}\n\nconst scriptedModel: ModelPort = {\n  async complete(request) {\n    const patientInstruction = request.systemPrompt.includes(\n      \"Do not pressure a user who asks for time\"\n    );\n\n    if (request.userText === \"I need a moment to think.\") {\n      return patientInstruction\n        ? \"Of course. Take your time.\"\n        : \"Are you ready to continue now?\";\n    }\n\n    return \"I understand.\";\n  }\n};\n```\n\nThis scripted model does **not** demonstrate that a production LLM will always follow the prompt. It verifies that the release pipeline can collect fixture evidence and block a known failure. In staging, replace `scriptedModel`\n\nwith the same application-owned adapter used for your configured model route, then retain the model, route, prompt digest, and request identifiers with the report.\n\nPer-turn prompt lookup seems convenient, but it lets an activation change a companion's behavior halfway through a conversation. Instead, snapshot the active release at session admission.\n\nAdd these types below the previous code:\n\n```\ntype SessionState =\n  | \"listening\"\n  | \"thinking\"\n  | \"speaking\"\n  | \"recovering\"\n  | \"closed\";\n\ntype ActiveTurn = {\n  id: string;\n  ordinal: number;\n  traceId: string;\n  promptReleaseId: string;\n};\n\ntype VoiceSession = {\n  id: string;\n  state: SessionState;\n  promptReleaseId: string;\n  nextOrdinal: number;\n  activeTurn?: ActiveTurn;\n};\n\nfunction openSession(registry: PromptRegistry): VoiceSession {\n  const release = registry.current();\n  return {\n    id: randomUUID(),\n    state: \"listening\",\n    promptReleaseId: release.id,\n    nextOrdinal: 1\n  };\n}\n\nfunction beginTurn(session: VoiceSession): ActiveTurn {\n  if (session.state !== \"listening\" && session.state !== \"recovering\") {\n    throw new Error(`cannot begin a turn while ${session.state}`);\n  }\n\n  const turn: ActiveTurn = {\n    id: randomUUID(),\n    ordinal: session.nextOrdinal++,\n    traceId: randomUUID(),\n    promptReleaseId: session.promptReleaseId\n  };\n\n  session.activeTurn = turn;\n  session.state = \"thinking\";\n  return structuredClone(turn);\n}\n\nfunction interrupt(session: VoiceSession) {\n  if (session.state === \"thinking\" || session.state === \"speaking\") {\n    session.activeTurn = undefined;\n    session.state = \"listening\";\n  }\n}\n\nfunction admitResponse(\n  session: VoiceSession,\n  turn: ActiveTurn,\n  responsePromptReleaseId: string\n): boolean {\n  const current = session.activeTurn;\n\n  if (session.state !== \"thinking\") return false;\n  if (!current || current.id !== turn.id) return false;\n  if (responsePromptReleaseId !== session.promptReleaseId) return false;\n  if (turn.promptReleaseId !== session.promptReleaseId) return false;\n\n  session.state = \"speaking\";\n  return true;\n}\n\nfunction finishSpeaking(session: VoiceSession) {\n  if (session.state !== \"speaking\") {\n    throw new Error(`cannot finish speech while ${session.state}`);\n  }\n  session.activeTurn = undefined;\n  session.state = \"listening\";\n}\n```\n\nNotice that `interrupt`\n\ninvalidates the active turn immediately. Cancelling an upstream HTTP request or speech-synthesis operation is still worthwhile, but cancellation is only resource management. The admission check is what prevents a late completion from becoming audible.\n\nFinish `src/index.ts`\n\nwith a complete scenario:\n\n```\nasync function prepareRelease(\n  registry: PromptRegistry,\n  version: string,\n  instructions: string,\n  author: string,\n  reviewer: string,\n  fixtures: Fixture[]\n) {\n  const draft = registry.create(version, instructions, author);\n  const report = await evaluatePrompt(instructions, scriptedModel, fixtures);\n  registry.markCandidate(draft.id, report);\n  registry.approve(draft.id, reviewer);\n  return draft.id;\n}\n\nconst fixtures: Fixture[] = [\n  {\n    id: \"give-user-time\",\n    userText: \"I need a moment to think.\",\n    mustMatch: /take your time/i,\n    mustNotMatch: /ready.*now/i\n  }\n];\n\nconst basePrompt = `\nYou are a concise voice companion.\n[TURN-TAKING]\nDo not pressure a user who asks for time.\n[RECOVERY]\nAcknowledge uncertainty rather than inventing an answer.\n`.trim();\n\nconst updatedPrompt = `\nYou are a warm, concise voice companion.\n[TURN-TAKING]\nDo not pressure a user who asks for time.\n[RECOVERY]\nAcknowledge uncertainty and offer one next step.\n`.trim();\n\nasync function main() {\n  const registry = new PromptRegistry();\n\n  const v1Id = await prepareRelease(\n    registry,\n    \"1.0.0\",\n    basePrompt,\n    \"prompt-author\",\n    \"conversation-reviewer\",\n    fixtures\n  );\n  registry.activate(v1Id, undefined);\n\n  const existingSession = openSession(registry);\n  assert.equal(existingSession.promptReleaseId, v1Id);\n\n  const v2Id = await prepareRelease(\n    registry,\n    \"1.1.0\",\n    updatedPrompt,\n    \"prompt-author\",\n    \"conversation-reviewer\",\n    fixtures\n  );\n  registry.activate(v2Id, v1Id);\n\n  // Existing conversations keep v1; new conversations receive v2.\n  assert.equal(existingSession.promptReleaseId, v1Id);\n  const newSession = openSession(registry);\n  assert.equal(newSession.promptReleaseId, v2Id);\n\n  // A completion arriving after an interruption must not be spoken.\n  const interruptedTurn = beginTurn(existingSession);\n  interrupt(existingSession);\n  assert.equal(\n    admitResponse(existingSession, interruptedTurn, v1Id),\n    false\n  );\n\n  // A current response with the pinned prompt can be admitted.\n  const currentTurn = beginTurn(existingSession);\n  assert.equal(admitResponse(existingSession, currentTurn, v1Id), true);\n  finishSpeaking(existingSession);\n\n  // A response labeled with the newly active prompt is invalid for this session.\n  const mismatchedTurn = beginTurn(existingSession);\n  assert.equal(admitResponse(existingSession, mismatchedTurn, v2Id), false);\n\n  console.log(\"Verified prompt pinning, activation, and stale-response rejection.\");\n}\n\nawait main();\n```\n\nRun it:\n\n```\nnpm run demo\n```\n\nExpected output:\n\n```\nVerified prompt pinning, activation, and stale-response rejection.\n```\n\nYou have now reproduced three important properties without relying on callback timing:\n\nTencent RTC's [Conversational AI overview](https://trtc.io/document/conversational-ai-overview?product=conversationalai) describes real-time voice interaction with multiple LLM providers and cross-platform integration. Keep the responsibilities visible when connecting the local core:\n\n``` php\nUser audio\n  -> RTC/media transport\n  -> speech recognition\n  -> application turn coordinator\n  -> pinned prompt + configured LLM route\n  -> application response admission\n  -> speech synthesis\n  -> RTC/media transport\n  -> user audio\n```\n\nDo not collapse these components into a single “AI” box. Each one fails differently.\n\nAt session creation:\n\nFor each recognized user turn:\n\n`turn.id`\n\nand `traceId`\n\n.`admitResponse`\n\nbefore requesting speech synthesis.The exact callback and configuration field names depend on the integration you choose, so map documented Tencent RTC and provider events into these domain operations rather than inventing a universal callback interface.\n\nFor companion and character-dialogue product context, Tencent RTC also describes AI virtual companions in its [Social Entertainment solution](https://trtc.io/solutions/social-entertainment). That scenario makes session consistency especially important: users perceive unexplained personality or boundary changes as part of the relationship, not merely as a deployment detail.\n\nSession pinning is a default, not a universal law.\n\n| Situation | Recommended scope | Trade-off |\n|---|---|---|\n| Tone or persona adjustment | Pin for the session | Existing users receive consistent behavior but adopt the update later. |\n| New model route under evaluation | Pin route and prompt together | Easier investigation, but rollback affects new sessions first. |\n| Typo with no behavioral effect | Usually next session | Avoids unnecessary live mutation. |\n| Safety or authorization defect | Fix deterministic policy immediately | Do not wait for a prompt rollout to enforce a hard boundary. |\n| Long-running session | Offer an explicit restart or migration notice | Prevents indefinite use of an old release without silently switching it. |\n\nIf a change is urgent enough to override active sessions, treat migration as its own state transition. Record the old release, new release, reason, operator, and user-visible effect. Do not make “always fetch latest prompt” your emergency mechanism.\n\nAutomated fixtures only cover cases you wrote down. A model-based reviewer can also produce a confident but weak assessment.\n\n**Response:** keep approval human-owned, show reviewers the exact prompt diff and failed/passed fixture outputs, and expand fixtures after incidents. Never translate an evaluator score directly into activation.\n\nWithout an expected-current value, the last write wins and the effective release may differ from the operator's review screen.\n\n**Response:** use transactional storage or compare-and-set semantics around the active release pointer. The in-memory `expectedActiveId`\n\ncheck demonstrates the invariant; production storage must enforce it atomically.\n\nA response may differ because the model route changed, not because the prompt changed.\n\n**Response:** record the intended route and observed request identifiers with each turn. If fallback is permitted, define it as an explicit configured route and expose it in diagnostics. If it is not permitted, enter a visible recovery state instead of pretending the original route answered.\n\nThe network request may be impossible to cancel, or cancellation may arrive too late.\n\n**Response:** invalidate the turn synchronously and reject the completion at admission. Do not depend on cancellation success.\n\nDeleting retired prompt text makes the existing session unreproducible and can break its next turn.\n\n**Response:** make retired releases immutable and readable. Apply a retention policy only after considering maximum session duration, investigation needs, and privacy requirements.\n\nA voice interface cannot hide a timeout behind a spinner.\n\n**Response:** move the session into a visible recovery state, stop waiting audio, and offer deterministic choices such as retry, continue without the AI feature, or exit. Do not send repeated hidden retries that could later produce several spoken answers.\n\nBefore activating a prompt release, verify:\n\nAI can help generate prompt variants, propose fixtures, cluster failed conversations, or review a diff. Those are demonstrated workflow accelerators, not proof that a prompt is ready to speak to users.\n\nThe human decision remains: *Is this behavior acceptable for this product and this relationship with the user?* Release state, pinned configuration, traceable requests, and deterministic speech admission make that decision reviewable instead of burying it inside a text box.\n\nThat reframes the maintenance concern. Prompt iteration is not “less engineering.” It is configuration engineering with unusually visible behavioral consequences.\n\n**Relationship disclosure:** I am connected with Tencent RTC, and I used the official Tencent RTC documentation linked above as the implementation reference for this article.", "url": "https://wpnews.pro/news/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion", "canonical_source": "https://dev.to/susiewang/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion-4n5b", "published_at": "2026-08-28 04:13:11+00:00", "updated_at": "2026-08-28 04:48:25.539289+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-products"], "entities": ["Tencent RTC", "TypeScript", "Dify", "Coze"], "alternates": {"html": "https://wpnews.pro/news/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion", "markdown": "https://wpnews.pro/news/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion.md", "text": "https://wpnews.pro/news/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion.txt", "jsonld": "https://wpnews.pro/news/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion.jsonld"}}