Tip: you can run any of these commands by asking Claude Code in your IDE — see Quick start in README.
This is the canonical reference for adding a Claude Code skill that wraps a YALC capability. Every new skill in .claude/skills/ starts here.
YALC ships three different things called "skills". They look similar, run on different runtimes, and live in different places. Only the first is in scope for this guide.
| System | Location | Runtime | What it is |
|---|---|---|---|
.claude/skills/ |
repo root | Claude Code reads SKILL.md frontmatter and routes by trigger phrase |
Conversational entry points. A user says "qualify these leads" in a Claude Code session, the matching skill activates and drives the conversation + CLI calls. This guide is about these. |
configs/skills/ |
repo root | Framework runner reads YAML at scheduled run time | Step definitions for framework:run — non-conversational, machine-driven, no human in the loop. |
~/.gtm-os/skills/ |
user home | skills:create wizard scaffolds + the runner reads at execute time |
User-authored skills generated by the skills:create wizard. Persisted per-user, never in the repo. |
If you're adding a conversational entry point — keep reading. If you're adding a framework step or a user-wizard skill, this is the wrong document.
Write a .claude/skills/<slug>/SKILL.md when:
- A specific phrasing (or family of phrasings) should always route to a specific YALC flow. "Qualify these leads", "score these prospects", "run leads through the gates" →
qualify-leads. - The user benefits from a guided conversation around the call (validating inputs, choosing between options, confirming destructive operations). The skill body is where that conversation lives.
- The flow is high-frequency or high-value enough to justify the maintenance cost. Wrapping every CLI command would dilute trigger phrases and make the linter scream.
Don't write a skill when:
- A user can call
pnpm cli <command>directly with one line of input. Adding a skill for trivial commands clutters the namespace and creates trigger collisions. - The flow is exploratory and isn't repeated. Use a one-off
/tmp/...mjsscript instead. - The work belongs in a framework runner step (multi-step batch over time) — that's
configs/skills/, not.claude/skills/.
YALC uses a hybrid runtime: shell-out for SIDE-EFFECTING commands, import-direct for PURE commands. The benchmark numbers below justify the split.
The default. Use this when the wrapped command writes to the database, calls an external API, modifies framework state, sends a message, or otherwise mutates something outside the read-only registry.
Reference: .claude/skills/provider-builder/SKILL.md. Read the whole thing before authoring your first shell-out skill.
Body skeleton:
Frontmatter:
name: <kebab-case slug>
description: |
Use when the user says <trigger phrase 1>, <trigger phrase 2>, ...
version: 1.0.0
Body:
1. Greeting + scope statement ("I'll wrap leads:qualify. I'll ask
three questions then call the CLI.").
2. Ask for inputs via natural conversation, one question at a time.
3. Local validation (URL format, file exists, required env var set
via `process.env.X ? 'set' : 'unset'` — never read the value).
4. Run the CLI:
Bash(cd ~/Desktop/gtm-os && set -a && source .env.local && set +a && \
npx tsx src/cli/index.ts <command> <args> --json)
5. Parse the CLI's JSON output. On non-zero exit, surface the CLI's
stderr verbatim — see "Failure surfacing" below.
6. Render success cleanly with a follow-up offer ("Want me to also
run campaign:track?").
Why shell-out is mandatory for side-effecting commands:
- The CLI command is the source of truth for environment loading, tenant resolution, diagnostics wrapping, and exit-code semantics. Re-implementing those in the skill body drifts.
- A user running
pnpm cli leads:qualify ...directly should get bit-for-bit the same outcome as the skill. Shell-out guarantees that. - DB writes, API calls, and Notion writes are all wired through
withDiagnostics()at the CLI layer. That wrapper reports failures to ops; bypassing it via direct import means broken alarms.
Use this only when the wrapped command:
- Reads from the registry / cache / local YAML / config file.
- Runs deterministic rules over those inputs.
- Returns the same answer for the same inputs every time.
- Never writes to disk, never calls the network, never calls Anthropic.
Tier 4 skills (list-adapters, show-routine, run-doctor) fit this pattern. The proposal generator at src/lib/routine/generator.ts is a worked example — pure rules over a few lookup values.
Body skeleton:
Frontmatter: same shape as Pattern A.
Body:
1. Greeting (often no input is needed — these are read-only).
2. Ask for inputs only if the function needs any.
3. Generate a tiny TS snippet at /tmp/yalc-skill-<slug>.mjs that:
- imports from the actual lib path (e.g.
file:///abs/path/to/src/cli/commands/adapters-list.ts)
- calls the function with the user's inputs
- prints JSON to stdout
4. Bash(cd ~/Desktop/gtm-os && npx tsx /tmp/yalc-skill-<slug>.mjs)
5. Parse + render.
6. **Fall back to shell-out if the inline runner errors.** This gives
import-direct the safety of shell-out without committing to it
forever. If a "pure" function later grows side effects, the
fallback catches it instead of silently producing stale data.
The fallback rule is non-negotiable. Pure functions sometimes grow side effects under refactor (a cache write, a telemetry call). The fallback means the skill keeps working through the regression while a maintainer fixes the lib.
The split exists because chaining three CLI commands in a row is slow. Five trials each on this machine (M-series macOS, Node 20, tsx 4.x, dependencies symlinked from the repo's node_modules/):
| Scenario | min (ms) | median (ms) | max (ms) |
|---|---|---|---|
A: shell-out, single (adapters:list --json) |
736 | 764 | 1552 |
B: shell-out, chained ×3 (adapters:list + routine:propose + framework:list) |
2387 | 2550 | 3070 |
C: import-direct, single (runAdaptersList via inline tsx) |
699 | 723 | 738 |
D: import-direct, chained ×3 (single tsx, three imports) |
1329 | 1343 | 1441 |
Numbers from node scripts/bench-skill-runtime.mjs against ~/Desktop/gtm-os. Re-run after any meaningful change to src/cli/index.ts or package.json dependency tree.
Read the table this way:
- Single-command skills (A vs C). Shell-out adds ~40ms over import-direct (764 vs 723ms median). That's a rounding error on a flow the user is already waiting for. For single-command skills, shell out unconditionally — the engineering simplicity is worth more than 40ms.
- Chained skills (B vs D). Three shell-outs in series cost 2550ms vs one tsx with three imports at 1343ms — a ~47% reduction. That's the win. The Tier 4 chained reads (e.g. an "everything I need to know about my YALC" composite read) get import-direct.
- The Commander program loads a lot of stuff. Scenario A's 764ms is mostly Node + tsx + the full
src/cli/index.tslazy-import graph (98 commands' wires + dotenv + diagnostics). Scenario C's 723ms is Node + tsx + only the lib chainrunAdaptersListactually pulls. The 40ms gap underestimates the true Commander tax — under load (or on a colder machine), expect more.
Rule:
- Side-effecting? Always Pattern A (shell-out). No exceptions.
- Pure, single-command? Pattern A by default. The 40ms doesn't justify the maintenance cost of two patterns.
- Pure, three-or-more chained reads? Pattern B (import-direct). The chained subprocess tax compounds; the import-direct script lets you load the lib graph once.
The benchmark script writes its raw numbers to scripts/.bench-skill-runtime.last.json. The schema is documented and tested — docs/ and downstream consumers can rely on schemaVersion: 1.
Every skill activates on phrases extracted from its frontmatter description. The trigger-phrase linter at scripts/lint-skill-triggers.mjs runs in CI and rejects any merge that introduces a substring overlap.
Hard rules:
- Quote your trigger phrases inside the description. Use the same bulleted-list-of-quoted-phrases shape
provider-builder/SKILL.mduses. The linter expects this format. - Run the linter locally before submitting.
node scripts/lint-skill-triggers.mjsfrom the repo root. Exit 0 means clean. - Avoid bare verbs. "Send", "run", "check" — those collide with every other skill. Anchor the trigger to a noun + verb pair: "send cold email", "run leads qualification", "check campaign status".
- Avoid common collision patterns:
- Bare
analyze/report— overlaps with monthly-report, sentiment-analysis, campaign-intelligence at minimum. for [client]/for [campaign]— every client-scoped skill ends up matching.- First-person
my X— "my campaigns", "my leads" — picked up by every skill that touches a user-owned object.
- Bare
- Include both the formal name and the colloquial. "Qualify these leads" and "score these prospects" should both fire
qualify-leads. The linter checks for collisions, not coverage; coverage is on you.
When the wrapped CLI exits non-zero, surface its stderr verbatim in the chat. Do not summarise. Do not rewrite. Do not suppress.
Wrong:
The CLI failed because the API key is missing. Please set it.
Right:
leads:qualify failed (exit 2):
Error: ANTHROPIC_API_KEY is not set.
At src/lib/anthropic/client.ts:14
throw new Error('ANTHROPIC_API_KEY is not set.')
To fix: edit ~/.gtm-os/.env and re-run.
Why verbatim:
- The CLI's error messages are tested and stable. They include line numbers and remediation hints. Skills that paraphrase strip the actionable parts.
- Users debug by pasting the error into search. A summary breaks that.
- If the CLI message is bad, the fix is in the CLI, not in every skill that wraps it.
In Pattern A bodies, this is one line: pipe stderr to the chat. In Pattern B bodies, the inline tsx script's stderr already lands in the bash output — same result. If your fallback fires, surface both errors (the import-direct one AND the shell-out one) so the user can see whether the regression is in the lib or in the CLI.
| Surface | Format | Example |
|---|---|---|
| CLI command | area:command (colons) |
leads:qualify |
| Skill folder | verb-noun (kebab) |
qualify-leads |
| yalc.ai page | matches skill folder | yalc.ai/skills/qualify-leads/ |
The CLI keeps colon-namespacing because Commander's --help groups commands by area prefix. The skill folder uses natural-language order because trigger phrases match natural language. The yalc.ai slug matches the skill folder so users can hop from a LinkedIn post to the skill landing page to the GitHub source without translating between formats.
The 16 wrappers in the 0.13.0 plan map this way:
| CLI | Skill folder | Tier |
|---|---|---|
leads:qualify |
qualify-leads |
1 |
personalize |
personalize-message |
1 |
leads:scrape-post |
scrape-post-engagers |
1 |
campaign:create (+ create-sequence) |
launch-linkedin-campaign |
1 |
routine:propose (+ routine:install) |
build-routine |
1 |
signals:similar |
find-lookalikes |
2 |
signals:enrich |
enrich-with-signals |
2 |
research |
research-prospect |
2 |
competitive-intel |
run-competitive-intel |
2 |
leads:import |
import-leads |
3 |
email:send (+ email:create-sequence) |
send-cold-email |
3 |
campaign:track |
track-campaigns |
3 |
linkedin:answer-comments |
answer-linkedin-comments |
3 |
adapters:list |
list-adapters |
4 |
routine:propose (read-only) |
show-routine |
4 |
doctor |
run-doctor |
4 |
Tier 4 is the only tier that gets Pattern B. Everything else is Pattern A.
A user partway through yalc-gtm start (the Setup wizard) should not have a long-running skill kick off mid-Setup and fight for the same resources. The recommended check:
Pre-flight (do this before any other step):
Bash(test -f ~/.gtm-os/.in-flight-setup && echo "BLOCKED" || echo "OK")
If output is BLOCKED:
Stop. Tell the user:
"It looks like Setup is mid-flight. Finish `yalc-gtm start` first,
then re-invoke me."
Exit cleanly.
The ~/.gtm-os/.in-flight-setup flag is the convention going forward. As of 0.12.0 the Setup wizard does not yet write this flag — adding the flag write is a future change to bin/yalc-gtm.mjs (or wherever the wizard lives). Skills should still include the check today; it's a no-op until the flag exists, and it lights up automatically when the wizard ships the write.
If your skill is fast (Tier 4 read-only) and the user explicitly invokes it during Setup, you can soften this to a warning instead of a hard block. Anything Tier 1–3 should hard-block.
Run through this before opening a PR:
- Skill folder is at
.claude/skills/<verb-noun>/. -
SKILL.mdfrontmatter hasname,description,version: 1.0.0. - Description has a bulleted list of quoted trigger phrases.
- Trigger phrases are anchored to noun + verb (no bare
run/check/analyze). -
node scripts/lint-skill-triggers.mjsexits 0. - Body picks Pattern A or Pattern B per the hybrid rule.
- Pattern B skill includes a fall-back to Pattern A on import-direct error.
- CLI command(s) wrapped are listed in
## Tools usedat the bottom. - Failure-surfacing prints CLI stderr verbatim, no summarisation.
- Onboarding interruption guard is the very first step (
~/.gtm-os/.in-flight-setupcheck). - References (
references/*.md) are listed and exist if cited. - Three random trigger phrases activate the skill from a fresh Claude Code session.
- Killing the relevant API key surfaces the actual CLI error to the user.
-
pnpm typecheckclean,pnpm test≥ baseline,pnpm build:webclean.
If every box is checked, your skill is ready for review.
- Migrating the 8 pre-0.13.0 skills. Audit-only — see
CONTRIBUTING.md. configs/skills/framework runner steps. Different runtime, different review path.~/.gtm-os/skills/wizard output. Created byskills:create, not authored by hand.- yalc.ai landing-page generation for the skill. That happens after the skill ships, via the
skill-launch-pageskill against the published.claude/skills/path.