where-your-customer-lives について
理想顧客像の課題シグナルを具体的なオンラインコミュニティに結び付け、競合の話題も加えて根拠に基づくチャネル案を順位付けするスキルです。Reddit、Hacker News、DuckDuckGo と任意の GitHub データを利用します。
主な機能
- 01ICP の課題シグナルを収集
- 02具体的なコミュニティを発見
- 03競合の話題シグナルを追加
- 04チャネルを採点して順位付け
- 05根拠に基づく施策案を作成
活用シーン
- 01製品の ICP に合うコミュニティを探す
- 02実際の議論からチャネル参入を計画
- 03顧客像ごとのチャネル優先度を比較
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ varnan-tech/ opendirectory --skill where-your-customer-livesインストール後、Claude Code で/where-your-customer-livesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
.env.exampleREADME.mdevals/evals.jsonreferences/channel-types.mdentry-tactics.mdscoring-guide.mdscripts/fetch.py詳細
顧客がいる場所
製品の実用性とICPをもとに、実際のICPの課題に関する投稿を、発信元のコミュニティまでたどります。競合に関する議論のシグナルも重ね合わせます。DuckDuckGoを使って、Slack/Discord/ニュースレター/ポッドキャスト/カンファレンスのチャネルを見つけます。ICPシグナルの件数、規模、活動状況、競合の存在によって、各チャネルをスコアリングします。根拠、参加方法、コンテンツの切り口、避けるべきパターンを含む、順位付きのプレイブックをチャネルごとに1つずつ出力します。推測は禁止です。シグナルを追跡できるチャネルのみです。
重要なルール: 出力するすべてのチャネル名は、この実行時の Reddit API レスポンスまたは DuckDuckGo の検索結果に存在していなければなりません。すべてのメンバー数は、about.json API または検索スニペットから取得してください。推定してはいけません。すべての ICP シグナル件数は、生データと一致していなければなりません。ある種類のチャネルが 0 件の場合は、0 件と報告してください。チャネルを捏造してはいけません。
よくある間違い
| エージェントがやりがちなこと | それが誤りである理由 |
|---|---|
| 一般的なチャネル(「LinkedIn」「Twitter」など)を推奨する | すべてのチャネルには、具体的な名前、メンバー数、URLが必要です。「LinkedIn グループ:DevOps for Enterprise Teams(45Kメンバー)」のように記載し、単に「LinkedIn」としてはいけません。 |
| どの ICP にも同じチャネルを使う | シグナルの追跡結果は ICP に固有です。DevOps の ICP と Finance の ICP では、チャネルの一覧がまったく異なります。ICP ごとにスクリプトを新たに実行してください。 |
| メンバー数やコミュニティ名を捏造する | すべてのチャネル名は DuckDuckGo の検索結果または Reddit API から取得する必要があります。すべてのメンバー数は API または検索スニペットから取得してください。見つからない場合は「メンバー数が見つかりません」と記載してください。 |
| 競合の情報を省く | 競合について議論されている場所は、ICP が代替案を検討している場所であり、最も有望なアウトリーチの機会です。ユーザーから求められていなくても、必ず競合を検索してください。 |
| 製品の売り込みを参加方法として書く | 「r/devops で自社製品について投稿する」は参加方法ではありません。参加方法には、具体的なスレッドの種類、コンテンツ形式、コミュニティの慣習を記載してください。 |
| Reddit だけをチャネルの種類として扱う | 出力には少なくとも3種類のチャネルを含める必要があります。Reddit しか見つからない場合は、そこで止める前に DuckDuckGo で Slack/Discord/ニュースレター/カンファレンスを明示的に検索してください。 |
ステップ 1:セットアップの確認
echo "GITHUB_TOKEN: ${GITHUB_TOKEN:-not set -- competitor layer runs at 60 req/hr unauthenticated}"
echo ""
echo "Data sources this run will use:"
echo " Reddit public JSON (no auth, signal-trace)"
echo " Reddit about.json (no auth, subreddit metadata)"
echo " HN Algolia API (no auth, signal-trace)"
echo " DuckDuckGo HTML (no auth, channel discovery)"
echo " GitHub API (${GITHUB_TOKEN:+authenticated, }optional for competitor enrichment)"
GITHUB_TOKEN が設定されていない場合:そのまま続行してください。チャネルの主要な発見機能は、これがなくても動作します。
ステップ 2:ICP を解析する
会話から以下を収集します。
product-- 製品が何をするか(1文)icp_role-- ICP がどのような人か(例:「技術系共同創業者」「シリーズAのDevOpsエンジニア」)icp_pain-- 主な課題(例:「顧客獲得」「アラート疲れ」)category-- 市場カテゴリのキーワード(例:「startup gtm sales」「devops monitoring」)competitors-- 競合名(任意、例:「Clay、Apollo、HubSpot」)
ICP の段階的な確認:
- ユーザーのプロンプトに product + icp_role + icp_pain が含まれている場合:直接抽出して進めます。
- プロンプトの情報が少ない場合(カテゴリのみ、または製品名のみ):保存済みの ICP プロフィールを
docs/icp.mdで確認します。プロンプトの詳細と統合します。 - それでも情報が不十分な場合(icp_role または icp_pain が不足):次の3つの質問を、1つずつ尋ねます。
- 「製品が何をするか、1文で説明してください。」
- 「理想的な顧客はどのような人ですか?(役割、企業の種類、チーム規模)」
- 「製品を見つける前に、顧客が抱えている主な課題は何ですか?」
- 最終的な ICP を
docs/icp.mdに保存し、ほかのスキルでも再利用できるようにします。
docs/icp.md にこの製品の情報がまだ含まれていない場合は、ICP ファイルを保存します。
python3 << 'PYEOF'
import json, os
icp = {
"product": "PRODUCT_HERE",
"icp_role": "ICP_ROLE_HERE",
"icp_pain": "ICP_PAIN_HERE",
"competitors": ["COMP_1", "COMP_2"],
"category": "CATEGORY_HERE"
}
os.makedirs("docs", exist_ok=True)
with open("/tmp/wcl-input.json", "w") as f:
json.dump(icp, f, indent=2)
# Update docs/icp.md
icp_md_path = "docs/icp.md"
new_block = f"""## {icp['product']}
- **ICP role:** {icp['icp_role']}
- **ICP pain:** {icp['icp_pain']}
- **Competitors:** {', '.join(icp['competitors']) if icp['competitors'] else 'none'}
- **Category:** {icp['category']}
"""
existing = open(icp_md_path).read() if os.path.exists(icp_md_path) else ""
if icp['product'] not in existing:
with open(icp_md_path, "a") as f:
f.write(new_block)
print(f"ICP saved to {icp_md_path}")
else:
print(f"ICP already in {icp_md_path}")
print(f"Product: {icp['product']}")
print(f"ICP role: {icp['icp_role']}")
print(f"ICP pain: {icp['icp_pain']}")
print(f"Competitors: {', '.join(icp['competitors']) if icp['competitors'] else 'none'}")
PYEOF
ステップ 3:スタンドアロンのデータ収集スクリプトを実行する
スクリプトが存在するか確認します。
ls scripts/fetch.py 2>/dev/null && echo "script available" || echo "not found"
チャネルの発見を実行します。
GITHUB_TOKEN="${GITHUB_TOKEN:-}" python3 scripts/fetch.py \
"$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['category'])")" \
--icp-role "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['icp_role'])")" \
--icp-pain "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['icp_pain'])")" \
--product "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(d['product'])")" \
--competitors "$(python3 -c "import json; d=json.load(open('/tmp/wcl-input.json')); print(','.join(d['competitors']))")" \
--output /tmp/wcl-raw.json
完了するまで待ちます(最大5分まで許容してください。Reddit と DuckDuckGo の検索には合計で約120秒かかります)。
出力を検証します。
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
print(f'Reddit posts found: {d[\"reddit_posts_found\"]}')
print(f'HN signals found: {d[\"hn_signals_found\"]}')
print(f'Channels discovered: {d[\"summary\"][\"total_channels\"]}')
print(f'Top priority: {len(d[\"summary\"][\"top_priority\"])}')
print(f'By type: {d[\"summary\"][\"by_type\"]}')
print(f'Competitor layer ran: {d[\"summary\"][\"competitor_layer_ran\"]}')
"
total_channels < 3 の場合、ユーザーに「見つかったチャネルは3件未満です。ICP の説明が Reddit/DDG でカバーできる範囲に対して狭すぎる可能性があります。カテゴリのキーワードを広げるか、競合レイヤーを有効にするために競合名を追加してください。」と伝えてください。その後、カテゴリのキーワードを広げて1回だけ再試行してから終了します。
ステップ 4:チャンネルの概要を出力する
生データを読み込み、順位付けした概要テーブルを出力します:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
channels = d['channels_discovered']
print(f'Channels found: {len(channels)}')
print()
print(f'{'#':<4} {'Channel':<35} {'Type':<14} {'Members':<12} {'ICP signals':<13} {'Score':<8} Tier')
print('-' * 100)
for i, ch in enumerate(channels[:15], 1):
members = ch.get('members', 0)
m_str = f'{members//1000}K' if members >= 1000 else str(members) if members else '?'
print(f'{i:<4} {ch[\"name\"]:<35} {ch[\"type\"]:<14} {m_str:<12} {ch.get(\"icp_signal_count\",0):<13} {ch.get(\"channel_score\",0):<8} {ch.get(\"tier\",\"\")}')
"
最もスコアの高いチャンネルから、根拠となる投稿の上位 3 件を出力します:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
channels = d['channels_discovered']
if channels:
top = channels[0]
print(f'Top channel: {top[\"name\"]}')
print(f'Evidence posts:')
for ep in top.get('evidence_posts', [])[:3]:
print(f' [{ep.get(\"score\",0):.0f}] {ep.get(\"title\",\"\")}')
print(f' {ep.get(\"url\",\"\")}')
"
ステップ 5:AI によるチャンネル情報の補完
チャンネルの生データが揃いました。最優先および高優先度の各チャンネルについて、プレイブックの項目を作成します。
すべてのチャンネルを読み込みます:
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
d = json.load(f)
top_channels = [ch for ch in d['channels_discovered'] if ch.get('tier') in ('top-priority', 'high')]
print(json.dumps(top_channels, indent=2))
"
上記の各チャンネルについて、以下を作成します:
who_is_here: このチャンネルにいる具体的な ICP のタイプを説明する 2 文です。根拠となる投稿、サブレディットの説明、ICP プロフィールから導き出します。「ターゲットオーディエンス」とは書かず、具体的に説明してください。例:「専任の SRE チームがいない、従業員 50~500 人の企業でインフラスタックを担当する DevOps エンジニア。オンコール対応による疲弊、Kubernetes の乱立、クラウドネイティブとセルフホスト可能なオブザーバビリティの選択について投稿しています。」
entry_tactic: 具体的で実行可能な参入方法を 1 つ提示します。スレッドの種類、投稿形式、コミュニティの規範を明示してください。「コミュニティに参加する」では不十分です。例:「automoderator が毎週月曜日に投稿する『今週取り組んでいることは?』のスレッドを見つけます。解決した技術的課題について、何が壊れ、何を試し、何がうまくいったかを 3 文で返信します。製品には言及しません。独立した投稿をする前にカルマを積み上げます。」
content_angle: 根拠となる投稿のタイトルとスコアから導き出した、このチャンネルで最も高いエンゲージメントを得られるコンテンツ形式です。例:「このチャンネルでは、技術的な事後分析が製品発表を 5 倍上回ります。形式:『20 万人のユーザーを X から Y に移行しました。何が壊れたのか、そしてその理由を紹介します。』具体的な数値と失敗したことが、最も多くの賛成票を集めます。」
anti_patterns: このコミュニティで投稿が削除されたり、評判を大きく損なったりする具体的な行動を 2~3 件挙げます。サブレディットのルール(説明に記載されている場合)と、根拠となる投稿の傾向から導き出します。例:["宣伝目的ではないスレッドに製品リンクを投稿する――モデレーターが数時間以内に削除します", "具体的な背景なしに「どんなツールを使っていますか?」と尋ねる――市場調査目的の情報収集と見なされ、報告されます"]
補完したプレイブックを /tmp/wcl-channels.json に書き込みます:
{
"playbook": [
{
"channel": "r/devops",
"evidence": "34 ICP signals traced here, avg pain score 180",
"who_is_here": "...",
"entry_tactic": "...",
"content_angle": "...",
"anti_patterns": ["...", "..."]
}
]
}
python3 -c "
import json
with open('/tmp/wcl-channels.json') as f:
d = json.load(f)
print(f'Playbook entries: {len(d[\"playbook\"])}')
for p in d['playbook']:
print(f' {p[\"channel\"]}')
"
ステップ 6:順位付きの完全な出力を生成する
順位付きの完全なプレイブックを /tmp/wcl-output.json に書き込みます:
python3 << 'PYEOF'
import json
from datetime import datetime
with open('/tmp/wcl-input.json') as f:
inp = json.load(f)
with open('/tmp/wcl-raw.json') as f:
raw = json.load(f)
with open('/tmp/wcl-channels.json') as f:
enriched = json.load(f)
playbook_by_channel = {p['channel']: p for p in enriched['playbook']}
channels = raw['channels_discovered']
output = {
"date": raw['date'],
"product": inp['product'],
"icp_role": inp['icp_role'],
"icp_pain": inp['icp_pain'],
"competitors": inp.get('competitors', []),
"total_channels": raw['summary']['total_channels'],
"channels": []
}
for ch in channels:
name = ch['name']
playbook = playbook_by_channel.get(name, {})
output['channels'].append({
"rank": channels.index(ch) + 1,
"name": name,
"type": ch['type'],
"url": ch['url'],
"members": ch.get('members', 0),
"active_users": ch.get('active_users', 0),
"icp_signal_count": ch.get('icp_signal_count', 0),
"competitor_mentions": ch.get('competitor_mentions', 0),
"channel_score": ch.get('channel_score', 0),
"tier": ch.get('tier', ''),
"entry_type": ch.get('entry_type', 'open'),
"evidence_posts": ch.get('evidence_posts', []),
"who_is_here": playbook.get('who_is_here', ''),
"entry_tactic": playbook.get('entry_tactic', ''),
"content_angle": playbook.get('content_angle', ''),
"anti_patterns": playbook.get('anti_patterns', []),
})
with open('/tmp/wcl-output.json', 'w') as f:
json.dump(output, f, indent=2)
print(f"Output written: /tmp/wcl-output.json")
print(f"Total channels: {len(output['channels'])}")
PYEOF
ステップ 7:セルフ QA
python3 -c "
import json
with open('/tmp/wcl-raw.json') as f:
raw = json.load(f)
with open('/tmp/wcl-output.json') as f:
output = json.load(f)
full_text = json.dumps(output)
raw_channel_names = {ch['name'].lower() for ch in raw['channels_discovered']}
passes = 0
fails = 0
# Check 1: No em dashes
if chr(8212) in full_text:
print('FAIL: em dash found in output -- replace with hyphen')
fails += 1
else:
print('PASS: no em dashes')
passes += 1
# Check 2: No banned words
banned = ['powerful', 'robust', 'seamless', 'innovative', 'game-changing',
'streamline', 'leverage', 'transform', 'revolutionize']
found = [w for w in banned if w.lower() in full_text.lower()]
if found:
print(f'FAIL: banned words: {found}')
fails += 1
else:
print('PASS: no banned words')
passes += 1
# Check 3: At least 3 channel types
types = {ch['type'] for ch in output['channels']}
if len(types) < 3:
print(f'FAIL: only {len(types)} channel type(s) in output: {types}')
fails += 1
else:
print(f'PASS: {len(types)} channel types: {types}')
passes += 1
# Check 4: All channel names exist in raw data
for ch in output['channels']:
if ch['name'].lower() not in raw_channel_names:
print(f'FAIL: channel not in raw data: {ch[\"name\"]}')
fails += 1
if fails == 0:
print('PASS: all channel names verified in raw data')
passes += 1
# Check 5: No generic entry tactics
generic_phrases = ['engage with the community', 'post about your product', 'share your content']
for ch in output['channels']:
tactic = ch.get('entry_tactic', '').lower()
for phrase in generic_phrases:
if phrase in tactic:
print(f'FAIL: generic entry tactic in {ch[\"name\"]}: contains \"{phrase}\"')
fails += 1
if fails == 0:
print('PASS: entry tactics are channel-specific')
print()
print(f'Result: {passes} passed, {fails} failed')
if fails > 0:
print('Fix failures before saving.')
else:
print('All checks passed. Ready to save.')
"
ステップ 8 に進む前に、失敗項目を修正します。
ステップ 8:出力を保存してクリーンアップする
python3 << 'PYEOF'
import json, os, re
from datetime import datetime
with open('/tmp/wcl-input.json') as f:
inp = json.load(f)
with open('/tmp/wcl-raw.json') as f:
raw = json.load(f)
with open('/tmp/wcl-output.json') as f:
output = json.load(f)
slug = re.sub(r'[^a-z0-9]+', '-', (inp.get('icp_role') or inp['category']).lower()).strip('-')[:40]
date = datetime.now().strftime('%Y-%m-%d')
os.makedirs('docs/channel-map', exist_ok=True)
outpath_md = f"docs/channel-map/{slug}-{date}.md"
outpath_json = f"docs/channel-map/{slug}-{date}.json"
channels = output['channels']
by_type = {}
for ch in channels:
by_type.setdefault(ch['type'], []).append(ch)
lines = [
f"# Where Your Customer Lives: {inp['product'] or inp['category'].title()}",
f"ICP: {inp['icp_role']} | Date: {date} | Channels found: {len(channels)}",
"",
"---",
"",
"## Channel Ranking",
"",
]
tier_labels = {"top-priority": "TOP PRIORITY", "high": "HIGH", "medium": "MEDIUM", "low": "LOW"}
for ch in channels:
members = ch.get('members', 0)
m_str = f"{members//1000}K" if members >= 1000 else str(members) if members else "member count not found"
tier_label = tier_labels.get(ch.get('tier', ''), ch.get('tier', '').upper())
lines.append(f"### #{ch['rank']}: {ch['name']} [score: {ch['channel_score']}] -- {tier_label}")
active = ch.get('active_users', 0)
active_str = f" | Active: {active//1000}K/day" if active >= 1000 else f" | Active: {active}/day" if active else ""
lines.append(f"Type: {ch['type'].title()} | Members: {m_str}{active_str} | {ch.get('entry_type', 'open').title()} to join")
evidence_str = f"{ch['icp_signal_count']} ICP signals traced here" if ch['icp_signal_count'] > 0 else "Discovered via DuckDuckGo search"
lines.append(f"Evidence: {evidence_str}")
if ch.get('competitor_mentions', 0) > 0 and inp.get('competitors'):
lines.append(f"Competitor mentions: {ch['competitor_mentions']} across {', '.join(inp['competitors'][:3])}")
lines.append("")
if ch.get('who_is_here'):
lines.append(f"**Who is here:** {ch['who_is_here']}")
lines.append("")
if ch.get('entry_tactic'):
lines.append(f"**Entry tactic:** {ch['entry_tactic']}")
lines.append("")
if ch.get('content_angle'):
lines.append(f"**Content angle:** {ch['content_angle']}")
lines.append("")
if ch.get('anti_patterns'):
lines.append("**Anti-patterns:**")
for ap in ch['anti_patterns']:
lines.append(f"- {ap}")
lines.append("")
lines.append("---")
lines.append("")
lines += [
"## Channel Summary by Type",
"",
"| Type | Count | Best channel | Score |",
"|---|---|---|---|",
]
for ch_type, chs in sorted(by_type.items(), key=lambda x: -max(c['channel_score'] for c in x[1])):
best = max(chs, key=lambda x: x['channel_score'])
lines.append(f"| {ch_type.title()} | {len(chs)} | {best['name']} | {best['channel_score']} |")
lines += [
"",
"---",
"",
"## Data Quality Notes",
f"- All channel names exist in Reddit API response or DuckDuckGo search results",
f"- Member counts from Reddit about.json API or search snippets",
f"- ICP signal counts match raw data ({raw['reddit_posts_found']} Reddit posts, {raw['hn_signals_found']} HN signals)",
f"- Competitor layer ran: {raw['summary']['competitor_layer_ran']}",
f"- Sources: Reddit signal-trace, HN signal-trace, DuckDuckGo channel discovery",
"",
f"Saved to: {outpath_md}",
f"JSON snapshot: {outpath_json}",
]
with open(outpath_md, 'w') as f:
f.write('\n'.join(lines))
# JSON snapshot
snapshot = {
"input": inp,
"channels": channels,
"summary": raw['summary'],
"date": date,
}
with open(outpath_json, 'w') as f:
json.dump(snapshot, f, indent=2)
print(f"Report saved: {outpath_md}")
print(f"JSON snapshot: {outpath_json}")
PYEOF
一時ファイルをクリーンアップします:
rm -f /tmp/wcl-input.json /tmp/wcl-raw.json /tmp/wcl-channels.json /tmp/wcl-output.json
echo "Done. Channel map saved to docs/channel-map/"
保存した .md ファイルの内容全体をユーザーに提示します。