Bhaify
LiveA persona-transfer engine — type a plain sentence, get it back in Salman Khan's tweeting voice. The hard part was measuring whether it worked.
- Period
- 2026 — Present
- SvelteKit
- Svelte 5
- Drizzle
- Neon
- Tailwind CSS
- Vercel
Not a chatbot. One box, one button, one line back — your message rewritten in a very specific voice, as an openly-labelled fan parody. It never answers you, never converses, never adds a claim of its own.
The persona is the product, so every interesting problem in it turned out to be a measurement problem.
The corpus comes first
52 verbatim tweets, 2010–2026, extracted from a fan site’s embedded RSC payload by script rather than retyped.
- Three records came back clipped — two contained a
"that killed the extractor’s string match, one was truncated by X’s own “Show more” and had to be recovered from press coverage. verify-corpus.tsaccepts a recovered record only if the scraped text is a strict prefix of it. A recovery can extend a record, never quietly rewrite one.- Grounding data only. It feeds few-shot selection and eval scoring; no page ever renders it.
The design is the joke
Borrowed x.ai’s language wholesale — near-black canvas, white outline pills, one geometric sans at weight 400, uppercase tracked mono labels. Its own design doc calls the effect “a research lab announcing its work rather than a SaaS marketing site.”
The voice it wraps is the opposite: loud, filmy, maximalist. Play the container completely straight and the content does all the work. A Bollywood-gold treatment would be more obvious and much less funny.
The eval harness was certifying the failure
It reported 28 of 31 passing on output that felt nothing like him.
- A “style marker” was a match on
/aa|oo|uu/or the token" ."— so pure Hindi transliteration scored for free, and a three-beat line banked three markers before saying anything. - “Has a structural move” passed on
segments >= 3, which is the three-beat template it was supposed to catch. - A
0.5×–2.5×length band enforced the clipped rhythm that made everything read like a Mad Lib, against a corpus that rambles to a median of 18 words.
It was measuring fidelity, which was already fine, and calling it persona.
The rebuild splits them into two axes that fail independently:
| Axis | Scope | Checks |
|---|---|---|
| Fidelity | per output | a question stays a question; names and numbers survive; no assistant-voice tells |
| Style | per batch | compression, English-base share, rhythm, median length, sign-off rate, phrase repetition |
Style has to be per batch because inconsistency is the style. One line that compresses nothing is fine; a batch where nothing compresses is broken.
One metric earns its place by refusing to average — pureHindiShare. A batch
averaging 9% English isn’t 31 lines at 9%, it’s a third at 30% and a third at
zero. The zeroes are the broken output hiding behind the good ones.
What the numbers found
- The few-shots were teaching the wrong task. 1.4 SMS tokens per 100 words and 0.2% English function words, against a corpus at 8.5–13 and ~19%. Fifty hand-written examples were demonstrating translate this to Hindi.
- The style guide was decoration. It asked for sign-offs under 10%; the
examples used them 20% of the time and live output hit 45%. New rule: anything
asserted in
style-guide.tsmust be visible inexamples.ts. - The deterministic quirk pass was inert. Its lexicon keys on English function words and the examples were teaching Hindi, so nothing matched — quirk density was 0 on 18 of 29 live rows, and raw-vs-quirkified metrics were identical on every axis. Surface polish can’t fix a voice; it can only finish one.
- Two transforms read as bugs, not voice. The corpus capitalises mid-sentence under rhetorical stress. A uniform per-word probability can’t tell stress from filler, so production produced “Roz rath ko Loud music”.
Choosing the slower model on purpose
12 cases, variant shapes rotated:
| Model | Compression | English-base | Pure Hindi | Gate | Latency |
|---|---|---|---|---|---|
gemini-flash-lite |
3.4 /100w | 5% | 50% | fail | 1.1s |
gemini-flash (capped) |
13.0 /100w | 19% | 0% | pass | 9.3s |
| corpus reference | 8.5 /100w | 19% | — | — | — |
flash-lite doesn’t just score lower — half its output is a Hindi translation, the exact failure that made the product feel wrong. So the primary is ~8× slower and 503s on 5 of 12 calls. 503 is retryable, so the chain degrades to the fast one automatically.
Capping the reasoning budget is what made it viable, and the working value isn’t
the obvious one: 'none' → HTTP 400, 'low' → 98 tokens to emit “ok”,
'minimal' → 8, omitted → 117.
Smaller decisions
- Variant slots. A response cache and randomised style cancel out if the key is just the input hash. Three slots per key instead; regenerate fills the next empty one, then cycles them free. Each slot binds a different shape — run-on, one-liner, pivot — because seed variation alone produced three rewordings of the same sentence.
- No AI SDK. One non-streaming call that must return an id synchronously. Gemini’s OpenAI-compatible endpoint means both providers take an identical payload, so the fallback chain is a loop over a table.
- Injection defence isn’t a coin flip. User text sits inside
<message_to_bhaify>, with the closing instruction after it. Two adversarial few-shots are pinned into every request — if the sampler could drop them, it would drop them on the request where they matter. - Share cards stay plainly ours. A pixel-perfect fake tweet screenshot turns
parody into a fabricated quote. Own chrome, a
BHAIFIEDstamp, disclaimer rendered into the image — OG cards travel without their page.