# M3.5.5 — GET /skills and /skills/{name}: loadable skills catalog | Field | Value | |---|---| | Phase | M3.5 — Distributed API Layer | | Size | M — 1–3 days | | Status | ✅ Done | | Flags | — | | Spec | inlined below | | Blocks | M3.5.8 | | Depends | M3.5.1, M4.1 (skill drafts exist locally) | ## Goal Read-only endpoints for skill catalog. List all promoted skills (exclude `_drafts/`), fetch individual skill metadata and body. Skills are Obsidian notes; expose them over HTTP for agent discovery. ## Design **List all loadable skills:** ``` GET /memory/skills?loadable=true → 200 { "skills": [ { "name": "infra-root-causes", "description": "Identify root causes of infrastructure failures", "when_to_use": "When troubleshooting cluster or service outages", "argument_hint": "--project ", "promoted_at": "2026-08-20T10:30:00Z", "generated_from": null }, ... ] } ``` **Get one skill (metadata only):** ``` GET /memory/skills/infra-root-causes → 200 { "name": "infra-root-causes", "description": "...", "when_to_use": "...", "argument_hint": "...", "promoted_at": "2026-08-20T...", "generated_from": null } ``` **Get skill with body (full content):** ``` GET /memory/skills/infra-root-causes?include_body=true → 200 { "name": "infra-root-causes", "description": "...", "body": "# Infra root causes\n\n..." } ``` **Filters:** - `loadable=true` (default): exclude `_drafts/`, return only promoted skills - `loadable=false`: include everything (admin only — must have special apikey, documented in code) ## Technical **Source:** Vault at `vault/skills/` contains skill markdown files. Each skill is a directory: ``` vault/skills/ infra-root-causes/ SKILL.md <- frontmatter + body ``` **Frontmatter (YAML in SKILL.md):** ```yaml --- name: infra-root-causes description: Identify root causes of infrastructure failures when_to_use: When troubleshooting cluster or service outages argument_hint: --project generated_from: null | --- ``` **Drafts are in `vault/skills/_drafts/`:** ``` vault/skills/ _drafts/ new-skill/ SKILL.md ``` Only load from `vault/skills/*/SKILL.md` (not `_drafts`), unless `loadable=false` is passed with an admin key. ## Steps 1. `GET /memory/skills` handler: - List `vault/skills/` directory (skip `_drafts/`) - For each `*/SKILL.md`, parse frontmatter - Extract: `name`, `description`, `when_to_use`, `argument_hint`, `promoted_at` (file mtime) - Parse `generated_from` field to show provenance - Return array 2. `GET /memory/skills/{name}` handler: - Load `vault/skills/{name}/SKILL.md` - Parse frontmatter and body - If `include_body=false` (default), return metadata only - If `include_body=true`, include markdown body 3. `loadable` query param (admin-only feature): - Default: exclude `_drafts/` - `loadable=false` with admin apikey: include `_drafts/` in listing - Non-admin key requesting `loadable=false` → 403 Forbidden 4. Error handling: - Skill not found → 404 with `{"error":"not_found","reason":"skill 'xyz' not promoted"}` - Malformed SKILL.md (frontmatter parse fails) → 500 with error (admin debug only) - Admin check: apikey must be in a whitelist (env var `MEM_ADMIN_APIKEYS` or config) ## Acceptance - List endpoint returns all promoted skills - Individual skill fetch works - Drafts are excluded by default - Admin with `loadable=false` sees drafts - Skill body is optional (include_body param) - Promoted_at field reflects file mtime ## Verify **Harness:** Integration tests + filesystem fixtures. **Setup:** Create test `vault/skills/` with: - `vault/skills/test-skill-1/SKILL.md` (promoted) - `vault/skills/test-skill-2/SKILL.md` (promoted) - `vault/skills/_drafts/draft-skill/SKILL.md` (unpromoted) **Integration test** — `tests/it_skills_endpoint.rs`: 1. `a1_list_skills_returns_promoted` — GET /skills returns array with test-skill-1 and test-skill-2. 2. `a2_drafts_excluded_by_default` — GET /skills does not include draft-skill. 3. `a3_drafts_included_with_admin_key` — GET /skills?loadable=false with admin apikey includes draft-skill. 4. `a4_non_admin_denied_drafts` — GET /skills?loadable=false with regular apikey returns 403. 5. `a5_get_single_skill_metadata` — GET /skills/test-skill-1 returns 200 with frontmatter fields. 6. `a6_include_body_true` — GET /skills/test-skill-1?include_body=true returns body field with markdown. 7. `a7_include_body_false` — GET /skills/test-skill-1?include_body=false (or omitted) does not include body field. 8. `a8_skill_not_found` — GET /skills/nonexistent returns 404. 9. `a9_promoted_at_is_file_mtime` — GET /skills/test-skill-1, assert promoted_at is a valid ISO timestamp close to SKILL.md's modification time. 10. `a10_generated_from_field` — SKILL.md with `generated_from: sha256xyz` is parsed and returned as-is. **Command:** `cargo test -p mem-cli skills_endpoint` **False pass:** - Drafts never created in test fixtures. The default exclude-drafts logic is untestable without a draft. - Admin key never tested. Non-admin path and admin path can be identical in code. - Promoted_at never validated. Can return a fake date; file mtime is the only source. - Frontmatter parsing doesn't validate required fields (name, description). A malformed SKILL.md is silently returned with null values. ## Traps - Vault directory may not exist locally (only in deployed cluster). Start with a default empty list if vault/ is missing. - YAML frontmatter parsing is fussy. A tab instead of spaces breaks YAML. Use a YAML parser (serde_yaml) and validate on load. - File mtime precision: Unix mtime is seconds; SKILL.md edits may not increment it if done within the same second. Use actual write timestamp if available. - Admin key stored in env var. If unset, default to deny (safer than default allow). --- Background: [DESIGN.md § Skills — the procedural projection](../DESIGN.md#skills--the-procedural-projection)