# Pin the Prompt: Safe Prompt Releases for a Tencent RTC Voice Companion

> Source: <https://dev.to/susiewang/pin-the-prompt-safe-prompt-releases-for-a-tencent-rtc-voice-companion-4n5b>
> Published: 2026-08-28 04:13:11+00:00

A voice companion prompt can be edited in seconds. That does not make it a harmless change.

A 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.

That 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.

In this tutorial, we will build a local TypeScript prompt-release service for a Tencent RTC Conversational AI voice companion. It will:

The 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.

Before writing code, define what a prompt is—and is not—allowed to control.

| Concern | Owner | Reason |
|---|---|---|
| Tone, brevity, and conversational style | Versioned prompt | These are model instructions and can be evaluated as response behavior. |
| Which prompt release a session uses | Application state | A model cannot reliably know whether a deployment changed. |
| Whether a late answer may be played | Application state | Interruption and cancellation are timing facts, not language tasks. |
| Provider routing | Configuration and adapter | Routing must remain observable and reviewable. |
| Safety, consent, mute, and stop controls | Deterministic application policy | A prompt is not an authorization boundary. |

This 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.

Use Node.js 20 or later:

```
mkdir pinned-voice-prompts
cd pinned-voice-prompts
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
```

Update `package.json`

:

```
{
  "type": "module",
  "scripts": {
    "demo": "tsx src/index.ts"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.0.0"
  }
}
```

The 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.

Create `src/index.ts`

and begin with the release data model:

``` python
import assert from "node:assert/strict";
import { createHash, randomUUID } from "node:crypto";

type ReleaseState =
  | "draft"
  | "candidate"
  | "approved"
  | "active"
  | "retired";

type EvaluationReport = {
  passed: boolean;
  fixtureIds: string[];
  failures: string[];
};

type PromptRelease = {
  id: string;
  version: string;
  state: ReleaseState;
  instructions: string;
  digest: string;
  author: string;
  evaluation?: EvaluationReport;
  approvedBy?: string;
};

function digest(text: string): string {
  return createHash("sha256").update(text).digest("hex");
}
```

The digest makes the reviewed artifact identifiable. A version label alone is insufficient because someone could accidentally reuse a label with different content.

Next, implement valid lifecycle transitions:

```
class PromptRegistry {
  private releases = new Map<string, PromptRelease>();
  private activeId?: string;

  create(version: string, instructions: string, author: string) {
    const release: PromptRelease = {
      id: randomUUID(),
      version,
      state: "draft",
      instructions,
      digest: digest(instructions),
      author
    };

    this.releases.set(release.id, release);
    return structuredClone(release);
  }

  markCandidate(id: string, report: EvaluationReport) {
    const release = this.require(id);
    if (release.state !== "draft") {
      throw new Error(`candidate transition rejected from ${release.state}`);
    }
    if (!report.passed) {
      throw new Error(`evaluation failed: ${report.failures.join("; ")}`);
    }

    release.evaluation = report;
    release.state = "candidate";
    return structuredClone(release);
  }

  approve(id: string, reviewer: string) {
    const release = this.require(id);
    if (release.state !== "candidate") {
      throw new Error(`approval rejected from ${release.state}`);
    }
    if (release.author === reviewer) {
      throw new Error("author cannot approve their own prompt release");
    }

    release.approvedBy = reviewer;
    release.state = "approved";
    return structuredClone(release);
  }

  activate(id: string, expectedActiveId?: string) {
    if (this.activeId !== expectedActiveId) {
      throw new Error("activation conflict: active release changed");
    }

    const next = this.require(id);
    if (next.state !== "approved") {
      throw new Error(`activation rejected from ${next.state}`);
    }

    if (this.activeId) {
      this.require(this.activeId).state = "retired";
    }

    next.state = "active";
    this.activeId = next.id;
    return structuredClone(next);
  }

  current() {
    if (!this.activeId) throw new Error("no active prompt release");
    return structuredClone(this.require(this.activeId));
  }

  get(id: string) {
    return structuredClone(this.require(id));
  }

  private require(id: string) {
    const release = this.releases.get(id);
    if (!release) throw new Error(`unknown release ${id}`);
    return release;
  }
}
```

`activate`

uses 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.

A 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.”

An 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.

Use fixtures as release evidence rather than authorization:

