Support chat widgets usually get built one of two ways. Either you buy one and administer its content through somebody else's web dashboard, or you build one and put the answers in a database table with a CMS in front of it.
We put ours in a TypeScript file. Not the widget, the knowledge: the whole decision tree, every canned answer, every button label. It is a module in the repo, imported by both the client widget and the server route, and it ships in a pull request like anything else.
I want to argue this is the right default for a small product, and be specific about where it stops being right.
What is actually in the file
/**
* The support chat's knowledge — the predetermined decision tree AND the text
* the AI deflection step is grounded in.
*
* This is versioned content, not data: the bot flow lives in code (like the FAQ
* on the help page) rather than in a table, so it ships and reviews with the
* rest of the app. Both the client widget and the server AI route import from
* here, so answers stay in one place.
*/
The shape is two levels. An intent, which is the branch a user picks first, and topics under it:
export interface SupportIntent {
id: TicketType
/** Intent button label ("Report a bug"). */
label: string
/** One-line helper under the label. */
description: string
/** Placeholder for the escalation message box, tailored to the intent. */
escalatePlaceholder: string
topics: SupportTopic[]
}
export interface SupportTopic {
/** Slug, stored on the ticket as `category`. Stable, used for triage/analytics. */
id: string
/** The button label the user taps. */
label: string
/** The predetermined answer shown as a bot bubble. Kept short and simple. */
answer: string
}
And an entry is just data:
{
id: 'practice-mismatch',
label: "A game doesn't match the real test",
answer:
"Thanks for flagging it, that's exactly what we want to hear about. Open a ticket below with the provider, the game, and what the real assessment did differently (timings, question types, instructions, scoring). The more precise you are, the faster we can correct our version.",
}
The five things this buys you
1. Answers cannot drift from the product. This is the big one. Look at what a real answer needs to say:
answer: `Open Games in the sidebar, pick an unlocked game and press Play. Each one
has a tutorial before the real rounds. Every account gets ${FREE_GAME_COUNT} free games
across all ${PROVIDER_COUNT} providers, with ${FREE_PLAY_BUDGET_PER_PROVIDER} free plays
shared across each provider's games.`
Those are the same constants the product enforces. When we change the free game allowance, the support bot's answer changes in the same commit, because it is the same value. In a CMS, that answer is a string somebody typed once and will be wrong within two releases, and nothing will tell you.
Every number a support answer quotes is a number your code already knows. Interpolating it is the only way to keep them in step.
2. Review. Support copy is the voice of your product at the moment a user is annoyed. That deserves a diff and a second pair of eyes at least as much as a button component does. Changing it through a web form means nobody reviews it and there is no history of what it used to say.
3. One source for the deterministic tree and the AI grounding. We have a canned decision tree and an AI step that handles things the tree does not cover. The AI route grounds on this same file. Two stores means the bot can confidently state something the tree contradicts, which is the worst version of this feature.
4. Category slugs are stable, and they are load bearing. The topic id gets stored on the ticket as its category, and that slug reaches further than the UI:
The category matters beyond the UI: the ticket API derives the subject a human
sees from it ("Report a problem: A game doesn't match the real test"), and triage
filters on it.
Slugs in a table get renamed by whoever is editing the copy, because the edit box for the label is right next to the one for the id. Slugs in a typed file are referenced by name from other modules, so renaming one is a compile error rather than a silently orphaned analytics segment.
5. It costs nothing to build. No admin UI, no schema, no migration, no permissions model for who may edit support copy. For a team where the people writing the answers are the people who can open a pull request, every one of those is pure overhead.
Where it stops being right
I am not claiming this scales forever. It stops working when:
- Non-technical support staff need to edit answers during an incident, without a deploy.
- You are localising into several languages and want translators in a translation tool rather than in your repo.
- The answer set gets big enough that a human maintaining an array is slower than a search index.
The honest version of the rule: content that only engineers edit, and that references values the code owns, belongs in code. The moment either half of that stops being true, move it. What you should not do is build the table and the admin UI on day one because it feels more grown up. You will have paid for a CMS and still have answers quoting a free-tier allowance from six months ago.
A detail I did like: writing for the medium
Answers are written deliberately SHORT and in plain language — a chat bubble,
not a documentation page. They mirror the fuller FAQ in app/dashboard/help but
are trimmed for a conversational surface.
The FAQ page and the chat bubble say the same thing at two different lengths, on purpose. The temptation with one source of truth is to also make it one string, and then either your FAQ is terse or your chat bubble is four paragraphs nobody reads.
Sharing the knowledge does not mean sharing the prose. Share the constants and the structure, write the text for where it appears.
The related trick: deep-linking without lifting state
One consequence of the widget being mounted once, by the dashboard layout, is that any page wanting to send someone into it is nowhere near it in the component tree. The escape hatch is a plain DOM event:
/** The `window` event the mounted widget listens for. */
export const SUPPORT_OPEN_EVENT = 'cogniprep:support-open'
export function openSupportTicket(request: SupportOpenRequest): void {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent<SupportOpenRequest>(SUPPORT_OPEN_EVENT, { detail: request })
)
}
The alternative was lifting the widget's state into a context provider, but the layout is a server component, so that means a new client wrapper around every dashboard page purely to let two call sites open a panel. The event keeps the widget as the sole owner of its own state machine, and it is a no-op if the widget is not mounted.
The payload references the same knowledge file, which is what makes the deep link safe:
/**
* `intent` and `topic` are the widget's own decision-tree ids from
* lib/support/knowledge, so a deep link lands on a real category.
*/
A deep link cannot point at a category that does not exist, because the ids are typed and imported.
Go and read the answers
The public FAQ at cogniprep.app/help is the long-form sibling of this file, rendered from the same constants. Check any answer that quotes a number: free games, providers covered, free plays per provider. Then compare it with the real allowance on cogniprep.app/pricing.
They agree because neither page is quoting a number a human typed. If you run a support knowledge base, that is the audit worth doing this afternoon: pick every answer that contains a digit and check it against what your code actually does.
I would guess at least one is wrong.
Top comments (0)