← All posts

The prompt that produced the tells it forbade

A voice guide that asks for a register in the abstract is an instruction a model satisfies by performing the register instead of writing in it. That is where "Curious about…", "No pitch, just…" and "Quick question" come from — the guide's own words. The fix is not better wording: it is a schema refinement on the field the model fills in, a pure lint at three seams, and a retry budget of one. Release notes 2026-09-16 — vocion-core#370.

Vocion Teamvocion-v2.88.0

Status: shipped. #370, commit 30b69679, carried by v2.86.0. One migration, 0109_project_voice_rules.sql, a nullable jsonb column. Part of Vocion 2.88; the user-facing half is Outbound copy has to pass your voice rules. No live model call is exercised in any test here — the lint is pure and tested directly, the retry and failure paths are tested against scripted models.

The failure mode

The header comment in libs/writing/voiceRules.ts states the whole problem, and it is worth reading before any of the code:

A workspace's voice used to live entirely in prompt text (a playbook that says "conversational, direct, low pressure"). Nothing checked the output, and that instruction is itself what makes a model write "Curious about…" and "No pitch, just…" — the guide's own words produce the tells. Asking the model more nicely is the weakest available lever.

This is not a prompt that failed to land. It is a prompt that landed exactly as written and produced the opposite of what it wanted.

A register described in the abstract — low pressure, conversational — is an instruction with no observable check attached, and the cheapest way for a model to satisfy it is to say it. So the draft opens by announcing that it is not a pitch. The register announcement is the model demonstrating compliance with the register instruction. It is the highest-signal proof available that the instruction was read.

Here is the test that makes it concrete. The floor's register-announcement family bans no pitch, with this reason:

{ id: 'no-pitch', pattern: 'no pitch', reason: 'Announces the register instead of being it. A note that is not a pitch does not need to say so.' },

A guide asking for a note that does not read as a pitch is one token away from a note that says "no pitch, just a quick question". The phrase is in the ban list because the guide put it in the draft.

That is the argument for structure over wording. Rewriting the guide to say "and do not announce that you are not pitching" adds a second abstract instruction with no check attached, which the model can satisfy the same way — by writing a sentence about not announcing things. Every round of that game is a prompt edit with no test.

What the fix is, precisely

Not a better prompt. A refinement on the schema field the model fills in.

export function outboundCopy(rules: VoiceRules, label?: string) {
  return z.string().superRefine((value, ctx) => {
    for (const v of lintCopy(value, rules).violations) {
      if (!v.blocking) {
        continue;
      }
      ctx.addIssue({
        code: 'custom',
        message: `${label ? `${label}: ` : ''}"${v.span}" is banned — ${v.reason}`,
      });
    }
  });
}

Twelve lines, and the interesting property is where it sits. A drafting skill already answers into a typed shape. Replacing z.string() with outboundCopy(rules, 'body') on that shape:

sends: z.array(z.object({
  day: z.number().int().min(0).nullish(),
  subject: outboundCopy(rules, 'subject').min(1),
  body: outboundCopy(rules, 'body').min(1),
})).min(1).max(10).nullish(),

turns the voice from an instruction the model may drift from into a validation contract the turn runner enforces. The schema is built per organisation, because the vocabulary is workspace configuration and only the mechanism belongs in core.

The consequence is that the corrective retry already existed. runSkillTurn has always handed a validation failure back to the model; this work only changed what can produce one. A banned phrase becomes a Zod issue whose message names the span and the authored reason, and that message is what the model reads on its next attempt. No new retry loop, no new prompt-repair layer.

One adjacent fix made that readable. ZodError.message is the issues array JSON-stringified, so a hand-written message — "Curious about" is banned — Announces the register instead of being it — arrives buried under escaped quotes and path objects. Skill-turn validation errors are now flattened to path: message lines, because the corrective retry is the one place that text has to be legible to a reader that is not a human.

The retry budget is one

maxAnswerRetries?: number;

with the reasoning in the comment above it: a caller behind a voice gate should set this to 1, because "a model that writes the banned phrase again after being told the phrase and the reason is not going to find it on the fourth attempt, and burning the budget to fail anyway is just a slower failure."

maxAnswerAttempts is (opts.maxAnswerRetries ?? Number.POSITIVE_INFINITY) + 1, so the default is unchanged for every existing caller and the gate opts into the tighter budget. After it, the turn throws. Failing loudly is the point: a draft still carrying a banned phrase must never reach a review queue, and a silent third attempt that also fails is worse than a stack trace, because it is indistinguishable from success until a person reads the send.

