Documentation

Extended Skills by Sapphire

Extended Skills by Sapphire is a skills API for AI agents and agentic CLIs.

Each skill lives in a folder and includes a SKILL.md file. A client can use the API to:

  • get the total skill count
  • list skill summaries
  • search skills by title
  • load the full SKILL.md for one skill

This is the intended flow for agent clients that need to find a skill, fetch it, and install or cache it locally before use.

Get Access

Create an API key from the API Keys page.

Send the key with one of these headers:

  • x-api-key: <your-key>
  • Authorization: Bearer <your-key>

Requests without a valid key return 401.

Base URL

https://skills.trysapphire.today

Environment Variables

SAPPHIRE_API_BASE_URL=https://skills.trysapphire.today
SAPPHIRE_API_KEY=your_api_key_here

Quick Start

  1. Create an API key.
  2. Call POST /v1/skills/search.
  3. Pick a skill_id from the results.
  4. Call GET /v1/skills/{skillId}.
  5. Save the returned markdown or install it into your agent's skill directory.

API Surface

Get Stats

Returns the current index metadata.

GET /v1/skills/stats

Response fields:

  • schema_version
  • total_skills
  • generated_at
  • categories

Use this endpoint when the client needs the total skill count before search or sync.

List the Manifest

Returns paginated skill summaries.

GET /v1/skills/manifest?limit=200&cursor=0&category=all

Query parameters:

  • limit
  • cursor
  • category

Each item includes:

  • skill_id
  • folder_name
  • skill_name
  • relative_path
  • markdown_path
  • size_bytes
  • is_nested
  • category

Use this endpoint when the client needs a full index view or wants to prefetch summaries.

Search Skills

Searches skills by title.

POST /v1/skills/search
Content-Type: application/json

Request body:

{
  "query": "zapier integration",
  "category": "integrations",
  "limit": 20
}

Response fields:

  • query
  • category
  • total_skills
  • returned
  • limit
  • results

Use this endpoint when the agent already knows the type of skill it needs.

Load a Skill

Loads the selected skill and returns the full SKILL.md.

GET /v1/skills/{skillId}

Response fields:

  • skill
  • markdown
  • files

markdown is the full SKILL.md content. files contains the returned file payload for the skill.

Use this endpoint after search, once the client has an exact skill_id.

Common Response Fields

skill_id

skill_id is the exact identifier used by the load endpoint.

Pass it back unchanged to:

GET /v1/skills/{skillId}

folder_name

The final folder name for the skill.

skill_name

Currently the same value as folder_name.

markdown

The full SKILL.md text for the selected skill.

Minimal cURL Example

curl -X POST "$SAPPHIRE_API_BASE_URL/v1/skills/search" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $SAPPHIRE_API_KEY" \
  -d '{
    "query": "quickbooks",
    "limit": 10
  }'

Load

curl "$SAPPHIRE_API_BASE_URL/v1/skills/quickbooks-automation" \
  -H "x-api-key: $SAPPHIRE_API_KEY"
  • keep the API key on the server or in a local CLI config
  • search first, then load by exact skill_id
  • cache loaded skills locally when that fits your client
  • treat markdown as the source input for installation
  • handle 401, 404, and 500 explicitly

TypeScript

const baseUrl = process.env.SAPPHIRE_API_BASE_URL!;
const apiKey = process.env.SAPPHIRE_API_KEY!;

type SkillSummary = {
  skill_id: string;
  folder_name: string;
  skill_name: string;
  relative_path: string;
  markdown_path: string;
  size_bytes: number;
  is_nested: boolean;
  category: string | null;
};

type SearchResponse = {
  query: string;
  category: string | null;
  total_skills: number;
  returned: number;
  limit: number;
  results: SkillSummary[];
};

type LoadResponse = {
  skill: SkillSummary;
  markdown: string;
  files: Array<{
    path: string;
    size_bytes: number;
    mime_type: string;
    sha256: string;
    encoding: "text";
    text: string;
    base64: null;
  }>;
};

async function searchSkills(query: string): Promise<SearchResponse> {
  const response = await fetch(`${baseUrl}/v1/skills/search`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": apiKey,
    },
    body: JSON.stringify({
      query,
      limit: 10,
    }),
  });

  if (!response.ok) {
    throw new Error(`Search failed: ${response.status}`);
  }

  return response.json() as Promise<SearchResponse>;
}

async function loadSkill(skillId: string): Promise<LoadResponse> {
  const response = await fetch(`${baseUrl}/v1/skills/${encodeURIComponent(skillId)}`, {
    headers: {
      "x-api-key": apiKey,
    },
  });

  if (!response.ok) {
    throw new Error(`Load failed: ${response.status}`);
  }

  return response.json() as Promise<LoadResponse>;
}

async function main() {
  const search = await searchSkills("zapier integration");
  const firstSkill = search.results[0];

  if (!firstSkill) {
    throw new Error("No matching skill found.");
  }

  const loaded = await loadSkill(firstSkill.skill_id);
  console.log(loaded.skill.folder_name);
  console.log(loaded.markdown);
}

void main();

Node.js

const baseUrl = process.env.SAPPHIRE_API_BASE_URL;
const apiKey = process.env.SAPPHIRE_API_KEY;

