Skip to content

Skill Sync Source Management API

Table of Contents

  1. API Route Prefix
  2. API Endpoints
  3. 2.1. Create Skill Sync Source
  4. 2.2. List Skill Sync Sources
  5. 2.3. Get Skill Sync Source Detail
  6. 2.4. Update Skill Sync Source
  7. 2.5. Delete Skill Sync Source
  8. 2.6. Trigger Sync
  9. 2.7. Initiate OAuth
  10. 2.8. OAuth Callback
  11. 2.9. Get Sync Job
  12. Access Control
  13. Data Models
  14. 4.1. SkillSyncSource
  15. 4.2. SkillSyncJob
  16. 4.3. Enums
  17. State Machine
  18. GitHub App OAuth Flow (PKCE)
  19. Token Management
  20. Error Response Format

API Route Prefix

/api/v1/skill-sync-sources

API Endpoints

1. Create Skill Sync Source

Endpoint: POST /api/v1/skill-sync-sources

Request Body:

{
  "displayName": "My Skills Repo",
  "description": "Production skill definitions",
  "tags": ["production", "internal"],
  "owner": "my-org",
  "repo": "skills-repo",
  "ref": "main",
  "paths": ["skills/", "prompts/mcp"],
  "githubAppClientId": "github-app-client-id",
  "githubAppClientSecret": "github-app-client-secret"
}

Request Fields: - displayName (required, string): Human-readable name, 1–128 characters - description (optional, string): Source description - tags (optional, array of strings): Categorization tags - owner (required, string): GitHub owner (user or org), 1–39 characters, alphanumeric + hyphens, regex: ^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ - repo (required, string): GitHub repository name, 1–100 characters, regex: ^[A-Za-z0-9._-]+$ - ref (optional, string): Git ref to sync from (default: "main"), 1–255 characters, validated against path traversal - paths (required, array of strings, min 1): Repository-relative POSIX paths to scan for skills. Must be safe relative paths (no leading /, no .., no \). Each path is a container: only its direct child folders holding a SKILL.md become skills — the path itself is never a skill, so a SKILL.md at the path root is skipped. Use ["."] to scan the repository root. - githubAppClientId (required, string): GitHub App OAuth client ID - githubAppClientSecret (required, string): GitHub App client secret (encrypted at rest via AES-CBC)

