The help button at the bottom left of every signed-in page in Nakodo opens a panel that shows three choices, then a handful of chips, then an answer. Most people stop at the answer. The answers are not in a database, a CMS or a help desk. They are an array in src/lib/support/manual.ts, 222 lines, and the reason is in the file's own header:
It lives in code rather than a table for the same reason the FAQ does: an answer is a claim about how the product behaves, so it should be reviewed and shipped with the behaviour it describes.
That is the whole argument. A support answer is documentation of behaviour. If it lives somewhere a product change cannot touch, it goes stale silently, and the first person to find out is somebody who trusted it.
The shape
export type ManualTopic = {
// Slug, stored on the ticket as its topic. Stable: the operator filters on
// it and a deep link names it.
id: string;
// The chip the person taps.
label: string;
// The answer, shown as one bubble.
answer: string;
};
export type ManualSection = {
kind: SupportKind;
lede: string;
placeholder: string;
topics: ManualTopic[];
};
Three sections, one per SupportKind (question, problem, billing), and 18 topics between them. Each section carries its own lede and its own form placeholder, so the question at the top of the form matches the thing you came in about rather than being one generic prompt.
The id is load-bearing in two places outside the widget: it is written onto the ticket as topic, and the operator's queue filters on it. So an id is a stable identifier, not a label, and renaming a chip does not orphan the tickets opened from it.
The numbers are not typed in
This is the part I would keep if I had to throw the rest away:
const free = PLANS.free.limits;
const pro = PLANS.pro.limits;
const business = PLANS.business.limits;
{
id: "limits",
label: "What counts towards my plan",
answer: `Each new creator or business Nakodo writes to, once. Follow-ups, answers and the introduction are all included, and anyone without a published email is never written to or counted. Free covers ${free.creatorsPerMonth} a month and pauses the campaign once they're used. Pro counts ${pro.creatorsPerDay} a day and Business ${business.creatorsPerDay}, across all of your campaigns, so outreach carries on all month.`,
}
There are 13 interpolations like that across the manual. The property that buys is specific: changing a plan's limit cannot leave a stale number in a support answer, because the support answer does not contain a number. It contains a reference to the number the app enforces. The pricing page, the limit check in the pipeline and the sentence somebody reads in a chat bubble are the same value.
Template literals in a data file are the cheapest possible version of this. No templating language, no placeholder syntax to document, no runtime substitution step, and a typo in a property name fails the build.
Three label maps for one enum
export const SUPPORT_KIND_LABELS: Record<SupportKind, string> = {
question: "Ask a question",
problem: "Report a problem",
billing: "Plans and billing",
};
// Shorter, for a row in a list where the kind is one column of several.
export const SUPPORT_KIND_WORDS: Record<SupportKind, string> = {
question: "Question",
problem: "Problem",
billing: "Billing",
};
// Plural, for a heading over a group of manual entries rather than one row.
export const SUPPORT_KIND_GROUPS: Record<SupportKind, string> = {
question: "Questions",
problem: "Problems",
billing: "Billing",
};
Three maps over the same three keys looks like duplication and is not. A chip invites you to do something ("Ask a question"). A cell in the operator's queue names a thing ("Question"). A heading over several of them is plural ("Questions"). Collapsing them into one map with a formatter means a formatter that knows English pluralisation and verb phrases, for three values that will never be generated at runtime. Record<SupportKind, string> means adding a fourth kind fails to compile three times, which is exactly three reminders that a new kind needs three words.
The subject line of a ticket nobody wrote a subject for is composed from two of these:
export function subjectFor(kind: SupportKind, topic: string | null): string {
const label = topicLabel(topic);
const head = SUPPORT_KIND_LABELS[kind].replace(/^(Ask a|Report a) /, "");
const start = head.charAt(0).toUpperCase() + head.slice(1);
return label ? `${start}: ${label.charAt(0).toLowerCase()}${label.slice(1)}` : start;
}
"Report a problem" plus "Emails aren't going out" becomes Problem: emails aren't going out. The regex strips the imperative from the chip and the two charAt calls fix the casing at the join. It is a silly little function and it is the difference between a queue of subjects that read like English and a queue of problem / not-sending.
The same array is the model's only source
The widget offers a model when a chip did not answer the question, because most questions are in the manual and phrased differently. What the model gets is the manual, flattened:
export const MANUAL_TEXT: string = MANUAL.map(
(section) =>
`## ${SUPPORT_KIND_LABELS[section.kind]}\n${section.topics.map((t) => `- ${t.label}: ${t.answer}`).join("\n")}`,
).join("\n\n");
So the chip's answer and the model's source are the same string, and anything it says traces back to a line somebody wrote on purpose. The instructions around it are a short list of refusals rather than a persona: answer only from the manual, never invent a price, a limit, a date, a policy or a refund, never guess at a cause, and when something looks broken say what detail would help a person look into it. The output is a two-field schema, the answer and the model's own account of whether it got there, and the widget leads with a person when that second field is false. That gate was its own post; what matters here is that the leash and the chips are one array.
The sizes both sides check live next to the authors and statuses, in a file with no imports so the schema, the server and the client can all read it:
export const SUPPORT_LIMITS = {
messageMin: 4,
messageMax: 4000,
subjectMax: 140,
questionMax: 600,
assistantTurns: 2,
transcriptMax: 30,
pageMax: 512,
userAgentMax: 400,
topicMax: 64,
} as const;
assistantTurns: 2 is a product decision in a constant: a follow-up and no more, because after that a person is faster. questionMax: 600 is sliced server side before the question reaches the model, not just checked in the browser.
What this does not solve
The manual is the short form. The long form is the FAQ on the marketing site, nine questions under the heading "Asked in the field", and they are two different arrays in two different files. Both read PLANS, so the numbers cannot drift, but nothing checks that the prose agrees. Our content pages have a test suite that walks every page and asserts things about titles, descriptions, word counts and banned words; the home page's FAQ and the support manual are hand-maintained and reviewed by reading them. If a behaviour changes, three places need editing and only two of them will fail a build.
The honest mitigation is small: both files are short, both are in the same repository as the behaviour, and both come up in the same diff as a plan change because they both import the plans.
Go and look
The widget needs an account, but the long form of every one of these answers is public, and the comparison is the interesting part:
- The FAQ on the home page is the fuller version of the same claims. "Does Nakodo send the emails for me?" there is three sentences; the manual's chip for the same thing is two.
- The pricing page is where the interpolated numbers are published, including the table of every limit side by side. Those figures and the ones in a support bubble are the same constants.
- How it works is the long form of the first chip in the manual, and the page the assistant is effectively paraphrasing when it answers a question about the brief or the search.
If your support answers contain numbers, put them in the same file your product reads. Everything else in this post is detail.
Top comments (0)