# I removed the LLM call and replaced it with 200 lines of template code

> Source: <https://dev.to/thedolceway/i-removed-the-llm-call-and-replaced-it-with-200-lines-of-template-code-2lh0>
> Published: 2026-08-25 12:11:32+00:00

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.