Validation Rules: - paths must not contain duplicates after normalization - ref must be a safe Git ref (no .., //, @{, or \) - owner must match GitHub username format - repo must match GitHub repository name format

Response: 201 Created

{
  "id": "source-id-1",
  "providerType": "github",
  "displayName": "My Skills Repo",
  "description": "Production skill definitions",
  "tags": ["production", "internal"],
  "owner": "my-org",
  "repo": "skills-repo",
  "ref": "main",
  "paths": ["skills/", "prompts/mcp"],
  "status": "active",
  "syncStatus": "idle",
  "syncMessage": null,
  "stats": { "skillCount": 0, "fileCount": 0 },
  "lastSync": null,
  "permissions": { "VIEW": true, "EDIT": true, "DELETE": true, "SHARE": true },
  "createdAt": "2026-08-19T10:30:00Z",
  "updatedAt": "2026-08-19T10:30:00Z",
  "githubAppClientId": "github-app-client-id",
  "hasClientSecret": true,
  "recentJobs": [],
  "createdBy": "user-id-1",
  "updatedBy": "user-id-1"
}

Important Notes: - The creator is automatically granted OWNER permission (VIEW + EDIT + DELETE + SHARE) via an atomic MongoDB transaction - githubAppClientSecret is never returned in any response; hasClientSecret indicates whether one is stored

Error: - 422 Validation error (invalid paths, bad owner/repo format, missing required fields) - 500 Internal server error


2. List Skill Sync Sources

Endpoint: GET /api/v1/skill-sync-sources

Query Parameters:

{
  syncStatus?: string;     // Filter by sync status (e.g., "idle", "syncing", "failed")
  tag?: string;            // Filter by tag
  query?: string;          // Full-text search (displayName, description)
  page?: number;           // Page number (default: 1, min: 1)
  perPage?: number;        // Items per page (default: 20, min: 1, max: 100)
}

Response: 200 OK

{
  "sources": [
    {
      "id": "source-id-1",
      "providerType": "github",
      "displayName": "My Skills Repo",
      "description": "Production skill definitions",
      "tags": ["production"],
      "owner": "my-org",
      "repo": "skills-repo",
      "ref": "main",
      "paths": ["skills/"],
          "status": "active",
      "syncStatus": "success",
      "syncMessage": null,
      "stats": { "skillCount": 12, "fileCount": 8 },
      "lastSync": {
        "jobId": "job-id-1",
        "status": "success",
        "startedAt": "2026-08-19T10:00:00Z",
        "finishedAt": "2026-08-19T10:02:30Z",
        "commitSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
      },
      "permissions": { "VIEW": true, "EDIT": true, "DELETE": false, "SHARE": false },
      "createdAt": "2026-08-19T10:30:00Z",
      "updatedAt": "2026-08-19T15:45:00Z"
    }
  ],
  "pagination": {
    "total": 5,
    "page": 1,
    "perPage": 20,
    "totalPages": 1
  }
}

Important Notes: - Only returns sources the authenticated user has VIEW access to (ACL-filtered) - Each source includes per-resource permissions for the requesting user - Results are sorted by updatedAt descending

Error: - 500 Internal server error


3. Get Skill Sync Source Detail

Endpoint: GET /api/v1/skill-sync-sources/{source_id}

Response: 200 OK

{
  "id": "source-id-1",
  "providerType": "github",
  "displayName": "My Skills Repo",
  "description": "Production skill definitions",
  "tags": ["production"],
  "owner": "my-org",
  "repo": "skills-repo",
  "ref": "main",
  "paths": ["skills/"],
  "status": "active",
  "syncStatus": "success",
  "syncMessage": null,
  "stats": { "skillCount": 12, "fileCount": 8 },
  "lastSync": {
    "jobId": "job-id-1",
    "status": "success",
    "startedAt": "2026-08-19T10:00:00Z",
    "finishedAt": "2026-08-19T10:02:30Z",
    "commitSha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
  },
  "permissions": { "VIEW": true, "EDIT": true, "DELETE": true, "SHARE": true },
  "createdAt": "2026-08-19T10:30:00Z",
  "updatedAt": "2026-08-19T15:45:00Z",
  "githubAppClientId": "github-app-client-id",
  "hasClientSecret": true,
  "recentJobs": [
    {
      "id": "job-id-1",
      "sourceId": "source-id-1",
      "jobType": "full_sync",
      "triggerType": "manual",
      "status": "success",
      "phase": "completed",
      "requestSnapshot": {
        "owner": "octocat",
        "repo": "skills",
        "ref": "main",
        "paths": ["skills"],
        "configRevision": 1
      },
      "discoverySummary": { "discoveredSkillCount": 12, "discoveredFileCount": 8, "skippedPaths": [] },
      "applySummary": { "skillsCreated": 12, "skillsUpdated": 0, "skillsDeleted": 0, "skillsFailed": 0, "filesCreated": 8, "filesUpdated": 0, "filesDeleted": 0 },
      "skillErrors": [],
      "errorCode": null,
      "error": null,
      "startedAt": "2026-08-19T10:00:00Z",
      "finishedAt": "2026-08-19T10:02:30Z",
      "createdAt": "2026-08-19T10:00:00Z",
      "updatedAt": "2026-08-19T10:02:30Z"
    }
  ],
  "createdBy": "user-id-1",
  "updatedBy": "user-id-1"
}

Important Notes: - Detail response extends the list response with githubAppClientId, hasClientSecret, recentJobs, createdBy, updatedBy - recentJobs returns the last 10 jobs sorted by createdAt descending

Error: - 403 User does not have VIEW permission - 404 Source not found - 500 Internal server error


4. Update Skill Sync Source

Endpoint: PUT /api/v1/skill-sync-sources/{source_id}

Request Body (all fields optional, partial update via exclude_unset):

{
  "displayName": "Updated Name",
  "description": "Updated description",
  "tags": ["production", "v2"],
  "owner": "new-org",
  "repo": "new-repo",
  "ref": "develop",
  "paths": ["src/skills/"],
  "githubAppClientId": "new-github-app-client-id",
  "githubAppClientSecret": "new-secret",
  "syncAfterUpdate": false
}

Request Fields: - All create fields are accepted (same validation rules apply) - syncAfterUpdate (optional, boolean, default: false): When true, triggers a sync after the update when a usable GitHub token is available; otherwise returns needsAuthorization: true

Behavior: - Only ACTIVE sources can be updated (state machine guard) - Changing githubAppClientId or githubAppClientSecret automatically deletes all stored OAuth tokens for this source, forcing re-authorization on next sync

Response: 200 OK — when syncAfterUpdate is omitted or false, returns the updated SkillSyncSourceDetailResponse

When syncAfterUpdate=true and sync starts successfully, returns SkillSyncTriggerResponse:

{
  "job": {
    "id": "job-id-3",
    "sourceId": "source-id-1",
    "jobType": "full_sync",
    "triggerType": "manual",
    "status": "pending",
    "phase": "queued",
    "requestSnapshot": {
      "owner": "new-org",
      "repo": "new-repo",
      "ref": "develop",
      "paths": ["src/skills/"],
    },
    "discoverySummary": { "discoveredSkillCount": 0, "discoveredFileCount": 0, "skippedPaths": [] },
    "applySummary": { "skillsCreated": 0, "skillsUpdated": 0, "skillsDeleted": 0, "skillsFailed": 0, "filesCreated": 0, "filesUpdated": 0, "filesDeleted": 0 },
    "skillErrors": [],
    "errorCode": null,
    "error": null,
    "startedAt": null,
    "finishedAt": null,
    "createdAt": "2026-08-19T10:00:00Z",
    "updatedAt": "2026-08-19T10:00:00Z"
  },
  "needsAuthorization": false,
  "authorizeUrl": null
}

When authorization is required:

{
  "job": null,
  "needsAuthorization": true,
  "authorizeUrl": null
}

Error: - 403 User does not have EDIT permission - 404 Source not found - 409 Source cannot be updated (status is not ACTIVE) - 500 Internal server error


5. Delete Skill Sync Source

Endpoint: DELETE /api/v1/skill-sync-sources/{source_id}

This endpoint: 1. Transition source status to DELETING 2. Create a DELETE_SYNC job that removes all synced skills outright, along with their auxiliary files, ACL entries, and stored GitHub tokens 3. Return 202 Accepted with the job ID

Child skills are hard-deleted, matching how federation reconciles stale children. The source document itself is soft-deleted (status=deleted plus a deletedAt timestamp) so the delete outcome stays auditable. Stale-skill reconciliation during a normal sync — a skill that disappeared upstream — hard-deletes the same way.

Response: 202 Accepted

{
  "sourceId": "source-id-1",
  "jobId": "job-id-2",
  "status": "deleting"
}

Error: - 403 User does not have DELETE permission - 404 Source not found - 409 Source cannot be deleted (status is not ACTIVE) - 500 Internal server error


6. Trigger Sync

Endpoint: POST /api/v1/skill-sync-sources/{source_id}/sync

This endpoint: 1. Check for an existing OAuth token (access → refresh fallback) 2. If no valid token is available, return needsAuthorization: true 3. If a token is valid, atomically transition the source to pending, persist a FULL_SYNC job, and return job details 4. The app-scoped job runner atomically claims persisted jobs with a renewable lease. An expired lease is reclaimed after process failure; after three abandoned attempts the job is failed and the source is released from its active state

Response: 200 OK

{
  "job": {
    "id": "job-id-3",
    "sourceId": "source-id-1",
    "jobType": "full_sync",
    "triggerType": "manual",
    "status": "pending",
    "phase": "queued",
    "...": "..."
  },
  "needsAuthorization": false,
  "authorizeUrl": null
}

Or when re-authorization is needed:

{
  "job": null,
  "needsAuthorization": true,
  "authorizeUrl": null
}

Error: - 403 User does not have EDIT permission - 404 Source not found - 409 Source already has an active sync job - 500 Internal server error


7. Initiate OAuth

Endpoint: GET /api/v1/skill-sync-sources/{source_id}/oauth/initiate

This endpoint: 1. Generate PKCE code_verifier + code_challenge (S256 via authlib) 2. Store OAuth flow state in FlowStateManager (Redis or memory fallback) 3. Redirect (307) to https://github.com/login/oauth/authorize with PKCE parameters

Response: 307 Temporary Redirect - Location: https://github.com/login/oauth/authorize?client_id=...&redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256

Error: - 403 User does not have EDIT permission - 404 Source not found - 500 Internal server error


8. OAuth Callback

Endpoint: GET /api/v1/skill-sync-sources/oauth/callback

This is the GitHub OAuth redirect target. It is a single constant URL for the whole deployment — the source_id is no longer in the path; it is recovered from the state parameter. It is unauthenticated (GitHub redirects do not carry session cookies).

Query Parameters (set by GitHub):

{
  code?: string;    // Authorization code
  state?: string;   // CSRF state token
  error?: string;   // Error from GitHub (e.g., "access_denied")
}

Behavior: - Resolves source_id from state first. If state is missing or does not resolve to a known flow (expired, already consumed, malformed) → redirects to the generic list page with error=invalid_callback (there is no source to attribute the error to). - Once the source is resolved: if error is present or code is missing → redirects to that source's page with error=auth_failed. - If source not found (404) → redirects to that source's page with error=auth_failed - Otherwise, validates state token and consumes the stored flow from FlowStateManager - Exchanges code for tokens at https://github.com/login/oauth/access_token with code_verifier - Stores encrypted tokens (access + refresh) in MongoDB tokens - Triggers a FULL_SYNC job with triggerType=oauth_callback

Response: 307 Temporary Redirect - Success: Location: {registry_client_url}/skill-sync-sources/{source_id}?status=syncing - Resolved-source error: Location: {registry_client_url}/skill-sync-sources/{source_id}?error=auth_failed - Unresolvable state: Location: {registry_client_url}/skill-sync-sources?error=invalid_callback


9. Get Sync Job

Endpoint: GET /api/v1/skill-sync-sources/{source_id}/jobs/{job_id}

Response: 200 OK

{
  "id": "job-id-1",
  "sourceId": "source-id-1",
  "jobType": "full_sync",
  "triggerType": "manual",
  "status": "syncing",
  "phase": "discovering",
  "requestSnapshot": {
    "owner": "octocat",
    "repo": "skills",
    "ref": "main",
    "paths": ["skills"],
    "configRevision": 1
  },
  "discoverySummary": {
    "discoveredSkillCount": 8,
    "discoveredFileCount": 5,
    "skippedPaths": ["skills/vendor", "skills/README.md"]
  },
  "applySummary": {
    "skillsCreated": 0,
    "skillsUpdated": 0,
    "skillsDeleted": 0,
    "skillsFailed": 0,
    "filesCreated": 0,
    "filesUpdated": 0,
    "filesDeleted": 0
  },
  "skillErrors": [],
  "errorCode": null,
  "error": null,
  "startedAt": "2026-08-19T10:00:00Z",
  "finishedAt": null,
  "createdAt": "2026-08-19T10:00:00Z",
  "updatedAt": "2026-08-19T10:01:15Z"
}

Error: - 403 User does not have VIEW permission on the source - 404 Source or job not found - 500 Internal server error


Access Control

Skill sync sources use the same ACL system as MCP Servers, A2A Agents, and Workflows:

Property Value
Resource type skill_sync_source (in RegistryResourceType enum)
Permission bits VIEW (1), EDIT (3), DELETE / OWNER (15)
Seed roles skill_sync_source_viewer (permBits=1), skill_sync_source_editor (permBits=3), skill_sync_source_owner (permBits=15)
On create Creator is automatically granted OWNER permission (atomic MongoDB transaction)

Per-endpoint permission requirements:

Endpoint Required Permission
POST / (create) Authenticated user (any)
GET / (list) VIEW (ACL-filtered)
GET /{id} (detail) VIEW
PUT /{id} (update) EDIT
DELETE /{id} DELETE
POST /{id}/sync EDIT
GET /{id}/oauth/initiate EDIT
GET /oauth/callback None (unauthenticated, GitHub redirect)
GET /{id}/jobs/{job_id} VIEW

Data Models

SkillSyncSource

MongoDB collection: skill_sync_sources

Field Type Description
providerType SkillSyncProviderType Source provider, always github for now
displayName string Human-readable name
description string | null Optional description
tags string[] Categorization tags
owner string GitHub owner (user or org)
repo string GitHub repository name
ref string Git ref (branch/tag), default "main"
paths string[] Repo-relative POSIX paths to scan
configRevision integer Internal monotonic revision for execution-affecting source configuration
githubAppClientId string GitHub App OAuth client ID
githubAppClientSecretEncrypted string AES-CBC encrypted client secret
status SkillSyncSourceStatus Source lifecycle status
syncStatus SkillSyncStatus Current sync state
syncMessage string | null Last sync error/status message
stats SkillSyncSourceStats Live synced inventory counts: { skillCount, fileCount } for non-deleted GitHub skills and their auxiliary files
lastSync SkillSyncSourceLastSync | null Last completed sync snapshot; commitSha is either a 40-character Git commit SHA or "unknown"
createdBy string | null Creator user ID
updatedBy string | null Last updater user ID
createdAt datetime Auto-set on insert
updatedAt datetime Auto-updated on save
deletedAt datetime | null Soft delete timestamp

Indexes: - (providerType, status, updatedAt desc) — filtered listing - (syncStatus, updatedAt desc) — sync queue queries - Text index on (displayName, description) — full-text search

SkillSyncJob

MongoDB collection: skill_sync_jobs

Field Type Description
sourceId PydanticObjectId Reference to SkillSyncSource
jobType SkillSyncJobType full_sync / config_resync / delete_sync
triggerType SkillSyncTriggerType manual / oauth_callback / api
triggeredBy string User ID who triggered the job
status SkillSyncJobStatus Job status
phase SkillSyncJobPhase Detailed execution phase
requestSnapshot SkillSyncFullRequestSnapshot | SkillSyncDeleteRequestSnapshot Typed, immutable execution input; full sync stores owner/repo/ref/paths/configRevision and delete stores action/configRevision
discoverySummary SkillSyncDiscoverySummary { discoveredSkillCount, discoveredFileCount, skippedPaths }
applySummary SkillSyncApplySummary { skillsCreated/Updated/Deleted/Failed, filesCreated/Updated/Deleted }
skillErrors SkillSyncSkillError[] Per-skill error details
errorCode string | null Machine-readable error code
error string | null Human-readable error message
startedAt datetime | null When execution started
finishedAt datetime | null When execution completed
leaseOwner string | null Registry runner instance currently owning the job
leaseExpiresAt datetime | null Renewable claim deadline used for crash recovery
heartbeatAt datetime | null Last successful lease renewal
attemptCount integer Number of worker claims, including crash-recovery attempts

Indexes: - (sourceId, createdAt desc) — recent jobs query - (sourceId, status) — active job guard - (status, leaseExpiresAt, createdAt) — durable claim and expired-lease recovery

The durable runner executes only from requestSnapshot. If an execution-affecting source update increments configRevision while a job is queued, that stale job fails before contacting GitHub; a new sync must be triggered from the updated source configuration.

Enums

SkillSyncSourceStatus: active | deleting | deleted

SkillSyncStatus: idle | pending | syncing | success | partial_success | failed

SkillSyncJobType: full_sync | config_resync | delete_sync

SkillSyncTriggerType: manual | oauth_callback | api

SkillSyncJobStatus: pending | syncing | success | partial_success | failed

SkillSyncJobPhase: queued | downloading | extracting | discovering | applying | completed | failed

SkillSyncJobErrorCode: github_auth_failed | github_rate_limited | github_not_found | download_failed | download_too_large | extraction_failed | decompression_bomb | no_skills_found | sync_not_implemented | internal_error

SkillSyncSkillErrorCode: skill_parse_failed | skill_name_missing | duplicate_skill_name | file_too_large | too_many_files | write_failed


State Machine

Source Status Transitions

ACTIVE ──→ DELETING ──→ DELETED
  ↑            │
  └── (restore on delete failure)
  • Only ACTIVE sources can be updated or start a sync
  • Only ACTIVE sources can transition to DELETING
  • restore_after_delete_failure reverts DELETINGACTIVE with syncStatus=FAILED

Sync Status Transitions

IDLE ──→ PENDING ──→ SYNCING ──→ SUCCESS
  ↑                      │        PARTIAL_SUCCESS
  └──────────────────────←── FAILED
  • can_start_sync: allowed from IDLE, SUCCESS, PARTIAL_SUCCESS, FAILED
  • Cannot start a new sync while in PENDING or SYNCING

Job Status Transitions

PENDING → SYNCING → SUCCESS / PARTIAL_SUCCESS / FAILED

Job Phase Transitions

QUEUED → DOWNLOADING → EXTRACTING → DISCOVERING → APPLYING → COMPLETED
                                                                FAILED

GitHub App OAuth Flow (PKCE)

Prerequisites

  1. Create a GitHub App (not an OAuth App):
  2. GitHub → Settings → Developer settings → GitHub Apps → New GitHub App
  3. Set Callback URL to https://<your-domain>/api/v1/skill-sync-sources/oauth/callback (one constant URL for all sources — the source_id is carried in the OAuth state, not the path)
  4. Enable "Request user authorization (OAuth) during installation"

  5. Set permissions: Repository permissions → Contents → Read-only

  6. Generate client secret on the App settings page

  7. Install the App on the target org/user account, granting access to specific repositories

Flow Sequence

1. User clicks "Connect GitHub"
   → GET /api/v1/skill-sync-sources/{source_id}/oauth/initiate

2. Server generates PKCE parameters:
   - code_verifier = secrets.token_urlsafe(32)
   - code_challenge = create_s256_code_challenge(code_verifier)  # via authlib
   - Stores flow state in FlowStateManager (Redis / memory fallback)

3. Server redirects (307) to GitHub:
   → https://github.com/login/oauth/authorize
     ?client_id={githubAppClientId}
     &redirect_uri={callback_url}
     &state={encrypted_flow_state}
     &code_challenge={code_challenge}
     &code_challenge_method=S256

4. User authorizes on GitHub

5. GitHub redirects to callback:
   → GET /api/v1/skill-sync-sources/oauth/callback
     ?code={authorization_code}
     &state={state}   # source_id is resolved from state

6. Server exchanges code for tokens:
   → POST https://github.com/login/oauth/access_token
     client_id, client_secret, code, redirect_uri, code_verifier

7. Server stores encrypted tokens (AES-CBC) in MongoDB Token collection

8. Server redirects to frontend:
   → {registry_client_url}/skill-sync-sources/{source_id}?status=syncing

Token Prefix Reference

Prefix Type Description
ghu_ GitHub App user-to-server token Issued by GitHub Apps via user OAuth
gho_ OAuth App token Issued by classic OAuth Apps (not used here)

Token Management

Storage

OAuth tokens are stored in the tokens MongoDB collection with AES-CBC encryption:

Token Type Identifier Pattern Default Lifetime
skill_sync_github_access skillsync:{source_id} 10 years (GitHub Apps without expiration setting)
skill_sync_github_refresh skillsync:{source_id} 1 year

Resolution Flow

resolve_access_token(user_id, source_id, client_id, client_secret):
  1. Look up access token → if valid (not expired), return it
  2. Look up refresh token → if valid, refresh via GitHub API → store new tokens → return
  3. No valid token → return None (caller returns needsAuthorization=true)

Token Cleanup

  • When githubAppClientId or githubAppClientSecret is changed via PUT, all stored tokens for the source are deleted
  • When a source is deleted, all associated tokens are removed

Error Response Format

All error responses follow the standard format:

{
  "detail": "Human-readable error message"
}
Status Code Meaning
403 Insufficient permissions
404 Resource not found
409 Conflict (invalid state for operation, e.g., updating a non-ACTIVE source)
422 Validation error (invalid input)
500 Internal server error

Durable Sync Integration Tests

The regular suite runs deterministic unit tests and skips tests that require a live MongoDB replica set. To verify atomic worker leasing and transaction rollback against MongoDB itself:

SKILL_SYNC_MONGO_INTEGRATION_URI='mongodb://127.0.0.1:27017/?replicaSet=rs0' \
  LOG_FORMAT='%(levelname)s:%(name)s:%(message)s' \
  uv run pytest registry/tests/integration/test_skill_sync_durability.py

The integration file uses isolated, randomly named databases and removes them after every test.