●TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 states●ADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls do●M365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePoint●MCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessions●SUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json output●DEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8●TEACHERS — Anthropic launches Claude for Teachers, giving verified US K-12 educators free premium access, teaching skills, and curriculum links aligned to standards in all 50 states●ADMIN — The Admin API is now in beta for every Claude Enterprise organization: member and invite calls need no beta header, while group and custom-role calls do●M365 — The Microsoft 365 connector gains write tools — draft, send, and organize email, manage calendar events and mailbox settings, and create or update files in OneDrive and SharePoint●MCP — Fixed servers from --mcp-config or .mcp.json ignoring a per-server request_timeout_ms, which left long-running tool calls timing out at the 60s default in fresh sessions●SUBAGENT — A new --forward-subagent-text flag and CLAUDE_CODE_FORWARD_SUBAGENT_TEXT variable include subagent text and thinking in stream-json output●DEADLINE — Opus 4.7 fast mode is removed on July 24, seven days out. speed: "fast" will error, so confirm your move to Opus 4.8
It Worked on My Machine, but Nobody Could Trigger It — Four Assumptions I Stripped Out of a Cowork Plugin
I bundled four working automation skills into a plugin and shared it. Only one of them ever fired. Here is how I measured skill trigger rate, and the four assumptions — vocabulary, paths, connections, and naming — I had to strip out before it was portable.
Working as an indie developer, I had accumulated four automation skills over six months, written a piece at a time between other work. All of them ran beautifully on my machine. So I packaged them into a plugin and handed it to a friend doing similar work.
His reply, a few days later, was short. "Installed it. Nothing happens."
Works here. Doesn't work there. An old story — but peeling the layers off one at a time showed me exactly what I had been quietly assuming. This is that record.
Break "it doesn't work" into stages
"It doesn't work" covers at least three different failures.
Stage
Symptom
How you spot it
Never fires
Claude answers normally, skill is never invoked
No Skill tool call appears in the log
Fires, then dies
Invoked, then stops on an error
Path, token, or permission errors surface
Finishes, wrong output
Runs to completion, result is off
Every step logs success; only the artifact is wrong
What was happening on his machine was the first one. If you skip past that and start debugging the body of the skill, you will dig in the wrong place for a long time.
I did exactly that. I suspected the connectors, then the permissions, and burned about an hour. The skill had never been called once, so nothing inside it mattered. When you are the only tester on your own tools, getting the order of suspicion wrong costs you the whole afternoon.
Turn trigger rate into a number
Arguing from intuition goes nowhere. "The description is probably bad" gives you no way to tell whether you fixed it.
So I wrote a small harness: replay a list of plausible utterances, record which skill gets picked. It runs claude -p headless and pulls the skill name out of the tool calls.
Test cases go in a JSONL file, one per line.
{"utterance": "pull together last week's sales into a report", "expect": "weekly-report"}{"utterance": "generate the weekly report", "expect": "weekly-report"}{"utterance": "can I get a summary for this week", "expect": "weekly-report"}{"utterance": "sort through the issues that piled up", "expect": "issue-triage"}{"utterance": "label the new issues that came in", "expect": "issue-triage"}{"utterance": "draft the release notes", "expect": "release-notes"}{"utterance": "check today's billing", "expect": "billing-check"}
expect is the skill you want chosen for that phrasing. Mix in the same intent said several different ways, not just the way you happen to say it. That matters more than it looks.
Here is the runner.
#!/usr/bin/env bash# skill-trigger-check.sh — record which skill gets picked for each sample utterance# usage: ./skill-trigger-check.sh cases.jsonl trigger-report.tsvset -euo pipefailCASES="${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") # One turn only. We want to know whether it fires, not to let it # actually relabel a real issue every time we test. 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 "trigger rate: %d/%d (%.0f%%)\n", m, n, (n ? m*100/n : 0) }' "$OUT"
--max-turns 1 keeps the test from doing real work. All I want to know is whether the skill gets picked, so I stop the moment it is. jq -rs collects the stream into an array, grabs the first Skill call, and falls back to none when there isn't one.
On my machine: 18 of 20 matched. 90%. No wonder it felt smooth.
On his machine, same cases: 5 of 20. 25%. Of four skills, exactly one was responding at all.
The moment there was a number, the conversation got concrete. Fifteen MISS rows tell you plainly what is going on.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A headless `claude -p` harness that replays 20 sample utterances and records which skill was picked — tracking the move from 25% to 85%
✦Three rules for rewriting a SKILL.md description from prose into routing input, with a concrete before and after
✦A bash preflight script, CI-ready as-is, that catches absolute paths, raw credentials, undocumented env vars, and collision-prone skill names
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Assumption 1: the description was tuned to my own vocabulary
This accounted for most of the misses.
Cowork and Claude Code pick skills using the description in the SKILL.md frontmatter. Which means the description is not documentation — it is routing input. Write it as a human-facing blurb and it will not route.
Mine started out like this.
---name: weekly-reportdescription: Generates the weekly report---
It never failed me. I always say "generate the weekly report," so the literal string was right there in my prompt.
My friend says "can I get a summary for this week" and "pull last week's numbers." Neither contains "weekly" or "report."
The rewrite:
---name: weekly-reportdescription: >- Aggregates the last seven days of sales and usage data into a Markdown report and posts it to Slack. Use when the user asks for any retrospective rollup over a recent period — "weekly report", "summary for this week", "last week's numbers", "how did last week go", "week in review". Do not use for a single one-off metric lookup (query it directly instead). Do not use for monthly or quarterly rollups (use monthly-report).---
Three rules came out of the rewrite.
Describe the intent, not the feature. Not "generates the weekly report" but "when the user asks for a retrospective rollup over a recent period." Write the situation, not your wording of it.
Include the cluster of phrasings. Synonyms, casual forms, whatever people actually type. The phrasings you would never use are the valuable ones.
State when not to use it. One "do not use for..." line draws the border with the skill next door.
The third one surprised me. Pile up only positive conditions and neighboring skills start fighting over the same utterance. Negative conditions are what make the boundary legible.
That rewrite alone took his trigger rate from 25% to 65%. Not good enough yet, but one cause was provably gone.
I touched on description design in Writing Skill Files in Markdown as well; rereading it through a routing lens surfaces different things.
Assumption 2: paths were welded to my folder layout
The next cluster: fires, then dies quietly.
The skill said, in plain text:
1. Read `/Users/masaki/Dropbox/Workspace/data/sales.csv`2. Filter to the last seven days and aggregate
Correct path on my Mac. Nonexistent on anyone else's.
It reads like a joke, but I could not see it while writing. The tests passed. As long as you test in your own environment, environment coupling is invisible by construction.
I settled on pushing the input location out to an environment variable and listing it as required in the README.
## Inputs- Sales data: `${SALES_DATA_PATH}` (if unset, ask the user where it lives)- Destination channel: `${SLACK_REPORT_CHANNEL}` (default: #reports)If `SALES_DATA_PATH` is unset and the user cannot say where the file is,stop there. Do not guess at a similar-looking file.
That last line is earned. When a path is missing, Claude helpfully goes looking for something nearby. It reads an unrelated CSV, produces a plausible report, and finishes clean. That was by far the worst failure mode — no error, so nobody notices.
If it isn't there, stop. If it's ambiguous, ask. Never guess. Those three lines change how safe the plugin is in someone else's hands.
Assumption 3: I assumed everything was already connected
The third one made me wince at my own code.
On my machine, GitHub and Slack and Google Drive have been connected for ages. I wired them up six months ago and have not thought about it since. The plugin naturally calls all of them.
My friend had no Slack connector. The plugin died on the post step, and the aggregation it had already finished was thrown away.
The lesson: you cannot ship your environment's state, only its files. Six months of connections, authorizations, and settings do not fit in the ZIP.
So I put a dependency check at the top of each skill.
## Before runningThis skill depends on the following. Check availability first and tell theuser about anything missing **before starting work**.| Requirement | Used for | If missing ||---|---|---|| GitHub connector | Fetching and updating issues | Stop, explain setup || Slack connector | Posting the report | Skip the post, save the file, continue || SALES_DATA_PATH | Location of the sales data | Ask the user |A missing Slack connector is not a reason to abort. Finish the aggregationand leave the artifact behind.
The "if missing" column is the important one. Make every dependency mandatory and one gap kills the whole plugin. Make them all optional and you get silently degraded results.
Decide, per dependency, whether to abort or degrade. Only the author can make that call — the person installing it has no idea what the assumptions were.
Assumption 4: I assumed nothing else would compete
The last one never crossed my mind until I read the logs.
For "sort through the issues that piled up," the winner was not my issue-triage. It was a skill from a different plugin my friend already had installed.
I don't have that plugin. Collisions were impossible in my world. I had been arranging furniture in an empty room; his room already had furniture in it.
A distributed plugin always lands inside someone else's collection of skills. Being optimal in isolation guarantees nothing.
I don't have a complete answer here — you cannot know what else people have installed. But a few things reduce the damage.
Make names specific.acme-weekly-sales-report, not report. Generic names collide.
Lean on the "do not use for" lines. Rule three from Assumption 1 pays off again here.
List known conflicts in the README. Partial knowledge, shared early, still helps.
With all three in, the re-measurement came back 17 of 20. 85%. Short of my own 90%, but usable.
The full progression:
Stage
Trigger rate on his machine
What moved it
As shipped
5/20 (25%)
—
After description rewrite
13/20 (65%)
Phrasing cluster + negative conditions
After externalizing paths and deps
15/20 (75%)
Env vars + dependency table
After namespacing
17/20 (85%)
Prefixed skill names
A 30-minute preflight before you ship
Whatever a machine can catch, let the machine catch. This script hunts for absolute paths, raw credentials, undocumented env vars, and names that are too generic to survive contact with other plugins.
#!/usr/bin/env bash# plugin-preflight.sh — find environment coupling before distribution# usage: ./plugin-preflight.sh ./my-pluginset -uo pipefailDIR="${1:?usage: plugin-preflight.sh <plugin-dir>}"FILES=(--include='*.md' --include='*.json' --include='*.sh' --include='*.yaml')fail=0section() { printf '\n== %s ==\n' "$1"; }ng() { printf ' NG %s\n' "$1"; fail=1; }ok() { printf ' ok %s\n' "$1"; }section "User-specific absolute paths"if hits=$(grep -rnE '(/Users/[^/[:space:]"]+|/home/[^/[:space:]"]+|C:\\\\Users\\\\)' "$DIR" "${FILES[@]}"); then printf '%s\n' "$hits" | sed 's/^/ /' ng "A personal home directory is baked in"else ok "none found"fisection "Raw credentials"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 "Something looks like a credential (use YOUR_API_KEY instead)"else ok "none found"fisection "Referenced env vars documented in README"# Process substitution, not a pipe: a pipe puts the loop in a subshell# and fail=1 never reaches the caller.while read -r var; do [ -z "$var" ] && continue if grep -q -- "$var" "$DIR/README.md" 2>/dev/null; then ok "$var (documented)" else ng "$var (not in README — nobody can configure it)" fidone < <(grep -rhoE '\$\{[A-Z][A-Z0-9_]{2,}\}' "$DIR" "${FILES[@]}" | tr -d '${}' | sort -u)section "Collision-prone skill names"while read -r skill; do base=$(basename "$(dirname "$skill")") case "$base" in report|triage|sync|analyze|publish|notify|check|update) ng "$base (too generic — add an org or domain prefix)" ;; *) ok "$base" ;; esacdone < <(find "$DIR" -name 'SKILL.md' 2>/dev/null)section "Result"if [ "$fail" -eq 0 ]; then echo " preflight passed"else echo " resolve the NG items above before shipping"fiexit "$fail"
The env var check uses while ... done < <(...) rather than a pipe because a pipe runs the loop in a subshell and fail=1 never escapes it. I learned that the hard way: every check was failing and the script still exited 0, so CI stayed green.
A machine only finds traces. Description quality and dependency design still come down to judgment. But a hardcoded home directory has not slipped through since.
What machines miss, other people's machines find
Preflight cannot measure trigger rate. What's left is running it somewhere that isn't yours.
You can't become another person, but you can get closer. My order of operations now:
Start from a fresh profile. No existing settings, no existing connections.
Write cases in phrasings you don't use. The hardest step and the highest-value one. Getting someone else to write the sample utterances turned out to be the only reliable way out of my own vocabulary.
Run skill-trigger-check.sh; don't ship below 80%. With a number, you can tell whether the fix worked.
Hand it to exactly one person and read their logs for a week. The misses that show up there are the best test cases you will ever get.
Step four felt rude. You are handing someone something broken and asking for their time. But my head will never produce phrasings from outside my own head. Leaning on that first person was the fastest route.
Where to start
If you have a skill running happily on your machine and someone waiting for it, write ten lines of cases.jsonl before you ship — in phrasings you would never use yourself.
If the trigger rate lands under 90%, one of these four assumptions is in there. The MISS rows will tell you which.
I am still working this out, particularly the collision problem, which I have no principled answer for yet. If you are wrestling with the same thing, I would like to compare notes. Thank you for reading.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.