---
title: "Push a skill from local files (upsert)"
method: POST
path: "/api/v1/library"
tags: ["Library"]
---

# Push a skill from local files (upsert)

`POST /api/v1/library`

## Request

- Base URL: `https://skillrepo.dev`
- URL: `https://skillrepo.dev/api/v1/library`
- Auth: HTTP bearer

Multipart upsert. If no skill with the SKILL.md's `name` exists in the caller's account, creates one with `visibility: "private"`. If a skill already exists and the content has changed, releases a new version (server-classified major/minor bump from contract-field diff: `description`, `allowed-tools`, `compatibility`). If content is unchanged, returns 200 with `action: "unchanged"` and no new version is created.

**File-path encoding.** Each file part's relative path within the skill is carried via the multipart `Content-Disposition: filename` parameter per RFC 7578. Most HTTP libraries set this automatically when you pass a `File` or `Blob` with a name. The server does NOT read a parallel `paths` JSON field — only the `filename` parameter.

**Payload size.** Total multipart body capped at 4.5 MB (the Vercel Function platform body limit). Larger payloads return 413 `payload_too_large`. The cap is enforced pre-parse via `Content-Length` so oversized requests never buffer the body.

**Idempotency.** Supports the optional `Idempotency-Key` header. Retries with the same key + same body return the cached response (2xx OR 4xx) with `Idempotent-Replayed: true`. Retries with a different body return 422 `idempotency_key_reused`. Concurrent retries while the first is in-flight return 409 `idempotency_key_in_progress`. Cached responses live for 24h.

Error codes: `missing_skill_md`, `invalid_skill_md`, `invalid_path`, `duplicate_path`, `invalid_multipart`, `payload_too_large`, `plan_limit`, `source_conflict`, `concurrent_create`, `concurrent_update`, `handle_required`, `rate_limited`, `not_found` (account lookup), `idempotency_key_*`.

## Headers

- `Idempotency-Key` string: Client-supplied idempotency key (max 255 chars). Retries with the same key + same request body return the cached response (with `Idempotent-Replayed: true`) instead of re-executing the handler. Retries with the same key + a different body return 422 `idempotency_key_reused`. Concurrent retries while the original is in-flight return 409 `idempotency_key_in_progress`. Cached responses live for 24 hours.

## Response `200`

Skill upserted — `action` is `updated` (new version released) or `unchanged` (server SHA-matched the latest version; no new version created). On Idempotency-Key replays the same body is returned with `Idempotent-Replayed: true`.

