個人開発の合間に少しずつ書き足してきた自動化スキルが、半年で4本たまっていました。どれも自分の手元では気持ちよく動きます。せっかくなのでプラグインにまとめ、同じような作業をしている知人に渡してみました。
数日後の返事は短いものでした。「入れてみたけど、何も起きないです」。
こちらでは動いている。向こうでは動かない。よくある話ではありますが、原因を1つずつ剥がしていくうちに、自分が何を暗黙の前提にしていたのかがはっきり見えてきました。この記事は、そのときの記録です。
まず「動かない」の中身を分解する
「動かない」には少なくとも3つの段階があります。
| 段階 | 症状 | 切り分けの合図 |
| 発火しない | スキルが呼ばれず、Claude が素の応答を返す | ツール呼び出しのログに Skill が出てこない |
| 発火するが落ちる | 呼ばれた直後にエラーで停止する | パス・トークン・権限のエラーメッセージが出る |
| 完走するが結果が違う | 最後まで走るが出力が期待と食い違う | ログはすべて成功、成果物だけがおかしい |
知人の環境で起きていたのは、いちばん上の「発火しない」でした。ここを飛ばして中身のデバッグに入ると、延々と見当違いの場所を掘ることになります。
私は最初、それをやりました。コネクタの設定を疑い、権限を疑い、1時間ほど溶かしています。個人開発では自分がテスターも兼ねるので、疑う順番を間違えると誰も止めてくれません。実際にはスキルが一度も呼ばれていなかったので、中身がどうであれ関係がなかったのです。
発火率を数字にする
感覚で議論しても進みません。「たぶん description が悪い」では、直したかどうかも分かりません。
そこで、想定される発話を並べて、どのスキルが選ばれるかを記録する小さな検証を組みました。ヘッドレスの claude -p を回して、ツール呼び出しから Skill の名前だけを抜き出します。
まず、テストケースを1行1件の JSONL で用意します。
{"utterance": "先週の売上をまとめてレポートにして", "expect": "weekly-report"}
{"utterance": "週次レポート作って", "expect": "weekly-report"}
{"utterance": "今週のサマリーをください", "expect": "weekly-report"}
{"utterance": "溜まってる Issue を仕分けして", "expect": "issue-triage"}
{"utterance": "新しく来た issue にラベル付けといて", "expect": "issue-triage"}
{"utterance": "リリースノートの下書きを用意して", "expect": "release-notes"}
{"utterance": "今日の分の請求をチェックして", "expect": "billing-check"}
expect は「この発話ならこのスキルが選ばれてほしい」という期待値です。自分の口癖だけでなく、同じ意図を別の言い回しで表現したものを必ず混ぜます。ここが後で効いてきます。
続いて、それを流すスクリプトです。
#!/usr/bin/env bash
# skill-trigger-check.sh — 想定発話に対してどのスキルが選ばれるかを記録する
# usage: ./skill-trigger-check.sh cases.jsonl trigger-report.tsv
set -euo pipefail
CASES="${1:?usage: skill-trigger-check.sh <cases.jsonl> [out.tsv]}"
OUT="${2:-trigger-report.tsv}"
: > "$OUT"
while IFS= read -r line; do
[ -z "$line" ] && continue
utterance=$(jq -r '.utterance' <<<"$line")
expected=$(jq -r '.expect' <<<"$line")
# 1ターンだけ回し、Skill ツールの呼び出し先を取り出す。
# 実際の副作用を出さないため --max-turns 1 で止める。
actual=$(claude -p "$utterance" \
--output-format stream-json --verbose --max-turns 1 2>/dev/null \
| jq -rs '[ .[]
| select(.type == "assistant")
| .message.content[]?
| select(.type == "tool_use" and .name == "Skill")
| .input.skill ] | first // "none"')
if [ "$actual" = "$expected" ]; then status="MATCH"; else status="MISS"; fi
printf '%s\t%s\t%s\t%s\n' "$status" "$expected" "$actual" "$utterance" >> "$OUT"
done < "$CASES"
awk -F'\t' '
{ n++; if ($1 == "MATCH") m++ }
END { printf "発火率: %d/%d (%.0f%%)\n", m, n, (n ? m*100/n : 0) }
' "$OUT"
--max-turns 1 を付けているのは、検証のたびに本物の Issue にラベルが付いたら困るからです。知りたいのは「呼ばれるかどうか」だけなので、呼ばれた瞬間に止めます。jq -rs でストリームを配列にまとめてから最初の Skill 呼び出しを拾い、1件も無ければ none を返す形にしました。
自分の環境で走らせると、20件中18件が MATCH。90% です。手元で快適だったのは当然でした。
知人の環境で同じケースを流してもらった結果が、20件中5件。25% です。4本のスキルのうち、実質的に反応していたのは1本だけでした。
数字が出た瞬間、議論が具体になりました。MISS になった15件を眺めれば、何が起きているかは目に見えます。
前提1: description が自分の口癖に最適化されていた
MISS の内訳で最も多かったのが、これでした。
Cowork や Claude Code のスキルは、SKILL.md の frontmatter に書いた description を手がかりに選ばれます。つまり description は説明文ではなく、ルーティングの入力です。ここを人間向けの紹介文として書いていると、うまく届きません。
私が最初に書いていたものは、こうでした。
---
name: weekly-report
description: 週次レポートを生成するスキル
---
自分では困りませんでした。私はいつも「週次レポート作って」と言うからです。文字列がそのまま入っている以上、当たって当然でした。
知人は「今週のサマリーください」「先週の数字まとめて」と言います。どちらにも「週次」も「レポート」も入っていません。
書き直したものが、こちらです。
---
name: weekly-report
description: >-
直近1週間の売上・利用状況を集計し、Markdown のレポートにまとめて Slack に投稿する。
ユーザーが「週次レポート」「今週のサマリー」「先週の数字」「週報」「weekly report」
「先週どうだった」のように、一定期間を振り返る集計物を求めたときに使う。
単発の数値を1つ調べるだけの質問には使わない(その場合は直接クエリを実行する)。
月次・四半期の集計には使わない(monthly-report を使う)。
---
書き換えの規則は3つに整理できました。
- 意図を書く。機能名を書かない。「週次レポートを生成する」ではなく「一定期間を振り返る集計物を求めたとき」。読者の言葉ではなく、読者の状況を書きます
- 言い回しの束を入れる。同義語・カジュアルな言い方・英語表記まで並べます。自分が使わない表現ほど価値があります
- 使わない条件を明示する。「〜には使わない」の一文が、隣のスキルとの境界を引きます
3つめが効いたのは意外でした。肯定的な条件だけを厚くしていくと、似たスキル同士が同じ発話を取り合います。「使わない」を書いたほうが、境界線がはっきりします。
この書き直しだけで、知人の環境の発火率は 25% から 65% に上がりました。まだ十分ではありませんが、原因が1つ潰れたことは数字ではっきりしました。
なお description の設計そのものについては、Markdown で書くスキルファイルの基本 でも触れています。ルーティングの観点で読み直すと、また違う発見があるかもしれません。
前提2: パスが自分のフォルダ構成に埋まっていた
次に多かったのが、発火した後で静かに落ちるケースです。
スキルの手順に、こう書いていました。
1. `/Users/masaki/Dropbox/Workspace/data/sales.csv` を読み込む
2. 直近7日分に絞って集計する
自分の Mac では正しいパスです。他人の Mac では存在しません。
これは笑い話のようですが、書いている最中は気づけませんでした。動作確認が通ってしまうからです。自分の環境でテストしている限り、環境依存は絶対にテストに引っかかりません。
直し方はいくつかありますが、私は入力の場所を引数か環境変数に追い出し、README に必須項目として書く形に落ち着きました。
## 入力
- 売上データ: `${SALES_DATA_PATH}`(未設定ならユーザーに場所を尋ねる)
- 出力先チャンネル: `${SLACK_REPORT_CHANNEL}`(既定: #reports)
`SALES_DATA_PATH` が設定されておらず、ユーザーも場所を答えられない場合は、
推測して別のファイルを読まずにその場で停止する。
最後の一文を足したのは、経験からです。パスが見つからないとき、Claude は親切心から似たファイルを探しにいきます。関係のない CSV を読んで、それらしいレポートを作り、完走します。これがいちばん厄介な壊れ方でした。エラーが出ないので、誰も気づきません。
見つからないなら止まる。曖昧なら尋ねる。推測しない。この3行を書くかどうかで、他人の環境での安全性がまるで変わります。
前提3: 認証と権限が「すでに繋がっている」ことを当てにしていた
3つめは、自分でも書きながら苦笑しました。
私の環境では、GitHub も Slack も Google Drive も、とっくにコネクタが繋がっています。半年前に繋いだきり、意識したことすらありませんでした。プラグインの中身には、当然のようにそれらのツール呼び出しが並んでいます。
知人の環境には、Slack コネクタがありませんでした。プラグインは Slack への投稿で止まり、そこまでの集計結果は捨てられました。
ここで学んだのは、プラグインは自分の環境の「状態」ごと配れないということです。配れるのはファイルだけです。私の環境で6ヶ月かけて積み上がった接続・認可・設定は、ZIP の中に入りません。
対策として、スキルの先頭に前提の確認を置くようにしました。
## 実行前の確認
このスキルは以下の接続を前提とします。開始前に、必要なものが
利用可能かを確認し、欠けているものがあれば**処理を始める前に**
ユーザーに伝える。
| 必要なもの | 用途 | 欠けている場合 |
|---|---|---|
| GitHub コネクタ | Issue の取得と更新 | 中断してセットアップ手順を案内 |
| Slack コネクタ | レポートの投稿 | 投稿を省略し、結果をファイルに保存して続行 |
| SALES_DATA_PATH | 売上データの場所 | ユーザーに場所を尋ねる |
Slack が無いことは中断の理由にしない。集計まで終えて成果物を残す。
「欠けている場合」の列が肝でした。すべての依存を必須にしてしまうと、1つ足りないだけでプラグイン全体が使えません。逆にすべてを任意にすると、静かに劣化した結果が返ってきます。
依存ごとに「中断するのか、機能を落として続けるのか」を決めておく。この判断は作者にしかできません。使う側は、そもそも何が前提だったのかを知らないからです。
コネクタ側の設定と可観測性については、Cowork コネクタ活用ガイド と、接続の健全性をどう記録するかを扱ったMCP コネクタの可観測性を個人運用で組む が参考になると思います。
前提4: 他のスキルと競合しない、と思っていた
最後の1つは、数字を見るまで想像もしていませんでした。
MISS のログを眺めていると、「溜まってる Issue を仕分けして」に対して、私の issue-triage ではなく、知人が既に入れていた別プラグインのスキルが選ばれていました。
私の環境には、そのプラグインがありません。だから競合が起きようがなかった。私は空いた部屋で家具を配置していて、知人の部屋には既に家具があった、という話です。
配布するプラグインは、常に他人のスキル群の中に置かれます。単体で最適なのは、当たり前ですが何の保証にもなりません。
これに対しては、完全な解決策を持っていません。他人がどのプラグインを入れているかは分からないからです。ただ、被害を減らす方法はいくつか見つかりました。
- 名前に固有性を持たせる。
report ではなく acme-weekly-sales-report。汎用的な名前ほど衝突します
- description の「使わない条件」を厚くする。前提1で書いた3つめの規則が、ここでも効きます
- README に「競合しうるスキル」を書く。知っている範囲でよいので、先に伝えておきます
3つを入れて再測定した結果が、20件中17件、85% でした。私の環境の 90% には届きませんが、実用の範囲に入りました。
ここまでの推移を並べると、こうなります。
| 段階 | 知人の環境での発火率 | 主に効いた変更 |
| 配布した直後 | 5/20(25%) | — |
| description 書き直し後 | 13/20(65%) | 言い回しの束+使わない条件 |
| パス・依存の外出し後 | 15/20(75%) | 環境変数化+前提確認テーブル |
| 命名の固有化後 | 17/20(85%) | プレフィックス付きスキル名 |
配布前に30分で回す preflight
同じ失敗を繰り返さないよう、機械で見つけられるものは機械に任せました。絶対パス・素の資格情報・未文書の環境変数・汎用的すぎる名前を洗い出すスクリプトです。
#!/usr/bin/env bash
# plugin-preflight.sh — 配布前に環境依存の痕跡を洗い出す
# usage: ./plugin-preflight.sh ./my-plugin
set -uo pipefail
DIR="${1:?usage: plugin-preflight.sh <plugin-dir>}"
FILES=(--include='*.md' --include='*.json' --include='*.sh' --include='*.yaml')
fail=0
section() { printf '\n== %s ==\n' "$1"; }
ng() { printf ' NG %s\n' "$1"; fail=1; }
ok() { printf ' ok %s\n' "$1"; }
section "ユーザー固有の絶対パス"
if hits=$(grep -rnE '(/Users/[^/[:space:]"]+|/home/[^/[:space:]"]+|C:\\\\Users\\\\)' "$DIR" "${FILES[@]}"); then
printf '%s\n' "$hits" | sed 's/^/ /'
ng "個人のホームディレクトリが埋め込まれています"
else
ok "検出なし"
fi
section "素の資格情報"
if hits=$(grep -rnE '(sk-ant-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})' "$DIR" "${FILES[@]}"); then
printf '%s\n' "$hits" | sed 's/^/ /'
ng "資格情報らしき文字列があります(YOUR_API_KEY 等に置き換えてください)"
else
ok "検出なし"
fi
section "参照されている環境変数が README に書かれているか"
# パイプではなくプロセス置換にする。パイプだと while がサブシェルになり fail が伝わらない。
while read -r var; do
[ -z "$var" ] && continue
if grep -q -- "$var" "$DIR/README.md" 2>/dev/null; then
ok "$var (README に記載あり)"
else
ng "$var (README に記載なし — 利用者が設定できません)"
fi
done < <(grep -rhoE '\$\{[A-Z][A-Z0-9_]{2,}\}' "$DIR" "${FILES[@]}" | tr -d '${}' | sort -u)
section "衝突しやすいスキル名"
while read -r skill; do
base=$(basename "$(dirname "$skill")")
case "$base" in
report|triage|sync|analyze|publish|notify|check|update)
ng "$base (汎用的すぎます。組織や用途のプレフィックスを付けてください)" ;;
*) ok "$base" ;;
esac
done < <(find "$DIR" -name 'SKILL.md' 2>/dev/null)
section "結果"
if [ "$fail" -eq 0 ]; then
echo " 配布前チェックを通過しました"
else
echo " 上記の NG を解消してから配布してください"
fi
exit "$fail"
環境変数の検査で while ... done < <(...) を使っているのは、パイプで渡すと while がサブシェルで動き、fail=1 が呼び出し元に伝わらないためです。ここは実際に一度踏みました。すべて NG なのにスクリプトが 0 で終了し、CI が緑になっていました。
機械で見つかるのは、あくまで痕跡だけです。description の質と依存の設計は、結局のところ人が判断するしかありません。それでも、絶対パスの混入のような単純な事故は、これで二度と起きなくなりました。
機械で見つからないものは、人の環境で見つける
preflight を通しても、発火率は測れません。最後に残るのは、他人の環境で動かしてみることです。
完全に他人になることはできませんが、近づくことはできます。私は次の順で確認するようにしました。
- 新規のプロファイルで試す。既存の設定・接続が無い状態から入れ直します
- 自分が使わない言い回しでケースを書く。これがいちばん難しく、いちばん効きます。自分の語彙から出るには、他人に発話例を書いてもらうのが確実でした
skill-trigger-check.sh を流し、80% を下回ったら配らない。数字にしておくと、直したかどうかが分かります
- 実際に1人に渡し、最初の1週間だけログを見せてもらう。ここで出る MISS が、いちばん質の高いテストケースになります
4番目には気が引けました。動かないものを渡して手間をかけさせることになるからです。ただ、私の頭の中だけでは、私の語彙の外側は決して出てきません。最初の1人に付き合ってもらうことが、結局いちばん速い道でした。
次の一歩
もし手元で快調に動いているスキルを誰かに渡す予定があるなら、渡す前に cases.jsonl を10件だけ書いてみてください。ただし、自分では絶対に言わない言い回しで。
そこで発火率が9割を切ったなら、この記事で挙げた4つの前提のどれかが混ざっています。どれが原因かは、MISS のログが教えてくれます。
私自身、まだ手探りの部分が多く残っています。特に他人のスキルとの衝突は、根本的な解決の道筋が見えていません。同じところで悩んでいる方と、いつか知見を交換できたら嬉しく思います。お読みいただきありがとうございました。