Twenty One Media
aiAugust 21, 2026

Symlinks Break Claude Skill Zips

When we packaged the five Freedom Ag skills for module 5, we hit a problem we hadn't anticipated: the zips were building, but they would have installed broken.

Claude's skill uploader expects a specific structure: a zip with <name>/SKILL.md at the top level. When the upload completes, Claude reads that file. If the file isn't there, the skill doesn't work. The upload itself might succeed. The skill shows up in the list. But Claude has nothing to read.

Where the Problem Comes From

Our skills live in a global ~/.agents/skills directory. The ones we build for ourselves are in there directly. When we stage a client deliverable or wire a skill into a project, we add a symlink pointing into that directory.

So a skill folder we'd package for a client isn't a real folder of files. It's a directory entry that points somewhere else. Run ls -la on it and you see the -> arrow.

A plain zip on a symlinked path packages the symlink itself, not the thing it points to. The resulting archive contains a pointer to a path that doesn't exist on the client's machine. When Claude installs it, nothing is there. The skill appears in the list with its name intact. Everything looks fine until you try to use it and notice the behavior is exactly the same as before.

The Fix

Stage a real copy first with cp -RL, then zip from the copy:

cp -RL humanizer ~/.skill-staging/humanizer
cd ~/.skill-staging
zip -r humanizer.zip humanizer/

The -L flag on cp is what matters. It tells cp to follow symlinks and copy the actual content. The resulting staging directory has real files, no pointers. The zip built from that is self-contained.

-R is recursive. -L dereferences every symlink in the tree. Together they produce a directory you can zip without guessing what's inside.

Clean Before You Package

Before we zipped the five skills for Freedom Ag, we grepped the staged content for client-confidential strings: brand voice specifics, names, and internal identifiers from other projects. Skills accumulate context from the work they were built to support. A skill pulled from a different engagement could carry language that identifies another client.

The grep takes 10 seconds. Shipping a client's name inside another client's skills pack is the kind of mistake that shows up weeks later in a call you weren't expecting.

The Pattern

Every skill in a distributable pack should go through the same two steps before it ships:

  1. cp -RL from wherever it lives to a clean staging directory
  2. Grep the staging directory for anything that shouldn't travel

Then zip from staging, not from the source. The source can stay symlinked to your global library. The deliverable is always built from a real copy.

This is the same discipline as building a release artifact from a clean checkout rather than from your working directory. The working directory has cruft. The artifact shouldn't.

The five Freedom Ag zips all passed the grep and installed correctly. The cp -RL step added about 90 seconds to the packaging process. That's the tradeoff for a client who installs the skill by dragging a zip file and expects it to work on the first try.