Claude and Codex both have a skills folder now, and neither one reads the other's.
We just shipped 11 marketing Skills as the companion pack to a free handbook we wrote in Chinese, 《AI 数字营销实战》 (AI Digital Marketing in Practice, 1,100+ pages). Its readers use Claude, ChatGPT and Codex, and the book never asks them to open a terminal. So the job was: one source folder, three kinds of ZIP, and an install path in all three tools that never touches a shell.
Five things broke along the way. Here they are, with the lint script that now catches them and the headless test that proves a Skill actually fired.
The layout
One src/ folder, one build script, three artifacts:
src/
├── .claude-plugin/plugin.json # plugin manifest
├── skills/<name>/SKILL.md # 11 skills, the only copy we edit
└── project/AGENTS.md # the brand brief both AIs read
dist/
├── plugin.zip # <plugin>/.claude-plugin/plugin.json + <plugin>/skills/
├── single/<name>.zip # one folder each, never a manifest inside
└── project-folder.zip # brand-folder/AGENTS.md
# brand-folder/.claude/skills/<name>/SKILL.md
# brand-folder/.agents/skills/<name>/SKILL.md
The build is stdlib zipfile. The interesting part is what goes where:
# 1) Plugin ZIP: Claude "Upload plugin" and ChatGPT "Upload plugin ZIP" both read this layout
with zipfile.ZipFile(DIST / "plugin.zip", "w", zipfile.ZIP_DEFLATED) as zf:
add_tree(zf, SRC / ".claude-plugin", f"{top}/.claude-plugin")
add_tree(zf, SKILLS, f"{top}/skills")
# 2) Single-skill ZIPs: only the skill's own folder, never a plugin manifest
for name in skill_names():
with zipfile.ZipFile(DIST / "single" / f"{name}.zip", "w", zipfile.ZIP_DEFLATED) as zf:
add_tree(zf, SKILLS / name, name)
# 3) Project folder: the same skills twice, once per tool
with zipfile.ZipFile(DIST / "project-folder.zip", "w", zipfile.ZIP_DEFLATED) as zf:
add_tree(zf, PROJECT, "brand-folder")
add_tree(zf, SKILLS, "brand-folder/.claude/skills")
add_tree(zf, SKILLS, "brand-folder/.agents/skills")
Gotcha 1: the description has a 200-character budget
Before deciding whether to load a Skill, the model sees only its name and description. The open standard allows 1,024 characters. Claude's help center lists 200 for uploaded Skills. If you want one file that works everywhere, write to 200.
Ours run from 119 to 189 characters. Each one says what the Skill does and lists the phrases people actually type, in Chinese and English, because the readers switch between both.
The lint, adapted from our check.py:
import re
from pathlib import Path
import yaml
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
RECEIPT = "Invoked" # our Chinese pack uses 已调用
def lint(skill_dir: Path) -> list[str]:
text = (skill_dir / "SKILL.md").read_text("utf-8")
meta = yaml.safe_load(text.split("---")[1])
name, desc = meta.get("name", ""), meta.get("description", "")
problems = []
if name != skill_dir.name:
problems.append(f"name {name!r} must match the folder name")
if not NAME_RE.match(name) or len(name) > 64:
problems.append("name: lowercase letters, digits and hyphens, 64 max")
if "claude" in name or "anthropic" in name:
problems.append("name may not contain 'claude' or 'anthropic'")
if len(desc) > 200:
problems.append(f"description is {len(desc)} chars, over the 200 upload limit")
if f"{RECEIPT} {name}" not in text:
problems.append("missing the first-line receipt (see gotcha 5)")
return problems
Gotcha 2: "A skill cannot contain a plugin manifest"
That's the error Claude shows when someone downloads a whole GitHub repo as a ZIP and drops it into Upload skill. The repo ZIP carries .claude-plugin/plugin.json, and the Skills uploader refuses anything with a manifest inside.
The rule we now give readers: a whole repo goes in as a plugin, a single skill goes in as a skill. Hence two kinds of ZIP. The plugin ZIP is for paid Claude plans and ChatGPT (100MB cap on ChatGPT's side). The single-skill ZIPs are for Claude Free, which has no plugin upload, and for anyone who wants just two or three. Our lint fails the build if a single-skill ZIP contains claude-plugin anywhere.
Gotcha 3: two tools, two skills folders
Codex reads .agents/skills. Claude reads .claude/skills. We tested both directions, and neither reads the other's folder. So the project ZIP ships every Skill twice, and the build writes both copies from the same source, so they can't drift.
When we asked Codex to list what it could see, it volunteered that the two folders were "identical copies". Good: that's the point.
Gotcha 4: one CLAUDE.md silences your AGENTS.md
The brand brief is AGENTS.md, because Codex reads it every time and Claude Code can too. The catch is in Claude Code's default: it reads AGENTS.md only when there's no CLAUDE.md or CLAUDE.local.md in the working directory or above it. Add a CLAUDE.md anywhere up the tree and your brief quietly drops out.
Two fixes: keep CLAUDE.md out of the project folder (our lint fails the build if one appears), or set Project instructions to claude-md-and-agents-md in /config. Reading AGENTS.md directly needs Claude Code v2.1.277 or later.
On the Codex side, keep the brief short: Codex reads instruction files up to a default combined 32 KiB and drops the rest. In the desktop app it only auto-loads AGENTS.md and skills from the folder marked primary.
Gotcha 5: nothing tells you the Skill fired
There's no reliable "skill used" indicator in the chat UI. So every Skill in the pack opens with one hard rule: the first line of the answer names the Skill. (Our pack is Chinese, so the line reads 已调用 plus the skill name; the English version would be Invoked competitor-onepager.) No receipt means the model did the task its own way and ignored your rules.
A model can write that line without ever loading the Skill, so the test checks two things: the first line, and the actual Skill tool call in the transcript. Claude Code's headless mode gives you both:
claude -p "Make a competitor one-pager: competitor RXBAR, us NorthBar, US market, no browsing" \
--output-format stream-json --verbose --max-turns 8 \
--setting-sources project \
--allowedTools Skill Read Glob Grep \
--disallowedTools WebSearch WebFetch Bash Write Edit \
> run.jsonl
--setting-sources project keeps your own user-level skills out of the run, so a same-named Skill in your account can't pass the test for the one in the pack. Then:
import json
def parse_stream(lines):
"""Which Skills ran, and what is the answer's first line?"""
called, final = [], ""
for line in lines:
try:
event = json.loads(line)
except ValueError:
continue
if event.get("type") == "assistant":
for block in event["message"]["content"]:
if block.get("type") == "tool_use" and block["name"] == "Skill":
called.append(block["input"].get("skill"))
elif event.get("type") == "result":
final = (event.get("result") or "").strip()
return called, final.splitlines()[0] if final else ""
called, first = parse_stream(open("run.jsonl", encoding="utf-8"))
assert called == ["competitor-onepager"], called
assert first.startswith("Invoked competitor-onepager"), first # 已调用 in our pack
On Codex the equivalent is codex exec --sandbox read-only --ephemeral -C brand-folder -o last.txt '$competitor-onepager ...' and a check on the first line of last.txt.
Results on the command-line versions: Claude Code 3 of 3, Codex 5 of 5, including three runs where we didn't name the Skill and Codex picked the right one on its own.
Two things a Skill won't do for you
It won't enforce rules. Claude Code's docs say instruction files are treated "as context, not enforced configuration". Models follow them most of the time. If an action must never happen (sending, deleting, posting), take the tool away with permissions; don't rely on a line in SKILL.md.
It won't make two models agree. We ran the same review-mining Skill in both tools: the same 5 pain-point themes came out, and the Codex version added one price promise the source data didn't support. A Skill makes the process repeatable. A human still signs off the output.
Before you install anyone else's plugin
Three checks, every time:
- Source. Who wrote it, what licence, when was it last updated? A plugin hosted in a big company's repo can still be a partner's work.
- Install preview. In Claude, the plugin page says either "adds instructions only" or that it can send data to other services, and lists any agents. Agents and hooks run commands on your machine in Claude Code and Cowork.
- Read the files, then turn off auto-sync for third-party marketplaces, so an author's next commit doesn't land in your account unreviewed.
The 11 Skills
| Skill | What it does |
|---|---|
brand-interview |
Interviews you one question at a time and writes your AGENTS.md brand brief |
review-pain-points |
Turns raw reviews into a table of pain points in customers' own words |
competitor-onepager |
One page on one competitor, compared with you line by line |
competitor-teardown |
Takes apart a competitor's live ads: reason to buy, memorable assets, journey stage, and the gaps nobody holds |
one-draft-four-platforms |
One draft rewritten for TikTok, Instagram Reels, Pinterest and Facebook |
ad-variants |
3 angles × 3 hooks = 9 ad variants, plus a test plan |
weekly-report |
Reads your exported CSVs and writes a week-aligned report |
acceptance-check |
QA before anything ships: flags unsourced numbers, overclaims and brief violations, never edits the original |
geo-20q-baseline |
Turns buyers' own words into 20 questions and a baseline sheet: does AI recommend you, and is it right when it names you? |
amazon-listing-rewrite |
Rewrites Amazon bullets and A+ copy, every sentence traced to product data in an evidence table |
product-feed-check |
Checks a product feed SKU by SKU against ChatGPT shopping and Google Merchant Center fields |
All 11 add instructions only: no connectors, no scripts. The pack ships free with the handbook at canlah.ai/zh/ai-marketing-book. The worked examples use two fictional companies, so you can try every Skill before pointing it at your own brand.
Shipping Skills to more than one AI tool yourself? I'd like to hear what broke for you.
Written with AI assistance. Commands and results above come from our own test logs.
Top comments (0)