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:

  1. Filesystem (~/.claude/skills, CLAUDE.md) — Claude Code CLI and Claude Desktop’s Code tab, which bundles the real CLI at Application Support/Claude/claude-code/2.1.219/claude.app.
  2. 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.json lists 19 skills, all creatorType: anthropic with server-side timestamps, and none of my local skills appear.
  3. 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

  1. 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_tree now raises on an absent root.
  2. My own eval-time guard was wrong. The assertion that skill names must be deployed fired on note and people, which are deployed. Upstream renders each skill as one directory-level home.file entry, while the ones with generated content are hand-wired file by file, so the regex needed (/.*)?$. The guard caught its own author.
  3. 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. Only nix flake check caught it. Renamed to credentials.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