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.mdfor 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
- Create an API key.
- Call
POST /v1/skills/search. - Pick a
skill_idfrom the results. - Call
GET /v1/skills/{skillId}. - Save the returned
markdownor install it into your agent's skill directory.
API Surface
Get Stats
Returns the current index metadata.
GET /v1/skills/stats
Response fields:
schema_versiontotal_skillsgenerated_atcategories
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:
limitcursorcategory
Each item includes:
skill_idfolder_nameskill_namerelative_pathmarkdown_pathsize_bytesis_nestedcategory
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:
querycategorytotal_skillsreturnedlimitresults
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:
skillmarkdownfiles
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
Search
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"
Recommended Client Behavior
- 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
markdownas the source input for installation - handle
401,404, and500explicitly
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(()) }