async function searchSkills(query) {
  const response = await fetch(`${baseUrl}/v1/skills/search`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-api-key": apiKey,
    },
    body: JSON.stringify({
      query,
      limit: 10,
    }),
  });

  if (!response.ok) {
    throw new Error(`Search failed: ${response.status}`);
  }

  return response.json();
}

async function loadSkill(skillId) {
  const response = await fetch(`${baseUrl}/v1/skills/${encodeURIComponent(skillId)}`, {
    headers: {
      "x-api-key": apiKey,
    },
  });

  if (!response.ok) {
    throw new Error(`Load failed: ${response.status}`);
  }

  return response.json();
}

async function main() {
  const search = await searchSkills("quickbooks");
  const firstSkill = search.results[0];

  if (!firstSkill) {
    throw new Error("No matching skill found.");
  }

  const loaded = await loadSkill(firstSkill.skill_id);
  console.log(loaded.markdown);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Go

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
)

type SkillSummary struct {
	SkillID      string  `json:"skill_id"`
	FolderName   string  `json:"folder_name"`
	SkillName    string  `json:"skill_name"`
	RelativePath string  `json:"relative_path"`
	MarkdownPath string  `json:"markdown_path"`
	SizeBytes    int     `json:"size_bytes"`
	IsNested     bool    `json:"is_nested"`
	Category     *string `json:"category"`
}

type SearchResponse struct {
	Query       string         `json:"query"`
	Category    *string        `json:"category"`
	TotalSkills int            `json:"total_skills"`
	Returned    int            `json:"returned"`
	Limit       int            `json:"limit"`
	Results     []SkillSummary `json:"results"`
}

type LoadResponse struct {
	Skill    SkillSummary `json:"skill"`
	Markdown string       `json:"markdown"`
}

func main() {
	baseURL := os.Getenv("SAPPHIRE_API_BASE_URL")
	apiKey := os.Getenv("SAPPHIRE_API_KEY")

	searchPayload := map[string]any{
		"query": "zapier integration",
		"limit": 10,
	}

	body, err := json.Marshal(searchPayload)
	if err != nil {
		panic(err)
	}

	searchRequest, err := http.NewRequest(http.MethodPost, baseURL+"/v1/skills/search", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}

	searchRequest.Header.Set("Content-Type", "application/json")
	searchRequest.Header.Set("x-api-key", apiKey)

	searchResponse, err := http.DefaultClient.Do(searchRequest)
	if err != nil {
		panic(err)
	}
	defer searchResponse.Body.Close()

	if searchResponse.StatusCode != http.StatusOK {
		responseBody, _ := io.ReadAll(searchResponse.Body)
		panic(fmt.Sprintf("search failed: %s", string(responseBody)))
	}

	var search SearchResponse
	if err := json.NewDecoder(searchResponse.Body).Decode(&search); err != nil {
		panic(err)
	}

	if len(search.Results) == 0 {
		panic("no matching skill found")
	}

	skillID := url.PathEscape(search.Results[0].SkillID)
	loadRequest, err := http.NewRequest(http.MethodGet, baseURL+"/v1/skills/"+skillID, nil)
	if err != nil {
		panic(err)
	}

	loadRequest.Header.Set("x-api-key", apiKey)

	loadResponse, err := http.DefaultClient.Do(loadRequest)
	if err != nil {
		panic(err)
	}
	defer loadResponse.Body.Close()

	if loadResponse.StatusCode != http.StatusOK {
		responseBody, _ := io.ReadAll(loadResponse.Body)
		panic(fmt.Sprintf("load failed: %s", string(responseBody)))
	}

	var loaded LoadResponse
	if err := json.NewDecoder(loadResponse.Body).Decode(&loaded); err != nil {
		panic(err)
	}

	fmt.Println(loaded.Skill.FolderName)
	fmt.Println(loaded.Markdown)
}

Rust

use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use std::env;

#[derive(Debug, Deserialize)]
struct SkillSummary {
    skill_id: String,
    folder_name: String,
    skill_name: String,
    relative_path: String,
    markdown_path: String,
    size_bytes: u64,
    is_nested: bool,
    category: Option<String>,
}

#[derive(Debug, Deserialize)]
struct SearchResponse {
    query: String,
    category: Option<String>,
    total_skills: u64,
    returned: u64,
    limit: u64,
    results: Vec<SkillSummary>,
}

#[derive(Debug, Deserialize)]
struct LoadResponse {
    skill: SkillSummary,
    markdown: String,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let base_url = env::var("SAPPHIRE_API_BASE_URL")?;
    let api_key = env::var("SAPPHIRE_API_KEY")?;
    let client = Client::new();

    let search: SearchResponse = client
        .post(format!("{}/v1/skills/search", base_url))
        .header("x-api-key", &api_key)
        .json(&json!({
            "query": "sap integration",
            "limit": 10
        }))
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    let first_skill = search
        .results
        .first()
        .ok_or("no matching skill found")?;

    let loaded: LoadResponse = client
        .get(format!("{}/v1/skills/{}", base_url, urlencoding::encode(&first_skill.skill_id)))
        .header("x-api-key", &api_key)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    println!("{}", loaded.skill.folder_name);
    println!("{}", loaded.markdown);

    Ok(())
}