zip/unzip CLI (or PowerShell Compress-Archive), Python 3 only if your skill runs a script, and a Claude plan that exposes the Skills feature — open Settings → Capabilities → Skills first to confirm the entry exists in your workspace (the exact menu label differs between the web app, desktop app, and admin console — verify against the documentation for your plan).A skill is a folder containing a SKILL.md file plus optional scripts and reference files. Claude reads three layers on demand: the frontmatter metadata first, the Markdown body only when the skill looks relevant, and bundled files only when the body tells it to open them. That layering is why the description field decides whether your skill ever runs.

zip with Compress-Archive.zip and unzip installed (zip -v prints a version banner).python3 --version; on Windows use py --version.SKILL.md.Structure you are going to build:
| Path | Required | Purpose |
|---|---|---|
| `<skill-name>/SKILL.md` | yes | YAML frontmatter + instructions |
| `<skill-name>/scripts/` | no | executable helpers the body can call |
| `<skill-name>/references/` | no | long docs loaded only on demand |
Pre-flight checklist:
zip -v and unzip -v both print outputpython3 --version prints 3.9 or higher (skip if no script)~/skills-work for building archivesIf you plan to test a skill against a service running on your own hardware, set that service up first — the workflow in How to add AI to your home server: self-hosted services guide gives you a local endpoint you can call from a bundled script.
What to do: Pick one narrow job, name the skill in lowercase-hyphen form, and write a description that lists the situations where the skill must fire.
Why: The description is the only part of your skill that is always in context. If it does not contain the words a user actually types — file extensions, verbs, domain nouns — Claude has no reason to open the body. A vague description is the single most common reason a correct skill never activates.
How to check: Create the folder and file, then confirm the frontmatter parses.
mkdir -p ~/skills-work/csv-sanity-check/scripts
cd ~/skills-work/csv-sanity-check
Create SKILL.md with this frontmatter. The name value must match the folder name exactly, and the description stays on one line so the YAML parser never sees a broken key:
---
name: csv-sanity-check
description: Audits an uploaded CSV file for empty cells, duplicate rows, and rows whose column count does not match the header, then returns a short report. Use when the user uploads a .csv file and asks whether the data is clean, complete, or safe to load into a database.
---
# CSV sanity check
## When to use
Use this skill when the user supplies a `.csv` file and asks about data quality,
completeness, or duplicates.
## Steps
1. Save the uploaded file in the working directory as `input.csv`.
2. Run `python3 scripts/check.py input.csv`.
3. Paste the script output verbatim into the reply, then add one sentence naming the worst issue.
## Output rules
- Always report: row count, column count, empty cells, duplicate rows, ragged rows.
- Never estimate numbers. Copy them from the script output.
Example: the phrase Use when the user uploads a .csv file is what makes the skill fire on "here's my export, is it clean?" — a description saying only "helps with data" would not match that sentence.
Run the frontmatter lint:
python3 - <<'PY'
import pathlib, re
text = pathlib.Path("SKILL.md").read_text(encoding="utf-8")
m = re.match(r"^---\n(.*?)\n---\n", text, re.S)
assert m, "frontmatter block missing"
fm = m.group(1)
for key in ("name:", "description:"):
assert key in fm, f"missing {key}"
print("frontmatter OK")
PY
Expected output: frontmatter OK. Anything else means the --- fences or the lowercase keys are wrong — fix that before continuing, because every later step depends on the folder name and the name field agreeing.

What to do: Write the helper your body calls, run it against a fixture, then zip the folder so that SKILL.md sits at the archive root.
Why: Claude executes the script inside a sandbox that has the Python standard library but not your machine's installed packages and not your network. Testing locally with the standard library only is the cheapest way to catch that mismatch before upload.
How to check: The script must print the same five lines locally as it will inside Claude, and the archive listing must show SKILL.md at depth one.
Create scripts/check.py:
#!/usr/bin/env python3
import csv, sys
from collections import Counter
def main(path):
with open(path, newline="", encoding="utf-8") as f:
rows = list(csv.reader(f))
if not rows:
print("rows: 0")
return
header, body = rows[0], rows[1:]
ragged = sum(1 for r in body if len(r) != len(header))
empty = sum(1 for r in body for c in r if c.strip() == "")
dupes = sum(n - 1 for n in Counter(tuple(r) for r in body).values() if n > 1)
print(f"rows: {len(body)}")
print(f"columns: {len(header)}")
print(f"empty_cells: {empty}")
print(f"duplicate_rows: {dupes}")
print(f"ragged_rows: {ragged}")
if __name__ == "__main__":
main(sys.argv[1])
Create a fixture sample.csv:
id,name,email
1,Ann,ann@example.com
2,Bob,
2,Bob,
Run it:
python3 scripts/check.py sample.csv
Expected output:
rows: 3
columns: 3
empty_cells: 1
duplicate_rows: 1
ragged_rows: 0
Build the archive from the parent directory, not from inside the skill folder — this is what keeps the paths flat:
cd ~/skills-work
zip -r csv-sanity-check.zip csv-sanity-check -x '*.DS_Store' -x '__pycache__/*'
unzip -l csv-sanity-check.zip
Expected listing contains exactly csv-sanity-check/SKILL.md and csv-sanity-check/scripts/check.py. If you see csv-sanity-check/csv-sanity-check/SKILL.md, you zipped the folder from the wrong directory and the upload will fail.
Upload it in the Claude client: open Settings → Capabilities → Skills, choose the upload action, and select csv-sanity-check.zip (menu labels vary by client — verify against the documentation). For Claude Code, the equivalent is copying the folder to ~/.claude/skills/csv-sanity-check/; confirm the skills directory path for your version in the documentation.

