customer-success について
カスタマーサポートとサクセス運用のシステム構築を支援するスキルです。チケット管理、ナレッジベース、チャットボット、SLA、顧客ヘルススコア、フィードバックの振り分けを扱います。
主な機能
- 01サポートチケットの連携と振り分け
- 02ナレッジベースとアプリ内ヘルプの設計
- 03SLA管理とエスカレーションの自動化
- 04顧客ヘルススコアモデル
- 05フィードバック収集と製品傾向の分析
活用シーン
- 01サポートシステムの構築や統合
- 02チャットボットとセルフサービスポータルの設計
- 03SLA追跡とエスカレーション通知の導入
- 04解約リスクのある顧客の特定
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill customer-successインストール後、Claude Code で/customer-successを入力して読み込まれたか確認します。
詳細
カスタマーサクセスエンジニアリング
概要
このスキルでは、カスタマーサポートとカスタマーサクセスの業務を支える技術システムの構築について説明します。サポートチケットシステムとの連携(Zendesk、Intercom、Freshdesk)、ナレッジベースのアーキテクチャ、会話型 AI チャットボットの設計、顧客フィードバックの収集と振り分け、SLA の管理と適用、エスカレーションワークフローの自動化、セルフサービスポータルの実装、顧客ヘルススコアモデルを扱います。
サポートシステムの構築や統合、チャットボットのフロー設計、セルフサービス用ドキュメントポータルの作成、SLA トラッキングの実装、顧客ヘルスダッシュボードの構築、サポートワークフローの自動化を行う際に、このスキルを使用してください。
基本原則
- セルフサービスを優先する - 最善のサポート対応とは、そもそも発生しない対応です。人によるサポートを拡大する前に、検索可能なナレッジベース、アプリ内ヘルプ、状況に応じたガイダンスに投資しましょう。
- 解決ではなくトリアージを自動化する - AI はチケットの分類、優先順位付け、振り分けを効果的に行えます。複雑な問題の解決は人に任せましょう。解決まで過度に自動化すると、顧客の不満につながります。
- チケット数ではなく解決までの時間を測定する - 顧客の問題が解決していなければ、チケットをすばやくクローズしても意味がありません。初回対応での解決率、顧客の労力スコア、再オープン率を追跡しましょう。
- チケットにはコンテキストを引き継ぐ - あらゆる引き継ぎ(ボットから人へ、L1 から L2 へ)では、会話履歴全体、ユーザーアカウントデータ、試行済みの解決策を含める必要があります。同じ情報を何度も伝えさせることは、顧客から最も多く寄せられる不満です。
- フィードバックはプロダクトのシグナルである - サポートチケットは、構造化されていないプロダクトフィードバックです。タグ付けと分類を行い、傾向をプロダクトチームに共有しましょう。最も多いサポートトピックを、次のプロダクト改善につなげるべきです。
主なパターン
パターン 1:ナレッジベースのアーキテクチャ
使用する場面: 顧客(セルフサービス)とサポートエージェント(社内リファレンス)の両方に役立つ、検索可能なドキュメントを構築するとき。
実装:
// 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`AND category = ${options.category}` : 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>
);
}
理由: ハイブリッド検索(セマンティック検索+全文検索)なら、「プロジェクトを共有するにはどうすればよいですか?」のような概念的なクエリと、「SSO SAML configuration」のような正確な用語のクエリの両方に対応できます。フィードバックループにより、品質の低い記事にフラグを立てて改善できます。状況に応じたヘルプを表示すれば、ユーザーが検索しなくても関連する記事を見つけられます。
パターン 2:サポートチケットの連携
使用する場面: 構造化されたサポートワークフローを実現するために、アプリケーションをチケット管理システムに接続するとき。
実装:
// 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;
}
理由: AI を活用したトリアージによってチケットを適切なチームにすばやく振り分け、初回応答までの時間を短縮できます。収益に基づく優先度のエスカレーションにより、価値の高い顧客に適切な対応を提供できます。SLA の監視によって説明責任を果たし、体系的な問題を明らかにできます(特定のカテゴリで SLA 違反が繰り返し発生する場合、プロダクトやドキュメントに不足があることを示しています)。
パターン 3:顧客ヘルススコアリング
使用する場面: 顧客が解約する前に、離反リスクのある顧客を先回りして特定するとき。
実装:
// 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),
});
}
}
}
理由: ヘルススコアリングにより、受け身のサポート(解約を待つ)から、先回りするカスタマーサクセス(解約前に介入する)へと転換できます。利用状況、サポートへの感情、機能の利用状況、支払い状況を組み合わせた総合スコアによって、全体像を把握できます。重要なアカウントに対するアラートを自動化すれば、価値の高い顧客に CSM が適切なタイミングで対応できます。
アンチパターン
| アンチパターン | 問題点 | より良い方法 |
|---|---|---|
| 人間へのエスカレーションがない、ボットだけのサポート | 顧客の不満、解約につながります | ボットで振り分けとナレッジ提供を行い、解決は人間が担当します |
| エスカレーション時にコンテキストを渡さない | 顧客がすべてを繰り返し説明することになります | 会話履歴とアカウントデータをすべて渡します |
| 対応完了チケット数で成功を測る | 対応の早すぎる終了を促します | 初回問い合わせでの解決率と顧客の労力を測定します |
| フィードバックループのないナレッジベース | 古く役に立たない記事が残り続けます | 役立ち度の評価を追跡し、評価の低い記事にフラグを付けます |
| すべての顧客に同じ SLA を適用する | エンタープライズ顧客と無料顧客が同じ優先度になります | プランと収益に基づいて SLA を段階化します |
| サポートと製品フィードバックを切り離す | チケットを未然に防げるパターンを見逃します | チケットに製品フィードバックのタグを付け、傾向を可視化します |
チェックリスト
- ナレッジベースをハイブリッド検索(セマンティック検索+全文検索)で検索できる
- 主要ページにアプリ内のコンテキストに応じたヘルプウィジェットがある
- サポートチケットが AI による分類で自動振り分けされる
- 優先度ごとに SLA ポリシーが定義されている
- SLA 違反を監視し、エスカレーションアラートを送信する
- 顧客ヘルススコアをスケジュールに沿って算出する
- リスクの高い重要顧客に対して CSM アラートを発生させる
- すべてのエスカレーションで完全なコンテキスト(会話+アカウントデータ)を共有する
- フィードバックループを通じて記事の役立ち度を追跡する
- サポートの傾向を毎月プロダクトチームに共有する
関連リソース
- スキル:
llm-app-development(チャットボットの RAG)、product-analytics(サポートファネル分析) - スキル:
email-systems(サポート通知メール)、accessibility-a11y(アクセシブルなヘルプウィジェット) - ルール:
docs/reference/stacks/react-typescript.md(ヘルプウィジェットのコンポーネント)