Update a Skill and Ship It
The developer walkthrough: change a skill in a kit repository, prove it locally, open a pull request, merge, and watch it reach the agents that run it — with the gate that refuses a broken tree before it is merged.
On this page
TL;DR: Most skills live in a kit — a GitHub repository of their own, not in this
platform's repository. Edit SKILL.md, run the gate, bump metadata.version, open a
pull request, merge. CI publishes a signed bundle and moves the stable channel; one
Import on /admin/sync serves it with no restart and no redeploy. The whole
release is one version number and one merge.
First: which kind of skill is this?
The two paths differ, and picking the wrong one wastes a day.
| Kit skill | Base skill | |
|---|---|---|
| lives in | its own repository — plugins/<plugin>/skills/<skill>/SKILL.md |
this repository — services/skills/<skill_id>/ |
| examples | any skill a pinned kit ships (none today) | the skills of the marketplaces this repository declares — dynapps-commons, dynapps-salesforce, dynapps-super-admin |
| to ship a change | merge a pull request; CI publishes; press Import | deploy the instance |
| takes effect | in place, no restart | at the next deploy |
| who can ship it | anyone with write access to the kit | a platform maintainer |
To find out which you are holding: if the skill id starts with a workspace prefix
(sf_, ba_) it is a kit skill. Otherwise look for the directory under
services/skills/ in this repository — if it is not there, it came from a kit. The
Sources table on /admin/sync names every kit the instance composes, and
/admin/configuration says, for every kind of configuration, which source declares
it.
Everything below is the kit path. The base path is one line: change
services/skills/<id>/, commit, and deploy — skills are served from code, so there
is no database to reconcile and no Import to press.
The five minutes before you start
Clone the kit and get the same server binary CI uses, so what passes locally passes in CI:
git clone https://github.com/<org>/<kit-repo>.git
cd <kit-repo>
# the release the kit validates against — the value of DYNAPPS_RELEASE in
# .github/workflows/publish-bundle.yml
gh release download "$DYNAPPS_RELEASE" --repo systempromptio/systemprompt-dynapps \
--pattern 'systemprompt-dynapps-server-*-linux-amd64.tar.gz' --dir ./release
tar -xzf release/systemprompt-dynapps-server-*-linux-amd64.tar.gz -C release
export PATH="$PWD/$(ls -d release/systemprompt-dynapps-server-*-linux-amd64)/bin:$PATH"
1. Change the skill
A skill is one directory. SKILL.md is the instruction the model reads; everything
beside it is material the skill can cite.
plugins/<plugin-id>/skills/odoo-module-review/
SKILL.md
references/ # optional, and only read from here
The frontmatter contract — the shape just kit-export writes, and the one the gate
measures against:
---
name: odoo-module-review # must equal the directory name
title: Odoo Module Review
description: Use when reviewing a custom Odoo module for upgrade safety and ORM misuse.
tags:
- odoo
- review
category: development
display_category: Odoo Development
---
Four rules that are easy to get wrong, each of which the gate will catch:
- The directory is the id, not
name.plugins/<p>/skills/odoo-module-review/becomes the skillodoo_module_review. Renaming the directory renames the skill; changingnameonly changes how it is listed. descriptionis the only key the platform requires — and it decides when the model reaches for the skill, so write it as when to use this, not as a title. Quote it if it contains a colon:Entirely self-contained: no MCP servers requiredis read as nested YAML and fails the import.- A skill may only cite files it carries.
references/state-machine.mdmust exist in that skill's directory. A path to a sibling skill (../other-skill/references/x.md) can never work: directories are renamed tosnake_casewhen the bundle is composed. Share by copying, not by pointing. - Keep the plugin's id prefix. Skill ids are claimed globally across every kit the
instance composes, so a generic
devopsorintegrationcollides with another kit and neither instance starts. Each kit declares its prefix intools/kit-sanitize.json.
Adding a skill is adding a directory; nothing lists them. Removing one is deleting the directory.
2. Prove it locally
Run the same three stages CI runs, in order. Each takes seconds.
python3 tools/sanitize-kit.py --strict # the gate
systemprompt core marketplace import --from . --into ./services --strict
systemprompt core services validate --root ./services --base ./base.tar.gz --strict
The gate prints one line per problem, each naming the file, the line and the fix, and
a table in the CI job summary. It exists because the importer is deliberately
permissive — description is the only key it demands — so everything else fails
quietly at runtime instead of loudly at build time:
| what it catches | what would otherwise happen |
|---|---|
frontmatter that does not parse, or has no description |
the import fails with a byte offset instead of a reason |
a skill directory with no SKILL.md |
the directory is not a skill, so nothing it holds reaches the bundle — silently |
a cited references/ file the skill does not carry |
the model is told to read a file that is not there |
| a path climbing out of the skill directory | it can never resolve once directories are renamed to snake_case |
| two skills covering one topic | the model has to choose between two answers |
| an id without the plugin's prefix | a boot-time collision with another kit |
an access: block |
a kit cannot widen who reaches it; entitlement is declared on the instance |
| a version that is already released | the merge would publish nothing |
Warnings are shape drift rather than blockers: a name that disagrees with its
directory, or missing title, tags, category and display_category. Those do not
break anything — they make the skill show up under its de-hyphenated id, grouped under
"General", sorted last.
3. Bump the version
The version is the trigger. In .claude-plugin/marketplace.json:
"metadata": { "version": "3.3.0" }
Bump it in the same pull request as the change. If the plugin itself changed
materially, bump its version too, in both marketplace.json and
plugins/<plugin>/.claude-plugin/plugin.json.
Forget this and nothing breaks — CI validates, then stops and says "metadata.version 3.3.0 is already released, so this push publishes nothing". Bump it and merge again.
4. Open the pull request
validate runs on the pull request: sanitize → import → compose against the platform's
own services/ tree. A broken tree is refused here, before it is merged, with
annotations on the offending lines. This is the check to wait for.
Merge when it is green. On merge, publish runs and does the rest by itself:
- packs the tree and signs it with the kit's Ed25519 seed
- publishes
ghcr.io/<org>/<kit>:v<version>and moves thestablechannel to the same digest - creates the git tag and the GitHub release
- prints the digest in the job summary
- asks the instance to import, if the kit has been given an admin token
No tag to push, no release to write.
5. Serve it on the instance
If the kit has no admin token configured, one button is left. On /admin/sync, press
Import sources.
This is not a deploy. The running server re-fetches every source, verifies the signature against the public key it pins for that kit, recomposes the services tree, projects entitlement and refreshes the skill inventory — in the same request, in the running process. Nothing restarts.
You do not have to be an administrator: Import sources sits in the participant tier, so anyone who participates in the kit's marketplace can press it for their own marketplaces.
Then read the row back. The kit is in step when the active digest is the one CI printed. Two states that look like failures and are not:
- channel — information, not a warning. The profile follows a tag CI moves on every release, which is why one Import is enough.
- pinned ≠ active — the profile pins a digest rather than the channel, so it
will not move until someone re-pins it:
just services-pin <kit> <digest>.
6. Verify it, three ways
| question | where |
|---|---|
| Is the instance serving the new bundle? | /admin/sync — the kit's row reads in step with the new digest |
| Is the skill in the catalogue? | the Skills catalogue, and systemprompt core skills list |
| Did the composition actually move? | Analysis → Versions — a new generation carrying the new composed hash, with the skills that entered and left |
7. Reaching the agents
Publishing a skill does not entitle anyone to it. Two things decide whether a particular person's client loads it:
- Entitlement. Who reaches a marketplace is declared in this repository, not in
the kit —
services/access-control/rules.yaml, asmarketplace/<id>withowner: bundle:<kit>— and enforced from the database. A kit ships noaccess:block and can never widen its own audience. A new skill inside a marketplace someone already reaches needs no new grant; a new marketplace does. See Access control. - Client refresh. The instance never pushes to a client. The bridge pulls, on a schedule; nothing a client already has changes until it does.
The client pulls — it is not pushed to
The bridge fetches the signed manifest and writes the plugins to disk. It does that:
| trigger | when |
|---|---|
| the scheduled background sync installed with the bridge | every 30 minutes — launchd, systemd or Task Scheduler, depending on the platform |
dynapps-bridge sync |
immediately, on demand |
| Sync now in the bridge GUI or tray | immediately |
| signing in, first run, or enabling a host | automatically, once |
So an unattended machine is up to half an hour behind a release. That is the number to quote when someone asks why they cannot see the skill yet.
The bridge compares each file's SHA-256 against what is already installed, so an
unchanged plugin re-syncs without downloading anything, and a changed SKILL.md
is fetched and swapped. Plugins the manifest no longer lists are deleted — if
someone loses reach to a marketplace, the plugin leaves their disk rather than
merely being hidden.
A running session never reloads
The bridge writes files; the client reads them when it starts. A claude session
that is already open will not pick up a new skill, however many times you sync.
dynapps-bridge sync # fetch the new manifest
# then exit Claude Code and start it again
claude plugin list # the new skill's plugin, in a new session
Cowork is the same: sync, then restart Claude Desktop. Full client instructions are in Connect Claude Code and Connect Cowork.
"It synced, and I still cannot see it"
Almost always one of these four:
- No entitlement. The filter is explicit allow — an item nobody is granted is
dropped, silently, per user. A new skill inside a marketplace the person already
reaches is fine; a new marketplace needs a rule in
services/access-control/rules.yaml. This is the most common cause by a distance. - The session was not restarted. See above.
- The profile pins a digest, not the channel. Publishing moves
stable; a digest pin follows nothing until someone re-pins and imports. - The skill is in the marketplace but in no plugin's
skills.include. It is counted in the manifest and installed nowhere. The sync summary says so explicitly — read it rather than guessing.
If you have just changed an entitlement and want to test without waiting, the server
memoises a user's resolved catalogue for up to a minute; dynapps-bridge sync --fresh
bypasses it.
If something is wrong
Roll back by pointing at the previous upload and importing again:
just services-pin <kit> <previous digest>
The cache keeps the last two trees per source, so a rollback is a re-point, not a download. Re-tagging the channel in the kit works too.
A failed fetch does not take the instance down. on_fetch_failure in the profile
decides: use_last_good keeps serving the previous composition, use_bundled boots
without the kit, fail_closed refuses to boot. The Sources row shows the fallback and
the error.
What does fail a boot: an id present in both this repository and a kit
(composition names both sources and refuses), a signature that does not verify against
the pinned public key, a digest that does not match the pin, or a second source
carrying config/, gateway/ or access-control/. The full list, and the model
underneath all of this, is in
Code ↔ Instance.