What to do: Run three prompts — one that must trigger, one that must not, one that should trigger but lacks the file.
Why: A skill that never fires and a skill that fires on everything are both broken. You need evidence for both directions before you hand the skill to anyone else.
How to check: Attach sample.csv and send:
Check this CSV before I load it into Postgres: is it clean?
Success looks like: the reply names the skill (the client shows a skill/tool indicator above the message), the five script lines appear verbatim, and the closing sentence names the empty email cell or the duplicated Bob row. Because the body explicitly forbids estimating, a reply without the literal duplicate_rows: 1 line means the script did not execute.
Negative test — no attachment, no CSV:
Summarise this article for me: <paste three paragraphs of prose>
Expected: the skill indicator is absent. If it appears, your description is too broad; add a constraint such as Use only when a .csv file is attached.
Third test — the script's error path. Rename the fixture so the path is wrong and confirm the model reports the failure instead of inventing numbers:
mv sample.csv missing.csv
Ask the same question with the file attached. Expected: the reply quotes the Python error and asks for a valid file. This proves the instructions are being followed rather than hallucinated.
Acceptance checklist before you publish:
If the skill will call an internal monitoring endpoint, the polling pattern in How to Set Up VPS Monitoring via a Telegram Bot with Mini App gives you a stable JSON payload to parse with urllib.request from inside scripts/.
Problem. The upload is rejected or the skill appears with no instructions.
No SKILL.md found at the root of the archivezip -r out.zip ~/skills-work/csv-sanity-check with an absolute path.cd ~/skills-work && rm -f csv-sanity-check.zip
zip -r csv-sanity-check.zip csv-sanity-check -x '*.DS_Store'
unzip -l csv-sanity-check.zip | grep SKILL.md
The output must be csv-sanity-check/SKILL.md, with no repeated folder name. Re-upload the rebuilt archive and delete the broken copy from the skills list.
Problem. The skill fires but the script dies instead of returning numbers.
ModuleNotFoundError: No module named 'requests'urllib.request or csv/json from the standard library, then retest locally with a clean interpreter before re-zipping.python3 -I scripts/check.py sample.csv
-I runs in isolated mode and ignores site-packages, which mimics the sandbox. If it prints the five metric lines under -I, it will run inside Claude.
Problem. The skill is listed but never activates.
AssertionError: missing description: printed by the lint step, or activation simply never happens when the YAML is valid but the wording is generic.Description:) breaks the frontmatter, or the description names the capability instead of the user's phrasing..csv, "clean", "duplicates"). Re-run the lint, rebuild the archive, re-upload, then repeat the trigger prompt from Step 3.You built a skill folder with a trigger-focused description, a standard-library script, and a flat ZIP that uploads cleanly. Next, clone the same layout for a second skill that references a long document under references/ so the body stays short and the file loads only on demand.
What is the difference between a Claude Skill and a saved prompt?
A prompt is text you paste every time; a skill is a folder that stays installed, with metadata that lets Claude decide on its own when to load the instructions and any bundled scripts.
Does a skill have to contain code?
No. A SKILL.md with instructions and examples is a complete skill. Add scripts/ only when you need deterministic output such as counts, parsing, or a formatted report.
Where do skills live when I use Claude Code?
As a folder under the skills directory, for example ~/.claude/skills/csv-sanity-check/SKILL.md. Confirm the exact directory and precedence rules for your installed version in the documentation, since project-level and user-level locations can both apply.
How do I update a skill that is already installed?
Edit SKILL.md or the scripts locally, rebuild the ZIP with the same commands, remove the old copy from the skills list, and upload the new archive. Re-run the three Step 3 prompts afterwards — an updated description is the usual reason a previously working trigger stops firing.