```
type CompletionRequest = {
  systemPrompt: string;
  userText: string;
  traceId: string;
};

type ModelPort = {
  complete(request: CompletionRequest): Promise<string>;
};

type Fixture = {
  id: string;
  userText: string;
  mustMatch: RegExp;
  mustNotMatch: RegExp;
};

async function evaluatePrompt(
  instructions: string,
  model: ModelPort,
  fixtures: Fixture[]
): Promise<EvaluationReport> {
  const failures: string[] = [];

  for (const fixture of fixtures) {
    const output = await model.complete({
      systemPrompt: instructions,
      userText: fixture.userText,
      traceId: `evaluation:${fixture.id}`
    });

    if (!fixture.mustMatch.test(output)) {
      failures.push(`${fixture.id}: required behavior was absent`);
    }
    if (fixture.mustNotMatch.test(output)) {
      failures.push(`${fixture.id}: forbidden behavior was present`);
    }
  }

  return {
    passed: failures.length === 0,
    fixtureIds: fixtures.map((fixture) => fixture.id),
    failures
  };
}

const scriptedModel: ModelPort = {
  async complete(request) {
    const patientInstruction = request.systemPrompt.includes(
      "Do not pressure a user who asks for time"
    );

    if (request.userText === "I need a moment to think.") {
      return patientInstruction
        ? "Of course. Take your time."
        : "Are you ready to continue now?";
    }

    return "I understand.";
  }
};
```

This 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`

with the same application-owned adapter used for your configured model route, then retain the model, route, prompt digest, and request identifiers with the report.

Per-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.

Add these types below the previous code:

```
type SessionState =
  | "listening"
  | "thinking"
  | "speaking"
  | "recovering"
  | "closed";

type ActiveTurn = {
  id: string;
  ordinal: number;
  traceId: string;
  promptReleaseId: string;
};

type VoiceSession = {
  id: string;
  state: SessionState;
  promptReleaseId: string;
  nextOrdinal: number;
  activeTurn?: ActiveTurn;
};

function openSession(registry: PromptRegistry): VoiceSession {
  const release = registry.current();
  return {
    id: randomUUID(),
    state: "listening",
    promptReleaseId: release.id,
    nextOrdinal: 1
  };
}

function beginTurn(session: VoiceSession): ActiveTurn {
  if (session.state !== "listening" && session.state !== "recovering") {
    throw new Error(`cannot begin a turn while ${session.state}`);
  }

  const turn: ActiveTurn = {
    id: randomUUID(),
    ordinal: session.nextOrdinal++,
    traceId: randomUUID(),
    promptReleaseId: session.promptReleaseId
  };

  session.activeTurn = turn;
  session.state = "thinking";
  return structuredClone(turn);
}

function interrupt(session: VoiceSession) {
  if (session.state === "thinking" || session.state === "speaking") {
    session.activeTurn = undefined;
    session.state = "listening";
  }
}

function admitResponse(
  session: VoiceSession,
  turn: ActiveTurn,
  responsePromptReleaseId: string
): boolean {
  const current = session.activeTurn;

  if (session.state !== "thinking") return false;
  if (!current || current.id !== turn.id) return false;
  if (responsePromptReleaseId !== session.promptReleaseId) return false;
  if (turn.promptReleaseId !== session.promptReleaseId) return false;

  session.state = "speaking";
  return true;
}

function finishSpeaking(session: VoiceSession) {
  if (session.state !== "speaking") {
    throw new Error(`cannot finish speech while ${session.state}`);
  }
  session.activeTurn = undefined;
  session.state = "listening";
}
```

Notice that `interrupt`

invalidates 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.

Finish `src/index.ts`

with a complete scenario:

```
async function prepareRelease(
  registry: PromptRegistry,
  version: string,
  instructions: string,
  author: string,
  reviewer: string,
  fixtures: Fixture[]
) {
  const draft = registry.create(version, instructions, author);
  const report = await evaluatePrompt(instructions, scriptedModel, fixtures);
  registry.markCandidate(draft.id, report);
  registry.approve(draft.id, reviewer);
  return draft.id;
}

const fixtures: Fixture[] = [
  {
    id: "give-user-time",
    userText: "I need a moment to think.",
    mustMatch: /take your time/i,
    mustNotMatch: /ready.*now/i
  }
];

