DEV Community

Cover image for We Shipped 11 Skills to Claude and Codex. Neither One Reads the Other's Folder.
Canlah AI
Canlah AI

Posted on AI-assisted

We Shipped 11 Skills to Claude and Codex. Neither One Reads the Other's Folder.

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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.
  3. 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)