- LibraryPushResponse: Response returned by `POST /api/v1/library` (multipart file-push, #1452). The `skill` field shape matches `SyncSkill` (one entry from `GET /api/v1/library`'s `skills[]` array), but content varies by `action`: - `created` / `updated`: `skill` reflects the JUST-PUSHED state (new description, new version label, new SKILL.md, new files). When server-side safety analysis is enabled, this differs from what `GET /api/v1/library` returns until analysis completes — GET continues to surface the prior `currentVersion` during the analysis window. The POST response gives callers the optimistic post-push view so the CLI can update its local cache without waiting on analysis. - `unchanged`: `skill` reflects canonical post-commit state, identical to what `GET /api/v1/library` returns. No new version was created (the server SHA-matched the latest committed version).
  - `action` 'created' | 'updated' | 'unchanged', required: Server-detected outcome of an upsert. `created` = first time this name was pushed for the account. `updated` = name existed and content (SKILL.md or files) changed, so a new version was released. `unchanged` = name existed and content was identical; no new version was created.
  - `bump` 'major' | 'minor', nullable, required: Server-classified version bump on the `updated` action: `major` if any contract-level frontmatter field changed (`description`, `allowed-tools`, `compatibility`); `minor` otherwise. Null for `created` and `unchanged`.
  - `skill` SyncSkill, required: Per-skill shape shared by bulk library sync and single-skill GET. Content is byte-identical across endpoints; the optional `governance` field rides only the manifest/scoped read paths.
    - `owner` string, required
    - `ownerName` string, required
    - `name` string, required
    - `version` string, nullable, required
    - `description` string, required
    - `keywords` string[], required: Single-word keywords extracted from the skill's `triggers` (stop-words removed, lowercased) for client-side indexing.
    - `updatedAt` string, date-time, required
    - `etag` string, required: Per-skill ETag of the form `"owner/name@<timestamp>"`. Used by clients for conditional re-fetching; the bulk sync response also sets an HTTP-level `ETag` covering the whole response.
    - `contextSignals` ContextSignals, nullable, required: Context-signals object attached to a skill by the analysis pipeline. Open-shaped — clients should pass through to UI without strict validation. Null when analysis has not yet run.
    - `files` SyncFile[], required
      - `path` string, required: File path relative to the skill root.
      - `content` string, required: Inlined file contents. UTF-8 when `encoding: utf-8`; base64 of the raw bytes when `encoding: base64`.
      - `sha256` string, required: Hex-encoded SHA-256 of the raw file bytes. Empty string for the synthesized SKILL.md entry (clients use the version-level `etag` for freshness there).
      - `contentType` string, required: MIME type. Text content types are inlined as UTF-8; everything else as base64.
      - `size` integer, required: File size in bytes (pre-base64-encoding).
      - `encoding` 'utf-8' | 'base64', required: How `content` is encoded. `utf-8` for text types, `base64` for binary.
    - `filesIncomplete` boolean, required: True when one or more blob-backed files failed to inline and were dropped from `files`. When true, the route SUPPRESSES the response-level HTTP `ETag` header and sets `Cache-Control: private, no-store` so clients re-fetch on the next sync instead of caching an incomplete response. On the push (POST) response this field is almost always `false` — files were just uploaded successfully, so the inline-fetch rarely fails. A `true` value here indicates a transient blob store hiccup between upload and response build; the next GET will return the complete state.
    - `governance` SyncSkillGovernance: Per-skill delivery governance carried ONLY on the non-recording read paths — the `?manifest=1` whole-library and skillset-scoped reads (`skillrepo list`). Absent on the byte-identical whole-library sync wire (D2) and the skillset-scoped sync wire, and absent entirely when the recall feature is disabled.
      - `status` 'latest' | 'serving' | 'pinned', required: Delivery status in the shared version-status vocabulary: `latest` — the served version is the publisher's head (healthy); `serving` — a recall rolled the account back to an earlier version (the served version is below head and diverted); `pinned` — a deliberate hold behind head (not a recall).
      - `head` string, nullable, required: The publisher's current head version label, for context (e.g. "vX available"). Null when the skill has no published head.
      - `recalledVersion` string, nullable: Present only when `status` is `serving`: the version that WOULD serve absent the recall — the one recalled and rolled back FROM. The version actually served is this skill's own `version`.

## Other responses

- `201`: Skill created (first push of this name in the caller's account). On Idempotency-Key replays the same 201 is returned with `Idempotent-Replayed: true`.
- `400`: Invalid request — malformed JSON, missing required fields, or failed schema validation. Body matches `ErrorEnvelope`.
- `401`: Missing or invalid authentication. For routes that require `bearerAccessKey` or `bearerCatalogKey`.
- `403`: Authenticated, but the key lacks the required scope or the account is suspended. Use `code` to distinguish: `insufficient_scope`, `account_suspended`, `plan_limit`.
- `404`: Resource does not exist, OR exists but the caller has no permission to see it. Private resources return 404 (never 403) to avoid leaking existence.
- `409`: Conflict — name collision under a different attribution (`source_conflict`), in-flight idempotent request (`idempotency_key_in_progress`), a concurrent CREATE race where another push for the same skill name landed first (`concurrent_create`), or a concurrent UPDATE race where another release for the same existing skill landed first (`concurrent_update`). On both `concurrent_*` codes the caller can retry immediately; the retry compares against the winning row and returns the correct outcome.
- `413`: Multipart body exceeds the 4.5 MB cap (Vercel Function platform body limit). The pre-parse 413 (fired on the declared `Content-Length`) returns `details: { max_size_bytes }` only; the in-loop 413 additionally returns `your_size_bytes` with the real measured byte count.
- `422`: Unprocessable request. `code` distinguishes the cases: `idempotency_key_reused` — the Idempotency-Key was reused with a different request body; `handle_required` — the push would CREATE a skill while the account's Author ID is still an unchosen placeholder. Set the Author ID in Settings → Account Profile, then retry. Updates to existing skills are never blocked by `handle_required`.
- `429`: Rate limit exceeded on the route's bucket. Includes `X-RateLimit-*` headers indicating the limit, remaining, and reset time.

---

[API](https://skmtc.dev/skillrepo/apis/skillrepo-api.md) · [All operations](https://skmtc.dev/skillrepo/apis/skillrepo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/skillrepo/apis/skillrepo-api/revisions/b62bac246918?raw)