const basePrompt = `
You are a concise voice companion.
[TURN-TAKING]
Do not pressure a user who asks for time.
[RECOVERY]
Acknowledge uncertainty rather than inventing an answer.
`.trim();

const updatedPrompt = `
You are a warm, concise voice companion.
[TURN-TAKING]
Do not pressure a user who asks for time.
[RECOVERY]
Acknowledge uncertainty and offer one next step.
`.trim();

async function main() {
  const registry = new PromptRegistry();

  const v1Id = await prepareRelease(
    registry,
    "1.0.0",
    basePrompt,
    "prompt-author",
    "conversation-reviewer",
    fixtures
  );
  registry.activate(v1Id, undefined);

  const existingSession = openSession(registry);
  assert.equal(existingSession.promptReleaseId, v1Id);

  const v2Id = await prepareRelease(
    registry,
    "1.1.0",
    updatedPrompt,
    "prompt-author",
    "conversation-reviewer",
    fixtures
  );
  registry.activate(v2Id, v1Id);

  // Existing conversations keep v1; new conversations receive v2.
  assert.equal(existingSession.promptReleaseId, v1Id);
  const newSession = openSession(registry);
  assert.equal(newSession.promptReleaseId, v2Id);

  // A completion arriving after an interruption must not be spoken.
  const interruptedTurn = beginTurn(existingSession);
  interrupt(existingSession);
  assert.equal(
    admitResponse(existingSession, interruptedTurn, v1Id),
    false
  );

  // A current response with the pinned prompt can be admitted.
  const currentTurn = beginTurn(existingSession);
  assert.equal(admitResponse(existingSession, currentTurn, v1Id), true);
  finishSpeaking(existingSession);

  // A response labeled with the newly active prompt is invalid for this session.
  const mismatchedTurn = beginTurn(existingSession);
  assert.equal(admitResponse(existingSession, mismatchedTurn, v2Id), false);

  console.log("Verified prompt pinning, activation, and stale-response rejection.");
}

await main();
```

Run it:

```
npm run demo
```

Expected output:

```
Verified prompt pinning, activation, and stale-response rejection.
```

You have now reproduced three important properties without relying on callback timing:

Tencent 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:

``` php
User audio
  -> RTC/media transport
  -> speech recognition
  -> application turn coordinator
  -> pinned prompt + configured LLM route
  -> application response admission
  -> speech synthesis
  -> RTC/media transport
  -> user audio
```

Do not collapse these components into a single “AI” box. Each one fails differently.

At session creation:

For each recognized user turn:

`turn.id`

and `traceId`

.`admitResponse`

before 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.

For 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.

Session pinning is a default, not a universal law.

| Situation | Recommended scope | Trade-off |
|---|---|---|
| Tone or persona adjustment | Pin for the session | Existing users receive consistent behavior but adopt the update later. |
| New model route under evaluation | Pin route and prompt together | Easier investigation, but rollback affects new sessions first. |
| Typo with no behavioral effect | Usually next session | Avoids unnecessary live mutation. |
| Safety or authorization defect | Fix deterministic policy immediately | Do not wait for a prompt rollout to enforce a hard boundary. |
| Long-running session | Offer an explicit restart or migration notice | Prevents indefinite use of an old release without silently switching it. |

If 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.

Automated fixtures only cover cases you wrote down. A model-based reviewer can also produce a confident but weak assessment.

**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.

Without an expected-current value, the last write wins and the effective release may differ from the operator's review screen.

**Response:** use transactional storage or compare-and-set semantics around the active release pointer. The in-memory `expectedActiveId`

check demonstrates the invariant; production storage must enforce it atomically.

A response may differ because the model route changed, not because the prompt changed.

**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.

The network request may be impossible to cancel, or cancellation may arrive too late.

**Response:** invalidate the turn synchronously and reject the completion at admission. Do not depend on cancellation success.

Deleting retired prompt text makes the existing session unreproducible and can break its next turn.

**Response:** make retired releases immutable and readable. Apply a retention policy only after considering maximum session duration, investigation needs, and privacy requirements.

A voice interface cannot hide a timeout behind a spinner.

**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.

Before activating a prompt release, verify:

AI 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.

The 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.

That reframes the maintenance concern. Prompt iteration is not “less engineering.” It is configuration engineering with unusually visible behavioral consequences.

**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.
