Support works fine with twenty conversations a week: one person reads everything. At two hundred it stops working. The billing questions sit behind the "how do I export a CSV" questions, the enterprise trial that is about to expire waits four hours behind a free user's feature request, and every conversation starts with the same three questions: who are you, what plan are you on, what were you doing?
The fix is not more people. It is telling the inbox what it is looking at.
Two mechanisms, two jobs
Session data is context: key/value pairs shown beside the conversation. It answers "who is this and what were they doing" before an operator asks.
Segments are routing: labels that drive who picks a conversation up, which automations fire, and which saved views it appears in.
They are not interchangeable. Session data is read by a human; segments are read by the system. Putting an account id in a segment gives you a thousand segments and no routing; putting "billing" in session data gives you a note nobody can filter on.
Session data: three or four keys, chosen deliberately
import { setSessionData } from "@/lib/support/crisp";
setSessionData({
plan: user.plan, // "free" | "pro" | "enterprise"
account_id: user.accountId, // so an operator can open your admin panel
signed_up: user.createdAt.slice(0, 10),
app_version: process.env.NEXT_PUBLIC_APP_VERSION ?? "dev",
});
Rules that come from watching real inboxes:
- Only what an operator can act on. If nobody in support will ever do anything differently because of a field, it is noise that will still be there in a year.
- Never spread an object.
setSessionData({ ...user })ships every column the type gains later, including the ones added after this line was written. - Never a credential. No tokens, session ids, signed URLs or reset links.
The wrapper strips key names matching
token|secret|password|session|jwtand values shaped like credentials, but relying on the filter is not a plan. If an operator needs something sensitive to help, they look it up in the admin panel behind their own login, where the access is logged. - Never free text a user typed. A search query or a note can contain anything, including a third party's personal data.
Add the current page when the chat opens: it turns "it doesn't work" into a question you can answer:
<SupportButton
context={{ area: "billing", page: pathname, invoice_id: invoice.id }}
segments={["billing"]}
>
Ask billing support
</SupportButton>
Segments: few, stable, lowercase
import { setSegments } from "@/lib/support/crisp";
const segments = [user.plan]; // "pro"
if (isTrialing) segments.push("trial");
if (user.plan === "enterprise") segments.push("priority");
setSegments(segments);
A good segment set is small enough to memorise: free, pro, enterprise,
trial, billing, onboarding, priority. Each one should correspond to
something a human or an automation does differently.
Where each comes from:
- Persistent segments: plan, tier, lifecycle stage. Applied once when the widget mounts, from the server session.
- Conversation segments: the area the user was in when they opened the
chat:
billing,import,api. Applied by the button that opened it.
Two anti-patterns:
- Ids in segments.
user_1a2b3cis not routing, it is a leak with a filter on it. Ids belong in session data. - A segment per feature. Fifty segments route nothing, because nobody configures fifty rules. If you cannot list them from memory, there are too many.
What routing looks like once the labels exist
Inside Crisp (or any equivalent inbox) the segments feed:
- Saved views: "enterprise + trial" as a view someone owns and watches.
- Assignment rules:
billinggoes to whoever is on billing this week. - Triggers: a message to
enterpriseconversations that goes unanswered for fifteen minutes pings a Slack channel. - Reporting: response time by segment is the number that tells you whether your priority customers are actually getting priority. Without segments you can only measure the average, which hides exactly the failures you care about.
The code's job is to apply correct labels every time. The configuration inside the inbox is the support team's job, and it changes far more often than the code, which is the reason to keep the segment vocabulary small and stable, and to write the vocabulary down where both sides can see it.
Keeping the vocabulary honest
Put the list in one place and derive from it:
// src/lib/support/segments.ts
export const SEGMENTS = ["free", "pro", "enterprise", "trial", "billing", "onboarding"] as const;
export type Segment = (typeof SEGMENTS)[number];
export function planSegments(user: { plan: Segment; trialEndsAt: Date | null }): Segment[] {
const segments: Segment[] = [user.plan];
if (user.trialEndsAt && user.trialEndsAt > new Date()) segments.push("trial");
return segments;
}
Now a typo is a compile error, the set is greppable, and adding a segment is a decision someone makes on purpose rather than a string that appears in a component one afternoon.
Sign-out and identity
Segments and session data are attached to the Crisp session, not to your app's.
Call resetSession() on sign-out or the next person on that browser inherits
the previous user's labels: an operator would see "enterprise, priority" on a
conversation from a stranger.
And remember that an unverified email makes every other label untrustworthy: if
you have not enabled HMAC verification, plan: enterprise is only as reliable
as the browser that claimed it.
Measuring whether it worked
- First response time by segment. The point of routing is that
priorityandfreediverge. If they are identical, the labels exist but nothing acts on them. - Questions per conversation before an answer. Good session data pushes this down; it is the clearest signal that the context is the right context.
- Segment distribution. If 90% of conversations carry no segment, the triggers are not firing where you thought, usually because the widget mounts before the session resolves and the labels are applied to nothing.