Automation

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 & pathUse when
POST /api/v1/libraryYou 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/refsYou 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}/versionsYou 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/libraryBulk 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}/publishFlip 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}/unpublishFlip 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 to private; 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 of description, allowed-tools, or compatibility changed in the frontmatter; minor otherwise. Returned as bump: "major" or bump: "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 the Content-Disposition: filename parameter (RFC 7578 §4.2), e.g. filename="references/intro.md". The root SKILL.md is required and identified by exact-match filename=SKILL.md (case-sensitive, no path prefix); missing it returns 400 missing_skill_md. The agentskills.io spec requires name and description in the SKILL.md YAML frontmatter; license, compatibility, allowed-tools, and metadata are 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 --json

Full 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.json

The 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

StatusWhat it means
200Skill ready. Body is the full SyncSkill with inline file content.
202Skill exists, safety analysis still running. Retry-After tells you when to come back. Owner-only — never returned to cross-account callers.
404Skill 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 1

Analysis 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.7 and 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 changelog instead of the auto-generated Contract 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 as POST /library: each part’s in-skill relative path travels in Content-Disposition: filename. Root SKILL.md is 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. Use POST /api/v1/library first 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 200 with action: "unchanged" and the current skill state — no new version is written and the supplied version label is ignored. Same shape as POST /library for 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: foo but you POST to /library/me/bar/versions, you get 400 name_mismatch.
  • Version label must be unique for the skill. If you’ve already released 2.3.7, you can’t release another 2.3.7 — you get 409 version_conflict. Pick a new label.
  • Cross-account skills look identical to missing ones. Posting to /api/v1/library/<someone-else>/<name>/versions returns the same 404 skill_not_found as a name that doesn’t exist anywhere. The endpoint never confirms whether a skill on another account exists — same info-hiding rule as GET /api/v1/library/{owner}/{name}.
  • 4.5 MB body cap applies. Same Vercel Function platform limit as POST /library; multipart bodies over the cap return 413 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. version is required. If you want the server to classify the bump, use POST /api/v1/library.
  • It does not enforce monotonicity. Going from 2.3.7 back to 0.9 is 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}/unpublish

From 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 is F. 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 --json

Idempotency & 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 24hCached response, same status, plus Idempotent-Replayed: true.
Different body422 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-flight409 idempotency_key_in_progress. Wait for the first call to return before retrying.
After 24 hoursThe 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 restart mid-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))
done

Limits & 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-key60 / minute for most CLI routes (cli-crud bucket). GET /api/v1/library uses a tighter 10 / minute sync bucket because the response is heavy and clients should cache via ETag.
  • Per-account anti-abuse on POST /api/v1/library (push) 5 / minute and 30 / hour on Publisher; 30 / minute and 500 / hour on Team. POST /api/v1/library/{owner}/{name}/versions shares 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 / minute and 500 / hour on Publisher; 120 / minute and 2000 / hour on 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 (declared Content-Length) carries max_size_bytes in details, and the in-loop 413 additionally carries your_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.md is the maximum; deeper paths return 400 invalid_path.
  • Blocked extensions: executables and binary archives (.exe, .dll, .so, .dylib, .bin, .jar, .wasm, and similar). The full list lives at src/lib/skills/constants.ts in 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.

StatusCodeMeaning
400invalid_skill_mdSKILL.md parse or frontmatter validation failed. details[] carries per-field errors.
400invalid_pathA file part’s path failed validation (traversal, absolute path, depth, or blocked extension).
400invalid_multipartBody could not be parsed as multipart/form-data. Check Content-Type — most often forgotten when hand-building the request.
400missing_versionPOST /api/v1/library/{owner}/{name}/versions only. The required version form field was absent or empty.
400invalid_versionPOST /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.
400name_mismatchPOST /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.
401unauthorizedMissing or invalid bearer token.
403insufficient_scopeYour access key is read-only; this endpoint requires registry:write.
403plan_limitYour plan’s skill-count or library-size cap was reached. Upgrade or remove items.
404not_foundSkill not found, or not public (the registry intentionally does not distinguish these — see refs recipe).
404skill_not_foundPOST /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.
409source_conflictA skill with this name is attributed to a different account.
409version_conflictPOST /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.
409self_ownershipCan’t add your own skill to your library — owned skills are already accessible.
409already_in_librarySkill is already in your library. Idempotent — treat as success.
409idempotency_key_in_progressAnother request with the same Idempotency-Key is still being processed. Wait for it to complete.
409concurrent_createTwo pushes for the same skill name raced; this one lost. Retry — the retry takes the update path against the winning row.
413payload_too_largeMultipart 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.
422idempotency_key_reusedSame Idempotency-Key sent with a different request body. Use a fresh key.
429rate_limitedRate-limit bucket exceeded. Retry-After tells you how long to wait (seconds).

Command Palette

Search for a command to run...