Versioned prompts
The classifier prompt is configurable per tenant and versioned. Admins can edit and version a prompt; the classifier always reads the active version, falling back to a built-in default when none is saved. AI prompt design is one of the "stop and request human review" areas in AGENTS.md, so treat prompt edits accordingly.
The descriptor catalogue
PromptService (api/src/domains/prompt.ts) exposes a catalogue of configurable prompts via
PROMPT_DESCRIPTORS. Each PromptDescriptor declares the prompt's key, a label/description for the
admin editor, the replacement tags it supports, and its built-in default body:
export const PROMPT_DESCRIPTORS: PromptDescriptor[] = [
{
key: CODING_PROMPT_KEY, // 'verbatim_coding'
label: 'Verbatim auto-coding',
description: 'Classifies free-text answers against your topics/themes taxonomy.',
tags: ['%topics%', '%verbatims%'],
defaultBody: DEFAULT_CODING_PROMPT,
},
];
verbatim_coding is currently the only configurable prompt. The sentiment analyze prompt is
hard-coded in VerbatimService and not customisable.
Storage & versioning
PromptService is a thin facade over the prompts repository:
list(ctx, key?)— all versions (optionally filtered by key).createVersion(ctx, key, body)— save a new version.activate(ctx, publicId)— make a version the active one.descriptors()— the catalogue above, for the editor.
The classifier reads the active body with r.prompts.getActiveBody(CODING_PROMPT_KEY); a null result
means "no saved version", and DEFAULT_CODING_PROMPT is used instead.
The REST surface (/v1/prompts*) is consumed by the admin Prompts page (/admin/prompts).
The verbatim_coding prompt
CODING_PROMPT_KEY and DEFAULT_CODING_PROMPT live in api/src/domains/verbatim.ts (imported by
prompt.ts). The default instructs the model to assign only exact topic labels from the supplied
list — never invent labels — and to return one array per verbatim in input order:
You classify customer-feedback verbatims against a fixed taxonomy of topics grouped under themes.
Assign ONLY exact topic labels from the list below — never invent labels. ...
Topics:
%topics%
Return JSON {"results":[[...]]} — one array per verbatim, in input order, ...
Verbatims:
%verbatims%
Replacement tags
The two tags are filled at classify time in VerbatimService.classify:
%topics%→ the taxonomy block: topics grouped under their theme, each rendered as- "label": description. The description is what lets the model classify on intent.%verbatims%→ the numbered list of verbatims to classify (whitespace-collapsed, clamped to 500 chars each).
Substitution is a global replace, so a tag may appear more than once:
const prompt = (activeBody ?? DEFAULT_CODING_PROMPT)
.replace(/%topics%/g, topicsBlock)
.replace(/%verbatims%/g, verbatimsBlock);
A custom prompt that omits a tag simply won't receive that data — the editor surfaces the supported tags from the descriptor so authors know which are available. The model output is still validated against the fixed schema and filtered against the real taxonomy (see Sentiment & coding), so a malformed prompt degrades to "nothing coded" rather than producing garbage assignments.