Library management from CI & scripts
The v1 REST API at /api/v1/library/* is the interface for CI pipelines, scripts, and anything else that isn’t a browser or an interactive terminal. This page covers the two write operations — pushing a skill from local files, and adding a published skill by reference — with copy-paste curl recipes and the idempotency model you need when retries are on the table.
For schemas of every field on every endpoint, see the API reference. For an opinionated wrapper that handles multipart encoding, idempotency, and exit codes, use the skillrepo CLI — both surfaces talk to the same endpoints.
Authentication
Every /api/v1/* request needs a bearer access key. Keys are prefixed sk_live_ and carry either registry:read or registry:write scope. Mint one from the Connect page in the dashboard.
Authorization: Bearer sk_live_<your-key>Reads (GET /api/v1/library) accept any valid key. Writes (POST, DELETE) require registry:write — a read-only key returns 403 insufficient_scope. The CLI’s --key flag and the SKILLREPO_ACCESS_KEY env var both accept the same key.
Library endpoints at a glance
The library exposes a small set of read and write endpoints. Pick by what you have in hand:
| Method & path | Use when |
|---|---|
POST /api/v1/library | You have local files (a SKILL.md plus optional scripts/, references/, assets/) and want to publish them. Server upserts by SKILL.md name: creates on first push, releases a new version on subsequent push with changed content, no-ops on identical content. |
POST /api/v1/library/refs | You want to add an existing public skill (somebody else’s, browsable in the catalog) to your library. Body is JSON {owner, name}. No files involved. |
POST /api/v1/library/{owner}/{name}/versions | You want to release a new version of a skill you own with an explicit version label (e.g. mirroring an external version scheme) instead of the auto-bump that POST /library would assign. Power- user override; most callers should use POST /library and let the server pick the label. |
GET /api/v1/library | Bulk sync. Inlines every accessible skill’s SKILL.md and supporting files. Pass ?since=<iso> for delta sync; the response is ETag-cacheable for cheap 304s. |
GET /api/v1/library/{owner}/{name} | Read one skill from your library, including your own private skills. Returns 202 Accepted with a Retry-After header when safety analysis is still running on a freshly-pushed skill. See “Recipe: read one skill” for the polling contract. |
DELETE /api/v1/library/{owner}/{name} | Remove a skill from your library. Writes a tombstone so the next delta sync surfaces the removal to other clients on the account. |
POST /api/v1/library/{owner}/{name}/publish | Flip a skill you own from private to global — discoverable in the public catalog. Idempotent. See Visibility model for permissions and preconditions. |
POST /api/v1/library/{owner}/{name}/unpublish | Flip a skill you own from global back to private. Subscribers keep their current copy; their owner(s) receive a one-time notification email (24h debounce). See Visibility model. |
How POST /library decides what to do
The push endpoint identifies a skill by the name field in your SKILL.md frontmatter. The server picks the outcome — you never tell it whether to create or update:
action: "created"(HTTP 201) — first push of this name in your account. Visibility defaults toprivate; you publish it from the dashboard when you’re ready.action: "updated"(HTTP 200) — the skill exists and the content changed. A new version is released. The server classifies the bump: major if any ofdescription,allowed-tools, orcompatibilitychanged in the frontmatter; minor otherwise. Returned asbump: "major"orbump: "minor". Visibility is not changed on update.action: "unchanged"(HTTP 200) — the SKILL.md + every file SHA-matches the latest version. No new version is created. This is the natural no-op for retries.
Heads-up on the GET / POST divergence. When server-side safety analysis is enabled, the created / updated response describes the just-pushed state — new description, new version label, new files — even though GET /api/v1/library continues to surface the prior analyzed version until analysis approves the new one. This is intentional: use the POST response to update your local cache after a push, and use GET to mirror what other subscribers on the account will see. The divergence disappears once analysis completes.
Multipart shape for POST /library
Upload the skill folder uniformly per the agentskills.io spec: multipart/form-data with one repeated field:
files— one or more file parts, one per file in your skill folder. Each part’s relative path within the skill is carried via theContent-Disposition: filenameparameter (RFC 7578 §4.2), e.g.filename="references/intro.md". The rootSKILL.mdis required and identified by exact-matchfilename=SKILL.md(case-sensitive, no path prefix); missing it returns400 missing_skill_md. The agentskills.io spec requiresnameanddescriptionin the SKILL.md YAML frontmatter;license,compatibility,allowed-tools, andmetadataare optional.
The server rejects path traversal, absolute paths, and executable / binary-archive extensions (.exe, .dll, .so, .dylib, .bin, .jar,.wasm, …). Paths can be up to five segments deep. Total multipart body is capped at 4.5 MB (Vercel Function platform body limit) — larger payloads return 413 payload_too_large with the cap and your size in details.
Recipe: push a skill from local files
The most common automation flow: a CI step builds a skill and pushes it. Hand-build the multipart with curl, or just shell out to the CLI.
curl https://api.skillrepo.dev/v1/library \
-X POST \
-H 'Authorization: Bearer sk_live_<your-key>' \
-H "Idempotency-Key: $(uuidgen)" \
-F 'files=@./my-skill/SKILL.md;filename=SKILL.md' \
-F 'files=@./my-skill/references/intro.md;filename=references/intro.md' \
-F 'files=@./my-skill/assets/logo.png;filename=assets/logo.png'The ;filename= suffix on each -F is a curl-ism that sets the Content-Disposition: filename parameter — that’s what carries the file’s relative path within the skill. Without it, curl sends the basename only and my-skill/references/intro.md arrives at the server as intro.md with no directory prefix. If you build the request in code, set the File / Blob name (third arg of new File([bytes], "references/intro.md")) and you’re done — the same RFC 7578 mechanism applies.
The response status discriminates the outcome: 201 for created, 200 for updated / unchanged. The body carries action, bump, and the full SyncSkill shape — see the resource model section.
If you’d rather not hand-build the multipart, the CLI equivalent is a one-liner that walks the directory for you:
SKILLREPO_ACCESS_KEY=sk_live_<your-key> \
npx skillrepo push ./my-skill --jsonFull flag list and exit-code semantics live in the CLI reference.
Recipe: add a published skill by reference
When the skill already exists on the registry and you just want it in your library, POST /api/v1/library/refs with a JSON {owner, name} body. No file content travels over the wire — you’re attaching an existing record to your library so it appears in your bulk-sync output.
curl https://api.skillrepo.dev/v1/library/refs \
-X POST \
-H 'Authorization: Bearer sk_live_<your-key>' \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"owner": "alice", "name": "pdf-helper"}'Outcomes you should branch on: 201 added (the row was inserted), 404 not_found (the skill doesn’t exist or isn’t public), 409 self_ownership (you own it — own skills are always accessible without a library row), 409 already_in_library (idempotent — no action needed), 403 plan_limit (you’ve hit your tier’s library cap).
Recipe: list your library
Bulk sync. Returns every skill accessible to your account with inline SKILL.md and supporting file content. The response is large — use ETags and ?since= on subsequent calls.
curl https://api.skillrepo.dev/v1/library \
-H 'Authorization: Bearer sk_live_<your-key>'Body shape: {skills: SyncSkill[], removals: [...], syncedAt}. Save the ETag response header and send it as If-None-Match on the next call for a 304 short-circuit. Add ?since=<iso> to receive only updates plus a populated removals array of tombstones.
Recipe: read one skill
Read one skill from your library by owner and name. The most common trigger: you just pushed a skill via POST /api/v1/library and want to read it back to verify it landed — that’s the flow this endpoint is shaped for, and it’s where the 202 contract below matters.
The response shape matches an entry in GET /api/v1/library, so parsing code you already have for the bulk-sync output works without changes.
curl -sS -o body.json -w '%{http_code}' \
https://api.skillrepo.dev/v1/library/<owner>/<name> \
-H 'Authorization: Bearer sk_live_<your-key>'
# prints '200' or '202' on stdout; full JSON in body.jsonThe status code is the contract, not the body. Branch on %{http_code} from curl (or response.status from fetch). The body’s skill.status field is informational — useful for log messages, not for control flow.
What the status codes mean
| Status | What it means |
|---|---|
200 | Skill ready. Body is the full SyncSkill with inline file content. |
202 | Skill exists, safety analysis still running. Retry-After tells you when to come back. Owner-only — never returned to cross-account callers. |
404 | Skill isn’t in your library. Same response whether the skill doesn’t exist, is private to another account, or you just haven’t added it via POST /api/v1/library/refs yet — we don’t distinguish, on purpose. The endpoint can’t be used to probe whether a skill exists on another account. |
Standard auth errors apply: 401 if your bearer key is missing, malformed, or expired; 403 insufficient_scope if your key lacks registry:read. Both are described under Authentication above.
Why 202, not 404 or 200
202 is RFC 9110 §15.3.3’s “accepted, not done” — exactly what’s happening: the resource exists, the safety analysis hasn’t finished. 404 would be a lie (your push just returned 201 with this URL in Location); a 200 with a partial body would tell your HTTP client “done” when it isn’t, and retry-on-2xx libraries would stop polling. 202 + Retry-After is the contract every well-behaved HTTP client already understands.
The 202 polling pattern
Right after you push a new skill via POST /api/v1/library, the safety analysis runs asynchronously (30 seconds to 2 minutes, typically). If you call GET on the new skill before analysis completes, the server returns:
HTTP/1.1 202 Accepted
Retry-After: 30
Cache-Control: no-store
Location: https://api.skillrepo.dev/v1/library/<owner>/<name>
Content-Type: application/json
{
"skill": {
"owner": "...",
"name": "...",
"createdAt": "...",
"status": "analyzing",
"processingStatus": {
"state": "safety_analysis_pending",
"startedAt": "...",
"estimatedCompletionSeconds": 90
}
},
"message": "Skill is undergoing safety analysis and will be available shortly. Retry the request after the interval in Retry-After."
}Most HTTP libraries honor Retry-After automatically. If yours doesn’t, poll with a bounded attempt cap:
attempts=0
while [ "$attempts" -lt 10 ]; do
status=$(curl -sS -o /tmp/resp -w '%{http_code}' \
"https://api.skillrepo.dev/v1/library/<owner>/<name>" \
-H "Authorization: Bearer sk_live_<your-key>")
case "$status" in
200) cat /tmp/resp; exit 0 ;;
202) attempts=$((attempts + 1)); sleep 30 ;;
*) echo "error: $status"; cat /tmp/resp; exit 1 ;;
esac
done
echo "still analyzing after 5min — give up and alert" >&2
exit 1Analysis usually finishes inside 2 minutes; we’ve never seen it take more than 5. If you’re still getting 202 after ten retries on a 30-second interval, treat it as a failure and page someone — it means the analyzer is wedged, not that your skill is unusually complex.
Recipe: release with an explicit version label
The normal release path is the upsert on POST /api/v1/library — the server detects the existing skill, classifies the bump from the diff in your frontmatter, and assigns a label like 1.0, 1.1, 2.0. Most callers should just use that.
Reach for POST /api/v1/library/{owner}/{name}/versions only when you need to pick the label yourself:
- Porting a skill from another registry or repo where the history already uses labels like
2.3.7and you want your releases to keep that scheme. - Mirroring an external tool’s version numbers (e.g. a skill that always ships against
some-tool@<upstream-version>). - Writing a human-authored
changeloginstead of the auto-generatedContract changed: …string.
curl -X POST https://api.skillrepo.dev/v1/library/<owner>/<name>/versions \
-H 'Authorization: Bearer sk_live_<your-key>' \
-H "Idempotency-Key: $(uuidgen)" \
-F 'version=2.3.7' \
-F 'changelog=Pin to upstream-tool 2.3.7 release' \
-F "files=@./SKILL.md;filename=SKILL.md" \
-F "files=@./references/intro.md;filename=references/intro.md"On success the response is 200 with { action: "updated", bump: "major" | "minor", skill: SyncSkill }. The skill.version field carries your supplied label verbatim. bump reflects the same classification the upsert would have produced from the frontmatter diff — useful if you want to log “this was effectively a major release” alongside the human-chosen label. Every response carries Cache-Control: private, no-store; on Idempotency-Key replays the response also carries Idempotent-Replayed: true.
Required vs optional fields
version(required) — 1-64 characters, must start with a letter or digit, body may include.,-,+. Semver, semver pre-release tags, and build metadata are all accepted (e.g.3.0.0-rc.1,1.0.0+build.42). The server uses it verbatim — no parsing, no monotonicity check.changelog(optional) — free-form prose for your users. Omit to fall back to the same auto-generated text the upsert endpoint produces.files(one or more) — same RFC 7578 multipart convention asPOST /library: each part’s in-skill relative path travels inContent-Disposition: filename. RootSKILL.mdis required.
Things that will trip you up
- Skill must already exist. The endpoint is update-only. If the skill doesn’t exist in your account, you get
404 skill_not_found. UsePOST /api/v1/libraryfirst to create it; then use this endpoint for explicit labels on subsequent releases. - Unchanged content returns 200. If your SKILL.md and files are byte-identical to the current version, you get
200withaction: "unchanged"and the current skill state — no new version is written and the suppliedversionlabel is ignored. Same shape asPOST /libraryfor an unchanged push, so idempotent retries from CI scripts succeed without breaking the build. - Frontmatter name must match the URL. The URL is the canonical identifier. If your SKILL.md says
name: foobut you POST to/library/me/bar/versions, you get400 name_mismatch. - Version label must be unique for the skill. If you’ve already released
2.3.7, you can’t release another2.3.7— you get409 version_conflict. Pick a new label. - Cross-account skills look identical to missing ones. Posting to
/api/v1/library/<someone-else>/<name>/versionsreturns the same404 skill_not_foundas a name that doesn’t exist anywhere. The endpoint never confirms whether a skill on another account exists — same info-hiding rule asGET /api/v1/library/{owner}/{name}. - 4.5 MB body cap applies. Same Vercel Function platform limit as
POST /library; multipart bodies over the cap return413 payload_too_large. Path validation (traversal, depth, blocked extensions) and the global multipart constraints are described under Limits & quotas.
Full status / code matrix lives in Error codes below — including the missing_version / invalid_version / name_mismatch / version_conflict / skill_not_found codes specific to this endpoint.
What it does NOT do
- It is not publish. Visibility is unchanged — a private skill stays private, a public skill stays public. To transition visibility, use the dedicated publish / unpublish endpoints.
- It does not pick a label for you.
versionis required. If you want the server to classify the bump, usePOST /api/v1/library. - It does not enforce monotonicity. Going from
2.3.7back to0.9is accepted. You own the label semantics.
Visibility model: publish & unpublish
A skill has two visibility states: private (only your account sees it) and global (anyone can find it in the public catalog). Pushing files via POST /api/v1/library never changes visibility — new skills are created private and existing skills keep their current visibility on re-push. Two dedicated endpoints transition the state:
POST /api/v1/library/{owner}/{name}/publish
POST /api/v1/library/{owner}/{name}/unpublishFrom the CLI: skillrepo publish @owner/name and skillrepo unpublish @owner/name. Both require a registry:write-scoped access key and use the cli-crud rate-limit bucket (60/min/key).
Who can do this
Account owners and admins can always publish/unpublish. Non-admin members can do either if they have the canPublish entitlement on their per-membership settings (or the account-wide memberCanPublish default is enabled). The same canPublish capability gates BOTH directions — there's no asymmetric “can publish but not unpublish” state. Without the entitlement the endpoint returns 403 publish_not_permitted / unpublish_not_permitted.
Publish-only preconditions
Publishing into the catalog has product-rule guards beyond the permission check. All three return 422 with a discriminator code:
namespace_unset— your account's name still equals its auto-generated slug. Customise the namespace in Settings → Account Profile before publishing (the slug becomes your public namespace).analysis_pending— server-side safety analysis hasn't completed yet. Wait for analysis (typically 30-90s after a push) and retry. Only fires on editions where analysis is enabled.safety_grade_too_low— the skill's safety grade isF. Address the flagged risks and push a new version to re-grade.
Unpublish behavior — what happens to subscribers
When you unpublish, the skill is removed from the public catalog — new accounts can no longer discover or add it. Accounts that have it in their library retain delivery: the library_items row is the access grant, so their bulk syncs continue to include the skill (and any new versions you push, if any). This is the universal delivery rule (#1629) — “your library is yours”: visibility is a discovery gate, not a delivery gate. To revoke a subscriber's access entirely, delete the skill via DELETE /api/v1/library/{owner}/{name} — that removes every library_items row and stops delivery to all subscribers.
Each affected subscriber account's owner-role member(s) receive a one-time notification email, debounced 24 hours per (skill, account) pair so a fast unpublish/republish/unpublish cycle doesn't spam the same recipient. Suspended accounts and suspended owner-memberships are excluded from notifications. The publisher's own account is excluded as well.
The 200 response includes notifiedSubscriberCount so you know how many accounts were notified after the debounce + suspension filters. The CLI surfaces this directly: Unpublished @owner/name. Notified 5 subscribers (they keep their current copy but won't receive future updates).
Idempotent
Calling publish on an already-global skill (or unpublish on an already-private skill) returns 200 with action: "unchanged" rather than 409. No state change, no subscriber notifications on the unchanged path. Safe to use in CI retry loops without conditional logic.
Both endpoints also support the optional Idempotency-Key header — same semantics as the other write endpoints (see Idempotency & retries). Useful for caching the full SyncSkill response body across network retries without an extra GET.
CLI quick reference
# Make a private skill discoverable in the catalog
skillrepo publish @alice/my-skill
# Take a public skill back to private (subscribers keep their copy)
skillrepo unpublish @alice/my-skill
# JSON output for scripts
skillrepo publish @alice/my-skill --json
skillrepo unpublish @alice/my-skill --jsonIdempotency & retries
Both write endpoints accept an optional Idempotency-Key header. With a key set, the server caches the response for 24 hours; subsequent requests with the same key and the same body return the cached response and add an Idempotent-Replayed: true response header. This makes retries safe — even across process restarts and CI step reruns.
Replay matrix
| Same key, then… | Server returns |
|---|---|
| Same body, within 24h | Cached response, same status, plus Idempotent-Replayed: true. |
| Different body | 422 idempotency_key_reused. The server treats this as a client mistake — it does not honor the new request. |
| Same body, while the first call is still in-flight | 409 idempotency_key_in_progress. Wait for the first call to return before retrying. |
| After 24 hours | The cache row expires; the request re-executes as fresh. |
4xx responses are cached, too
This catches people out. If your first push returns 400 invalid_skill_md because of a typo in your frontmatter, fixing the typo and retrying with the same Idempotency-Key will replay the cached 400 — the server never sees your corrected payload. Same story for 403 plan_limit after a plan upgrade, or 413 payload_too_large after a slim-down. The fix is to use a fresh key for the corrected attempt; the original key’s contract is “same input → same answer.” 5xx responses are not cached — they release the lock immediately so retries get a fresh shot at the server.
Choosing a key per attempt vs per operation
- One key per logical operation — the scenario the header is designed for. Generate a v4 UUID once for the whole “push my-skill” intent and reuse it across every retry. A flaky-network retry, a failed-CI-step rerun, even a manual
kubectl rollout restartmid-flight — they all converge on the same outcome. - Fresh key per attempt — for when you actually want each call to execute independently (for example, deliberately re-running after fixing a 4xx error). Generate a new UUID each time, or omit the header entirely.
CLI behavior
The skillrepo push CLI generates a fresh UUID per invocation and uses it for the in-process retry loop (transient 5xx and 429 get up to 3 attempts with exponential backoff). It does not persist that key across invocations — re-running the same command from your shell mints a new key by design, so a deliberate “try again” produces a fresh attempt. To share a key across invocations (the right move for shell-level retry loops in CI), pass --idempotency-key explicitly:
KEY=$(uuidgen)
for i in 1 2 3; do
skillrepo push ./my-skill --idempotency-key "$KEY" && break
sleep $((i * 5))
doneLimits & quotas
Rate limits
Two layers: a per-key bucket that protects the platform from abusive callers, and a per-account anti-abuse bucket that caps how often a tier can write. Whichever trips first wins.
- Per-key —
60 / minutefor most CLI routes (cli-crudbucket).GET /api/v1/libraryuses a tighter10 / minutesync bucket because the response is heavy and clients should cache via ETag. - Per-account anti-abuse on
POST /api/v1/library(push) —5 / minuteand30 / houron Publisher;30 / minuteand500 / houron Team.POST /api/v1/library/{owner}/{name}/versionsshares the same bucket — same workload (multipart parse, blob upload, version write), one shared budget across both release paths. - Per-account anti-abuse on
POST /api/v1/library/refs—60 / minuteand500 / houron Publisher;120 / minuteand2000 / houron Team.
Trips return 429 rate_limited with a Retry-After header (seconds). The CLI’s push command retries 429s automatically using exponential backoff with jitter (up to 3 attempts); it does not parse the Retry-After value. Hand-rolled clients should honor it.
Payload limits
- Multipart body total: 4.5 MB. This is the Vercel Function platform body limit. Larger payloads return
413 payload_too_large; the pre-parse 413 (declaredContent-Length) carriesmax_size_bytesindetails, and the in-loop 413 additionally carriesyour_size_bytes. The cap is small because skills are documentation, not bundled software — if you’re close to the cap, the skill probably has assets that should be hosted elsewhere and referenced by URL. - Path depth: 5 segments. A path like
a/b/c/d/e.mdis the maximum; deeper paths return400 invalid_path. - Blocked extensions: executables and binary archives (
.exe,.dll,.so,.dylib,.bin,.jar,.wasm, and similar). The full list lives atsrc/lib/skills/constants.tsin the open-source repo.
Error codes
Every error response carries a code string in the body. Branch on the code, not on the human-readable error message — the message wording is not stable across releases, the code is.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_skill_md | SKILL.md parse or frontmatter validation failed. details[] carries per-field errors. |
| 400 | invalid_path | A file part’s path failed validation (traversal, absolute path, depth, or blocked extension). |
| 400 | invalid_multipart | Body could not be parsed as multipart/form-data. Check Content-Type — most often forgotten when hand-building the request. |
| 400 | missing_version | POST /api/v1/library/{owner}/{name}/versions only. The required version form field was absent or empty. |
| 400 | invalid_version | POST /api/v1/library/{owner}/{name}/versions only. Label fails 1-64 chars / leading-alphanumeric / [a-zA-Z0-9.\-+] body. The offending label is echoed back in details.version. |
| 400 | name_mismatch | POST /api/v1/library/{owner}/{name}/versions only. The SKILL.md frontmatter name does not match the {name} path segment. Both values are echoed in details. |
| 401 | unauthorized | Missing or invalid bearer token. |
| 403 | insufficient_scope | Your access key is read-only; this endpoint requires registry:write. |
| 403 | plan_limit | Your plan’s skill-count or library-size cap was reached. Upgrade or remove items. |
| 404 | not_found | Skill not found, or not public (the registry intentionally does not distinguish these — see refs recipe). |
| 404 | skill_not_found | POST /api/v1/library/{owner}/{name}/versions only. The skill does not exist in the caller’s account, OR exists but on another account. Cross-account skills collapse to the same 404 by design — the endpoint cannot be used to probe cross-account existence. |
| 409 | source_conflict | A skill with this name is attributed to a different account. |
| 409 | version_conflict | POST /api/v1/library/{owner}/{name}/versions only. The supplied version label is already used by another version of this skill. The conflicting label is echoed in details.version. Pick a fresh label. |
| 409 | self_ownership | Can’t add your own skill to your library — owned skills are already accessible. |
| 409 | already_in_library | Skill is already in your library. Idempotent — treat as success. |
| 409 | idempotency_key_in_progress | Another request with the same Idempotency-Key is still being processed. Wait for it to complete. |
| 409 | concurrent_create | Two pushes for the same skill name raced; this one lost. Retry — the retry takes the update path against the winning row. |
| 413 | payload_too_large | Multipart body exceeds 4.5 MB. The pre-parse 413 (fired on the declared Content-Length) returns details: { max_size_bytes } only; the in-loop 413 (fired during multipart parse) additionally returns your_size_bytes with the measured byte count. |
| 422 | idempotency_key_reused | Same Idempotency-Key sent with a different request body. Use a fresh key. |
| 429 | rate_limited | Rate-limit bucket exceeded. Retry-After tells you how long to wait (seconds). |