I removed the LLM call and replaced it with 200 lines of template code A developer replaced an LLM call in a letter generator with 200 lines of deterministic template code, citing fixed output shape, legal-adjacent risk, and zero marginal cost as key reasons. The pure function approach eliminates hallucinations, enables instant offline rendering, and allows the tool to remain free without signup. The feature was a letter generator. Somebody fills in a few fields and gets a finished letter of recommendation, resignation letter or notice letter, in plain text, ready to paste into an email. The obvious build is a prompt and a model call. I wrote the deterministic version instead: a pure function, about two hundred lines, no network, no key, no tokens. I want to lay out the reasoning, because "just call a model" is the default now and the default is not always right. 1. The output is short and the shape is fixed. A recommendation letter is a date block, a greeting, three or four paragraphs, a sign off and a name. There is no structural variation to discover. Generation is valuable when the space of good outputs is large and you cannot enumerate it. Here the space is small enough to write down, and once you have written it down the model is doing an expensive approximation of a switch statement. 2. It is a legal-adjacent document. Not legal advice, but it goes into an employment record. A resignation letter that invents a notice period, or a reference that invents a fact about a person, is a real problem for the person who sent it. Templates cannot hallucinate. Everything specific in the output either came from a form field or is a sentence I wrote and can be held to. 3. Zero marginal cost changes what the product can be. This is the one that actually decided it. A model call costs money per use, and anything that costs money per use needs an account, a rate limit and eventually a card. A pure function costs nothing, so the tool can stay open with no signup, forever, without a business case. That is a product decision expressed as an architecture decision, and it only works if the code path is free. The whole engine is one exported function over one input type. export type LetterKind = 'resignation' | 'notice' | 'recommendation'; export type LetterTone = 'formal' | 'warm' | 'brief'; export function generateLetter input: LetterInput : string Tone is not a prompt instruction, it is a dimension of the data. Two tiny functions carry most of it: js function greeting input: LetterInput, tone: LetterTone : string { const name = input.recipientName.trim ; if name return tone === 'warm' ? 'Hello,' : 'Dear Sir or Madam,'; if tone === 'warm' return Hi ${name}, ; return Dear ${name}, ; } function signOff tone: LetterTone : string { if tone === 'warm' return 'With thanks,'; if tone === 'brief' return 'Regards,'; return 'Sincerely,'; } The bodies are arrays of paragraphs, assembled conditionally. A recommendation body opens differently depending on whether the writer told us how they know the subject: paras.push rel ? I am pleased to recommend ${who} for the role of ${input.role}. ${rel}, which gave me a direct view of how they work. : I am pleased to recommend ${who} for the role of ${input.role}, based on my direct experience of working with them at ${input.company}. , ; That ternary is the whole trick, repeated maybe fifteen times. It is not clever. Clever was never the requirement. Determinism, which means testability. Same input, same bytes out. A snapshot test over the full cross product of three kinds and three tones is nine assertions and runs in milliseconds. Testing a model call means either mocking it, in which case you are testing your mock, or asserting fuzzy properties of real output and paying for the privilege on every CI run. Offline and instant. No spinner, no failure state, no retry logic, no timeout, no "the service is busy" copy to write and translate. The letter updates as the user types because rendering it is a function call. A real validator instead of an implicit one. With a model you tend to send whatever you have and hope. With a template you have to decide what is required, which forces the product question into the open: js export function missingFields input: LetterInput : string { const missing: string = ; if input.senderName.trim missing.push 'Your name' ; if input.company.trim missing.push 'Company' ; if input.kind === 'recommendation' { if input.subjectName.trim missing.push 'Who you are recommending' ; if input.role.trim missing.push 'Their role' ; } else { if input.role.trim missing.push 'Your role' ; if input.lastDay.trim missing.push 'Last working day' ; } return missing; } Note that the required set differs by kind. A recommendation has no last working day. A resignation has no subject. A single prompt would have blurred those together and produced something plausible for a missing field, which is worse than refusing. Localisation is mechanical. Nine strings per tone, translated once, correct forever. The same feature backed by a model needs the prompt tuned per language and the output checked per language by someone who reads it. I am not going to pretend this scales to everything. It cannot say the specific thing. The generated paragraphs are competent and generic, and generic is exactly the part of a reference letter that carries no weight. A hiring manager skims "demonstrated consistent judgement" and stops at "she rewrote our billing reconciliation and the month-end close went from four days to one". So the engine has a highlight field, free text, dropped verbatim into the middle of the letter. That is not a limitation I worked around, it is the correct division of labour. The tool writes the scaffolding nobody reads. The human writes the one sentence that does the work. A model would have written a fluent guess at that sentence, and a fluent guess about a real person is precisely the thing you do not want in a reference. It cannot rewrite arbitrary prose. Paste in three rambling paragraphs and ask for them tightened, and templates have nothing to offer. That is a genuine generation task and I would use a model for it. Adding a kind costs a function. A new letter type means new code, not a new prompt string. For three kinds that is fine. For thirty I would be rethinking it. Reach for generation when the output space is large, variable, and you cannot enumerate the good answers. Reach for templates when the output space is small, the shape is fixed, and being wrong is expensive. Short formal documents sit squarely in the second category, and the industry keeps building them with the first tool because the first tool is what everyone is holding. The engine described here runs the letter of recommendation template https://cvbooster.ai/recommendation-letter-generator tool on the resume builder I maintain. No account, no card, no model call, and the plain text output is free to copy. It renders in whatever time a string concatenation takes, which is the entire point.