Loading...
Loading...
Support workflows, ticketing systems (Zendesk, Intercom), knowledge base design, chatbot design, and metrics (CSAT, NPS). Use when building support infrastructure, designing help centers, or optimizing customer experience.
npx skill4agent add travisjneuman/.claude customer-success// Knowledge base article schema
interface Article {
id: string;
slug: string;
title: string;
content: string; // Markdown
excerpt: string; // For search results
category: string;
subcategory: string;
tags: string[];
audience: "customer" | "internal" | "both";
visibility: "public" | "authenticated" | "internal";
relatedArticles: string[];
metadata: {
createdAt: Date;
updatedAt: Date;
author: string;
reviewedAt: Date | null;
helpfulVotes: number;
notHelpfulVotes: number;
viewCount: number;
};
}
// Search implementation with vector + full-text hybrid
async function searchKnowledgeBase(
query: string,
options?: { category?: string; audience?: string; limit?: number }
): Promise<SearchResult[]> {
const limit = options?.limit ?? 10;
// 1. Semantic search (catches paraphrased queries)
const embedding = await getEmbedding(query);
const semanticResults = await vectorDb.search({
vector: embedding,
filter: {
audience: options?.audience ?? "customer",
...(options?.category && { category: options.category }),
},
limit,
});
// 2. Full-text search (catches exact terminology)
const textResults = await db.$queryRaw`
SELECT id, title, excerpt,
ts_rank(search_vector, plainto_tsquery('english', ${query})) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', ${query})
AND audience IN ('customer', 'both')
${options?.category ? Prisma.sql`` : Prisma.empty}
ORDER BY rank DESC
LIMIT ${limit}
`;
// 3. Merge and deduplicate results
const merged = mergeSearchResults(semanticResults, textResults);
// 4. Track search for analytics
await trackSearch(query, merged.length);
return merged;
}
// Feedback loop - track article helpfulness
async function rateArticle(
articleId: string,
helpful: boolean,
feedback?: string
): Promise<void> {
await db.articleFeedback.create({
data: {
articleId,
helpful,
feedback,
createdAt: new Date(),
},
});
// Update aggregate counts
await db.article.update({
where: { id: articleId },
data: helpful
? { helpfulVotes: { increment: 1 } }
: { notHelpfulVotes: { increment: 1 } },
});
// Flag articles with low helpfulness for review
const article = await db.article.findUnique({ where: { id: articleId } });
if (article) {
const total = article.helpfulVotes + article.notHelpfulVotes;
const helpfulRate = total > 10 ? article.helpfulVotes / total : 1;
if (helpfulRate < 0.5 && total > 10) {
await createReviewTask(articleId, "Low helpfulness score");
}
}
}// In-app contextual help widget
function HelpWidget({ context }: { context: string }) {
const [articles, setArticles] = useState<Article[]>([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Fetch relevant articles based on current page/feature context
if (isOpen) {
searchKnowledgeBase("", { category: context, limit: 5 })
.then(setArticles);
}
}, [isOpen, context]);
return (
<div className="help-widget">
<button
onClick={() => setIsOpen(!isOpen)}
aria-label="Help"
aria-expanded={isOpen}
>
?
</button>
{isOpen && (
<div role="dialog" aria-label="Help articles">
<SearchInput onSearch={(q) => searchKnowledgeBase(q, { category: context }).then(setArticles)} />
<ArticleList articles={articles} />
<a href="/support">Contact support</a>
</div>
)}
</div>
);
}// Unified support ticket interface (abstracts provider)
interface SupportTicket {
id: string;
externalId: string; // Provider's ticket ID
subject: string;
description: string;
status: "open" | "pending" | "in_progress" | "resolved" | "closed";
priority: "low" | "normal" | "high" | "urgent";
category: string;
tags: string[];
requester: {
id: string;
email: string;
name: string;
};
assignee?: {
id: string;
name: string;
group: string;
};
metadata: {
plan: string;
accountAge: number;
mrr: number; // Monthly recurring revenue
previousTickets: number;
};
createdAt: Date;
updatedAt: Date;
firstResponseAt?: Date;
resolvedAt?: Date;
}
// Auto-triage new tickets with AI classification
async function triageTicket(ticket: SupportTicket): Promise<TriageResult> {
const classification = await classifyTicket(ticket.subject, ticket.description);
// Priority escalation rules
let adjustedPriority = classification.priority;
// Enterprise customers get elevated priority
if (ticket.metadata.mrr >= 5000) {
adjustedPriority = elevate(adjustedPriority);
}
// Revenue at risk detection
if (containsCancellationIntent(ticket.description)) {
adjustedPriority = "urgent";
classification.tags.push("churn-risk");
}
// Route to appropriate team
const team = determineTeam(classification.category, adjustedPriority);
return {
category: classification.category,
priority: adjustedPriority,
team,
tags: classification.tags,
suggestedArticles: await findRelatedArticles(ticket.subject),
autoResponse: classification.confidence > 0.9
? generateAutoResponse(classification, ticket)
: null,
};
}
// SLA enforcement
interface SLAPolicy {
priority: string;
firstResponseMinutes: number;
resolutionMinutes: number;
}
const SLA_POLICIES: SLAPolicy[] = [
{ priority: "urgent", firstResponseMinutes: 30, resolutionMinutes: 240 },
{ priority: "high", firstResponseMinutes: 120, resolutionMinutes: 480 },
{ priority: "normal", firstResponseMinutes: 480, resolutionMinutes: 1440 },
{ priority: "low", firstResponseMinutes: 1440, resolutionMinutes: 4320 },
];
async function checkSLABreaches(): Promise<SLABreach[]> {
const openTickets = await getOpenTickets();
const breaches: SLABreach[] = [];
for (const ticket of openTickets) {
const policy = SLA_POLICIES.find((p) => p.priority === ticket.priority);
if (!policy) continue;
const now = new Date();
const age = (now.getTime() - ticket.createdAt.getTime()) / 60000;
// First response SLA
if (!ticket.firstResponseAt && age > policy.firstResponseMinutes) {
breaches.push({
ticketId: ticket.id,
type: "first_response",
minutesOverdue: Math.round(age - policy.firstResponseMinutes),
});
}
// Resolution SLA
if (!ticket.resolvedAt && age > policy.resolutionMinutes) {
breaches.push({
ticketId: ticket.id,
type: "resolution",
minutesOverdue: Math.round(age - policy.resolutionMinutes),
});
}
}
return breaches;
}// Health score calculation
interface HealthSignal {
name: string;
weight: number;
score: (customer: Customer) => number; // 0-100
}
const healthSignals: HealthSignal[] = [
{
name: "product_usage",
weight: 0.3,
score: (c) => {
const weeklyActive = c.loginDaysLast30 / 30 * 100;
return Math.min(100, weeklyActive * 1.5);
},
},
{
name: "feature_adoption",
weight: 0.2,
score: (c) => {
const total = CORE_FEATURES.length;
const adopted = c.featuresUsed.filter((f) => CORE_FEATURES.includes(f)).length;
return (adopted / total) * 100;
},
},
{
name: "support_sentiment",
weight: 0.15,
score: (c) => {
if (c.ticketsLast90Days === 0) return 70; // Neutral
const resolved = c.resolvedTicketsLast90Days / c.ticketsLast90Days;
return resolved * 100;
},
},
{
name: "expansion_signals",
weight: 0.15,
score: (c) => {
const usageGrowth = c.currentMonthUsage / (c.previousMonthUsage || 1);
if (usageGrowth > 1.2) return 100;
if (usageGrowth > 1.0) return 70;
if (usageGrowth > 0.8) return 40;
return 10;
},
},
{
name: "payment_health",
weight: 0.2,
score: (c) => {
if (c.failedPaymentsLast90Days > 2) return 20;
if (c.failedPaymentsLast90Days > 0) return 60;
return 100;
},
},
];
function calculateHealthScore(customer: Customer): {
score: number;
status: "healthy" | "at-risk" | "critical";
signals: Array<{ name: string; score: number; weight: number }>;
} {
const signals = healthSignals.map((signal) => ({
name: signal.name,
score: signal.score(customer),
weight: signal.weight,
}));
const weightedScore = signals.reduce(
(total, s) => total + s.score * s.weight,
0
);
return {
score: Math.round(weightedScore),
status: weightedScore >= 70 ? "healthy" : weightedScore >= 40 ? "at-risk" : "critical",
signals,
};
}
// Scheduled job: calculate health scores and alert CSMs
async function updateHealthScores(): Promise<void> {
const customers = await db.customer.findMany({
where: { subscriptionStatus: "active" },
include: { usage: true, tickets: true, payments: true },
});
for (const customer of customers) {
const health = calculateHealthScore(customer);
await db.customerHealth.upsert({
where: { customerId: customer.id },
create: { customerId: customer.id, ...health },
update: health,
});
// Alert CSM for critical customers
if (health.status === "critical" && customer.mrr >= 1000) {
await notifyCSM(customer.csmId, {
customer: customer.name,
score: health.score,
signals: health.signals.filter((s) => s.score < 40),
});
}
}
}| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Bot-only support with no human escalation | Frustrated customers, churn | Bot for triage + knowledge, human for resolution |
| No context passed on escalation | Customer repeats everything | Pass full conversation history + account data |
| Measuring success by tickets closed | Incentivizes premature closure | Measure first-contact resolution + customer effort |
| Knowledge base with no feedback loop | Stale, unhelpful articles persist | Track helpfulness votes, flag low performers |
| Same SLA for all customers | Enterprise customers get same priority as free | Tiered SLAs based on plan and revenue |
| Support separate from product feedback | Miss patterns that could prevent tickets | Tag tickets as product feedback, surface trends |
llm-app-developmentproduct-analyticsemail-systemsaccessibility-a11ydocs/reference/stacks/react-typescript.md