Separate Prompts from Business Logic
Inline prompt strings rot fast: they get concatenated with data, edited mid-function, and eventually nobody can tell what the model actually receives. The fix is the same one templating solved for HTML twenty years ago — the prompt is a template that declares its variables, and business logic just supplies data.
Step 1 - Imports
import {
useLlm,
createChatPrompt,
createParser,
createLlmExecutor,
} from "llm-exe";Step 2 - Prompts Live with Prompts
The template is a plain exported constant — it can live in its own module, be reviewed in isolation, and produce clean diffs when prompt wording changes (no application code touched). The Handlebars variables declare exactly what data it needs.
// Prompts live here (or in their own module) - not inline in business logic.
// The template declares the variables it needs; TypeScript enforces them.
export const SUPPORT_REPLY_PROMPT = `You are a support agent for {{companyName}}.
Write a brief, friendly reply to the customer message below.
- Do not promise refunds or specific timelines.
- If the issue needs a human, say the team will follow up.
Customer message:
{{message}}`;Step 3 - Business Logic Supplies Data
The prompt's input type is enforced by the generic: forget companyName or pass a number and it's a compile error, not a prompt with a hole in it.
export interface DraftSupportReplyInput {
companyName: string;
message: string;
}
export async function draftSupportReply(input: DraftSupportReplyInput) {
const llm = useLlm("openai.gpt-4o-mini");
const prompt = createChatPrompt<DraftSupportReplyInput>(SUPPORT_REPLY_PROMPT);
const parser = createParser("string");
return createLlmExecutor({
name: "draft-support-reply",
llm,
prompt,
parser,
}).execute(input);
}Step 4 - Test Prompts Without an API Call
Because the prompt is data, you can render it and assert on the result — no LLM call, no API key, no cost. This runs in your normal test suite:
// Because the prompt is data, you can render it without calling an LLM -
// useful for snapshot tests and reviewing exactly what the model will see.
export function renderSupportReplyPrompt(input: DraftSupportReplyInput) {
return createChatPrompt<DraftSupportReplyInput>(
SUPPORT_REPLY_PROMPT
).format(input);
}const rendered = renderSupportReplyPrompt({
companyName: "Acme",
message: "My widget arrived broken.",
});
// assert the exact text the model will see — snapshot it, lint it, review itWhere to keep prompts as you scale
- Exported constants (this recipe) — right for most apps; prompts are versioned in git next to the code that uses them.
- A dedicated prompts module — one directory, all templates, easy to audit what your app says to models.
- Loaded at runtime — fetch templates from S3 or a CMS when non-engineers edit prompts or you want to change wording without deploying. See Loading Prompts Remotely.
The executor code is identical in all three — only where the template string comes from changes.
Related
- Prompts — chat and text prompt reference
- Advanced Templates — partials, conditionals, and loops with Handlebars
- Loading Prompts Remotely — runtime-loaded templates
- Write a Type-Safe LLM Function — typing the output side too