Three seams, because a gate with a corridor around it is not a gate

lintCopy is pure and synchronous — no database, no model, no clock — which is what lets the same rules run in three different places without three implementations:

  1. The skill seam. outboundCopy on the structured answer, as above. Covers the model writing the draft.
  2. The proposal seam. personalization.enroll lints every send inside proposeAction, before any write. This is the one that matters architecturally: a gate that only wraps the model call is bypassed by the write API and by a replay. The proposal is the narrow waist every path goes through, so that is where the precheck lives.
  3. The rewrite seam. ReviewService.rewriteDraft built its own prompt from a single hardcoded sentence of generic house style and validated nothing — which meant every "Add change" in the review queue silently discarded the workspace's voice guide entirely. It now composes the rules plus the workspace-named voice playbook, lints the answer, retries once naming the failing phrases, and on a second failure returns the original with voiceError set and writes no revision row.

The prompt composition is explicit about its own status. voicePrompt.ts lists the rules to the model and then says why:

Enforced afterwards by lintCopy, so listing them in the prompt is a courtesy to the model, not the gate.

That ordering is the whole design in one sentence. The prompt is a hint that saves a retry. The lint is the contract.

The honest gap in the rewrite path

voiceError is produced and nothing reads it. Grep the repository: it is set in ReviewService.ts, asserted in ReviewService.rewriteVoice.test.ts, and consumed by no component. A reviewer whose rewrite fails the gate twice sees the draft come back unchanged with no explanation on screen.

The enforcement is real — the tell-laden copy does not reach the queue, and no revision row is written for a rewrite that failed. What is missing is the last hop to a surface. It is named here rather than implied away because a post that said "the reviewer is told why" would be describing code that does not exist.

Rule matching, and what is deliberately not in the floor

lintCopy matches case-insensitively, word-boundary aware (just curious does not fire inside adjust curiousness), whitespace-flexible across a line wrap, and agnostic about which apostrophe the model used — all four of which are things a naïve includes() gets wrong on real model output. A RegExp pattern is used as authored, with the i flag added if absent; workspace YAML reaches that by setting match: regex.

Each violation carries the offending span, its character offset, the authored reason, and whether it blocks. prefer entries report and never block.

The platform floor is 34 banned constructions plus noEmoji: true — and the comment beside it is the part worth copying:

Emoji are a register problem in outbound business email in any workspace, so the floor bans them. Exclamation points and em-dashes are NOT here: both appear in real founder correspondence, so banning them platform-wide would be taste dressed as hygiene.

A platform floor that encodes taste is a platform that has to be fought. allow: ['leverage'] lets a workspace opt out of a named floor rule, and because rules carry stable ids the exemption reads as a diff a person can review.

Edits as evidence, and why it is not a verdict

The loop closes at the one moment both versions of a draft exist — before updateActionInput replaces the proposal wholesale and the agent's words are gone for good. A word-level longest-common-subsequence diff finds the deleted runs, so a phrase cut from the middle of an otherwise-kept sentence reads as a deletion rather than as a wholesale replacement. O(n·m) on token counts, which for an email body is a few thousand cells.

Each surviving phrase goes through the existing recordProposedRule, written as a rule rather than an observation — Never write "…" in outbound copy. — because that is what a person is being asked to adopt. A candidate appears on the first deletion; a repeat of the same phrase is recognised as a duplicate and raises occurrence_count, which is what the Learnings queue renders as a count a person can read.

The constants:

const MIN_PHRASE_WORDS = 2;          // One word is noise.
const MAX_PHRASE_WORDS = 8;          // Beyond this it is a deleted sentence, not a tell.
const MAX_PHRASES_PER_DECISION = 3;  // So one wholesale rewrite cannot flood the queue.

Plus: nothing carrying digits, URLs, addresses or proper nouns; nothing the rules already flag; and the whole pass is opt-in on a workspace naming a learningStep, returning { recorded: 0, reason: 'no_voice_learning_step' } otherwise. The whole function is wrapped so a failure here logs and returns rather than failing the approval a person just made.

Nothing is auto-adopted, and the file says why: "a reviewer trimming a sentence for length is indistinguishable in the diff from one deleting a tell, and only a person can tell them apart." This is the same gate as scoped memory — a pass may propose, only a person may adopt.

What is not implemented

Where this lives in the repo