Publishing Local Claude Skills to the claude.ai Store
Why
Started by re-checking an old conclusion: how skills, connectors, and CLAUDE.md sync across Claude surfaces. It held up, with one correction to memory. Three stores exist and none of them sync:
- Filesystem (
~/.claude/skills, CLAUDE.md) — Claude Code CLI and Claude Desktop’s Code tab, which bundles the real CLI atApplication Support/Claude/claude-code/2.1.219/claude.app. - claude.ai account store — Chat and Cowork. Cowork sits on the remote
side, not the file side, which is what I had misremembered. Proof on disk:
local-agent-mode-sessions/skills-plugin/<org>/<id>/manifest.jsonlists 19 skills, allcreatorType: anthropicwith server-side timestamps, and none of my local skills appear. - API store (
POST /v1/skills) — Messages API only, invisible to the UI.
The practical problem: the vault skills are invisible in mobile Chat, which is where they’d get the most use.
Rejected: a Terraform provider
Tempting, since these are resources with CRUD. Rejected. A provider is a compiled
artifact with a baked-in schema and wants a versioned public API; this store has
neither that nor non-interactive auth, and plan couldn’t diff anyway because
the list endpoint returns names and timestamps, not hashes. An idempotent sync
script gets the desired-state reconciliation for ~5% of the machinery.
Built
programs.claudeSkillSync, off by default, enabled via lib.mkDefault true in
home.nix. One uv2nix Python tool (nix/home-manager/files/scripts/claude-skill-sync,
130 tests) driven by a generated JSON spec from an activation entry. Full
rationale in docs/decisions/0022-claude-ai-skill-sync-at-activation.md.
Endpoint discovery without a working network capture
claude-in-chrome screenshots timed out and its network tracking recorded zero
requests. Static analysis of the frontend bundle worked instead: enumerate loaded
JS chunks via performance.getEntriesByType('resource'), grep for the endpoint
literals, then regex out the structural tokens. Yielded the contract:
GET /api/organizations/{org}/skills/list-org-skills -> {"skills": []}
POST /api/organizations/{org}/skills/upload-org-skill multipart
POST .../delete-org-skill, .../enable-skill, .../disable-skill
Multipart fields file, overwrite, check_skill_name, upload_source. The
endpoints are org-scoped even on a consumer account, and the personal org uuid
turned out to be the same one already naming the Cowork skills-plugin directory.
Dumping raw source windows tripped a content guard; extracting only matched
tokens got around it cleanly.
Three bugs the tests caught
- A missing skill directory hashed like an empty one — a perfectly stable
digest for a skill that isn’t there, which would read as a successful sync.
digest_treenow raises on an absent root. - My own eval-time guard was wrong. The assertion that skill names must be
deployed fired on
noteandpeople, which are deployed. Upstream renders each skill as one directory-levelhome.fileentry, while the ones with generated content are hand-wired file by file, so the regex needed(/.*)?$. The guard caught its own author. - The .gitignore trap again.
Wrote the credential module as
secrets.py; the repo’s blanket**secret**pattern meant it was never tracked, so the pure flake never saw it and the wheel shipped without it. Onlynix flake checkcaught it. Renamed tocredentials.py.
Verified
nix flake check green (231 checks, exit 0), including the new
claude-skill-sync-tests and a capability-flags contract entry. The assertion
rejects a bogus skill name at eval. And the load-bearing one: packing the real
rendered tree produced a knowledge-management.zip containing
assets/note-template.md and references/people-schema.md, neither of which
exists in the repo source. Zipping sources would have shipped a broken skill.
Blocked on an interactive grant
auth-check hit the keychain timeout on both cookie stores: macOS wants a GUI
prompt answered. That is risk #1 from the plan behaving exactly as designed —
the timeout exists precisely so this can never stall a non-interactive
hm switch. Next step is mine to do at the keyboard: answer the prompt with
“Always Allow”, then hm switch, then check the mobile app.
Related: Remote Obsidian MCP Cloud Host · 2026-07-24 Claude Code Context Audit - Cutting Superpowers and 8 Plugins