Skip to content
← Log

2026-08-25 · 6 min read

A fake AI that speaks Klingon

Adding languages to a response faker turned out to be less about translation and more about typography, defaults, and being honest that the output is fake.

A faker that only produces English is a faker that only tests English. If your app has to render Dutch, or Arabic, or Japanese, the fake responses that never leave ASCII will not tell you that your layout breaks right-to-left or that your line-breaking assumes spaces between words.

So phantom-core learned languages. The design questions that mattered were not about vocabulary.

The default is a decision, not an absence

The first rule: a request that names no language is answered in English. The second, less obvious one: a request naming a language we do not carry is *also* answered in English, rather than rejected.

That is a deliberate stance. A simulator exists to answer. Returning a 400 because someone asked for a language the fake does not stock trades a useful response for a pedantic one. But silently substituting English is its own trap, so every response reports the language it actually used — even when that language is English, and even when nothing was requested:

Content-Language: x-klingon
X-Phantom-Language: klingon

The header is emitted unconditionally. An earlier version omitted it when no language was requested, which meant a client could not distinguish "answered in English because you asked for English" from "answered in English because your request was not understood". Two tests failed on that, and they were right — the implementation changed, not the tests.

Typography is per-language, not per-branch

The naive shape for this is a word bank per language and one generator. That produces Japanese with spaces between every word and a full stop at the end, which is not Japanese. It produces Arabic that is capitalised, which is meaningless.

So each language carries its own typography alongside its words: what joins words, what ends a sentence, whether sentences capitalise, which direction it reads. The generator has no per-language branches — it reads those properties. Japanese declares an empty joiner and 。 as its terminator; Arabic declares right-to-left and no capitalisation.

That paid off immediately in an unglamorous place. Length control budgets by characters and then trims back to a clean boundary — which for a spaced language means dropping the partial trailing word. Japanese has no word boundary to find, so it trims to the last sentence terminator instead. One property, two behaviours, no special case.

Why fictional languages are the honest option

Alongside thirteen real languages there are nine invented ones: Klingon, Elvish, Dothraki, Pirate, Minionese, Simlish, Orcish, Machine, and Lorem — which holds the classic Latin filler the faker used before any of this existed.

They are not a joke feature, or not only one. The risk with a good fake is that it is mistaken for real: a plausible English paragraph in a screenshot, a log, a demo, is indistinguishable from a real completion. Nothing that comes back in Dothraki has that problem. Fictional output is self-labelling in a way no watermark achieves.

They also get a different instruction when a real model is in the loop. A model asked for Dutch simply answers in Dutch. A model asked for Klingon will answer in English, or answer in Klingon and then helpfully translate itself back — so the fictional instruction is firmer, and explicitly forbids the gloss.

Keys are a contract; values are not

The sharpest edge was structured output. Ask for JSON matching a schema, in German, and there is exactly one right answer about what gets translated:

{
  "name": "eventuell Datensatz",
  "age": 605,
  "tags": ["Kanal", "Sitzung"]
}

The keys are the caller's contract — their parser is looking for `name`, and a helpfully translated `Name` breaks it. The values are content. So the generator localises values and never keys, and when a real model is doing the filling it is told the same thing in words.

The same logic applies to emails and URLs: they stay ASCII in every language, because a non-Latin local part is not a useful fake — it is a broken one.

Four ways to ask

A language can arrive in the JSON body, on the query string, in a custom header, or via Accept-Language — in that order of precedence, with an unrecognised value in a higher source falling through to the next rather than winning and then failing.

Four sources is not over-engineering, it is the consequence of the surface being genuinely drop-in. An SDK client can only set a body field. A curl user reaches for the query string. A proxy can only add headers. Accept-Language is what a browser sends without being asked. Supporting one of those would have meant the feature only worked for one kind of caller.