A local, read-only MCP stdio server that gives agents on-demand skill discovery: route a natural-language task description to the right skill in your vault and deliver its SKILL.md byte-for-byte, verified by SHA-256.
Built for agents that lack native skill triggering (Goose recipe workers, opencode, and friends). Agents that already trigger skills natively (e.g. Claude Code) don't need it.
- How it works
- Tiers: routed vs. pinned
- Install
- Quick start — the fastest path to seeing it respond
- Pinning skills across surfaces — optional: statically load a curated set across multiple agents
- Docker Usage
- Configuration — inference modes, security scanning, installing skills, env vars
- CLI & Automation — context management, remote target resolution, policy calibration, JSON envelopes
- Benchmarks & Evaluation
- FAQ & Troubleshooting
- Guarantees
- Development
resolve_skill("convert this spreadsheet to markdown")
│
▼
hybrid recall: SQLite FTS5 (BM25) ∪ embedding cosine (brute-force)
│
▼
reciprocal-rank fusion → shortlist
optional reranker → matched | ambiguous | no_match
- matched — one skill clearly wins: full
SKILL.mddelivered inline,sha256(body) == content_sha256 ==hash of the file on disk at delivery time. Stale index? It re-indexes and delivers fresh bytes — never stale ones. - ambiguous — up to 10 candidates (id, title, description). The calling LLM picks and calls
fetch_skill. - no_match — proceed under your normal workflow; don't load an unrelated skill.
If embeddings are unavailable, the router remains ready with FTS5 lexical retrieval. If an optional reranker is unavailable, it preserves the hybrid shortlist instead of failing.
| Tool | Input | Returns |
|---|---|---|
resolve_skill |
query |
outcome + metadata in structuredContent; on match the verbatim body as text content (exactly once on the wire) |
fetch_skill |
skill_id |
verbatim body, content_sha256, supporting-file paths |
The full contract lives in docs/schema.json (JSON Schema 2020-12, language-neutral).
Skills live in one vault, but there are two ways an agent gets one:
- routed — the default, described above. Nothing is loaded up front; the agent calls
resolve_skillon demand and gets back exactly the skill that matches. This scales to hundreds of skills at zero standing cost. - pinned (
core/project) — a small, hand-picked set of skills symlinked directly into an agent's own skill directory (e.g.~/.claude/skills), so they load the same way any other skill on that agent does — no MCP round-trip, no query.corepins apply everywhere;projectpins apply only inside one repo.
Pinning is optional and orthogonal to serving: skillmux init/sync manage what's pinned; skillmux serve is what answers resolve_skill for everything else. Most single-agent setups never need pinning — reach for it once you're running the same small set of skills across multiple agent surfaces (Claude Code, opencode, ...) and don't want to maintain that list by hand in each one. See Pinning skills across surfaces.
Download the latest release for your architecture:
# AMD64
gh release download --repo klhq/skillmux \
--pattern 'skillmux-linux-*'
# Optional: verify build provenance
gh attestation verify skillmux-linux-amd64 --repo klhq/skillmux
# Install the binary matching your machine (amd64 or arm64)
chmod +x skillmux-linux-amd64
sudo install skillmux-linux-amd64 /usr/local/bin/skillmux
skillmux config showRelease assets are also available at https://github.com/klhq/skillmux/releases/latest.
Requirements at runtime:
- A skill vault: one directory per skill with a
SKILL.mdin agentskills.io format. Default:~/skills. - Optional remote OpenAI-compatible embeddings and Infinity-native reranking. The full binary uses local GTE-small embeddings by default.
No config is required — the vault defaults to ~/skills, and the full binary embeds its own local model, so there's nothing to download or provision first.
A skill is just a directory with a SKILL.md. Author one by hand to try against:
mkdir -p ~/skills/csv-formatter
cat > ~/skills/csv-formatter/SKILL.md <<'EOF'
---
name: CSV Formatter
description: Converts CSV or spreadsheet data into clean, aligned Markdown tables. Use whenever the user asks to convert, format, or clean up tabular/CSV/spreadsheet data into Markdown.
---
# CSV Formatter
Given raw CSV input, emit a well-aligned Markdown table: infer column headers
from the first row, right-align numeric columns, left-align text columns.
EOF(Or fetch an existing skill from a git repo instead — see Installing skills below.)
skillmux index
skillmux doctordoctor should report routing capability: hybrid and every check ok. If something's fail, the detail column names the exact path or setting to fix.
skillmux serveRegister with your MCP client directly, e.g.:
{
"mcpServers": {
"skillmux": {
"command": "skillmux",
"args": ["serve"]
}
}
}To see resolve_skill respond without wiring up a client, run the HTTP transport instead (skillmux serve --transport http, default port 3000) and speak MCP's Streamable HTTP protocol directly:
# 1. Initialize a session, capture the session id from the response header
SESSION=$(curl -sS -D - -o /dev/null http://127.0.0.1:3000/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"try-it","version":"1.0.0"}}}' \
| grep -i '^mcp-session-id:' | tr -d '\r' | cut -d' ' -f2)
# 2. Complete the handshake
curl -sS -o /dev/null -X POST http://127.0.0.1:3000/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. Call resolve_skill
curl -sS -X POST http://127.0.0.1:3000/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"resolve_skill","arguments":{"query":"convert this spreadsheet to markdown"}}}'Against the csv-formatter skill authored above, that returns a real match — trimmed here for length:
{"result":{"structuredContent":{"outcome":"ambiguous","retrieval":"hybrid","candidates":[
{"skill_id":"csv-formatter","title":"CSV Formatter","description":"Converts CSV or spreadsheet data..."}
]}}}outcome is "ambiguous" here specifically because the vault only has one skill in it — with more skills installed, a clear top match returns "matched" with the full SKILL.md body inline instead of a candidate list.
bun install --frozen-lockfile
bun run src/cli.ts index
bun run src/cli.ts serveOptional — skip this if resolve_skill alone is enough (most setups). Use it once you want a small set of skills loaded statically in every agent that reads from a given directory, instead of routed on demand — see Tiers.
skillmux initLists candidate surfaces (default: ~/.claude/skills, ~/.agents/skills) with their dir/symlink status and skill count. Nothing is written until you pass --target:
skillmux init --target claude --yesThis writes skillmux.toml at the vault root and marks ~/.claude/skills as skillmux-owned — a .skillmux marker file that sync requires before it will touch the directory.
init always starts [core] empty — there's no heuristic yet for which skills belong there. Pin with the CLI instead of hand-editing skillmux.toml:
skillmux manifest pin csv-formatter --coreUnpin the same way: skillmux manifest unpin csv-formatter --core. --project <group> pins into a [project.<group>] tier instead (add --repo <path> the first time, to create the group). Every pin is validated before writing — the skill must actually resolve from the vault, and [core] stays under its 25-skill cap.
Hand-editing skillmux.toml still works if you prefer it:
[core]
skills = ["csv-formatter"]
[targets.claude]
dir = "/Users/you/.claude/skills"
project_groups = []Full manifest schema, including [project.<group>] pins scoped to one repo and machine-local overlay vaults via local_vault_paths, is in docs/configuration.md.
skillmux sync
# claude: +1 -0Each pinned skill becomes a symlink from the target dir into the vault. Re-running sync is idempotent (+0 -0 once nothing changed); removing a skill from [core] removes its symlink on the next sync.
skillmux sync --dry-run # preview +added/-removed without touching disk
skillmux sync --install-hook # add a git post-merge hook in the vault that runs `skillmux sync` automatically
skillmux sync --restore-monolith # undo: replace a target dir with one symlink straight to the vault--restore-monolith drops the .skillmux marker along with the per-skill symlinks — re-adopt with skillmux init --target <name> --yes before that target can be sync'd again.
skillmux report reads the same audit log resolve_skill writes to (see Guarantees) — useful for deciding what belongs in [core] versus staying routed:
skillmux report --since 7d # local: reads state_dir's audit db
skillmux report --server http://host:3000 --since 7d # remote: hits a running server's /statswindow: 2026-07-14T00:00:00Z .. 2026-07-21T00:00:00Z
outcomes: matched=0 ambiguous=2 no_match=0 (ambiguous_rate=1.000)
skills:
csv-formatter matched=0 candidate=2
pdf-extractor matched=0 candidate=2
top no_match queries:
(none)
--since accepts a relative window (1h, 7d, 1m) or an absolute date/timestamp. A skill matched often but never pinned is a [core] candidate; a query that keeps showing up in top_no_match_queries means the vault is missing something.
The skillmux is packaged and distributed as a Docker image in two variants:
skillmux:latest: Bundles the small quantized GTE embedding model for local hybrid retrieval.skillmux:latest-slim: Excludes model weights and supports configured remote embeddings or lexical fallback.
Both tags are multi-architecture manifests for Linux AMD64 and ARM64; Docker selects the correct image automatically. Images are published to both ghcr.io/klhq/skillmux and docker.io/lazyskyline/skillmux — either registry works, examples below use GHCR.
To run as an HTTP MCP service (default in Docker):
# Battery-included (runs local in-process ONNX models)
docker run -d \
--name skillmux \
-v ~/skills:/vault:ro \
-v skillmux-data:/data \
-p 3000:3000 \
ghcr.io/klhq/skillmux:latest
# Slim (configured remote embeddings, or lexical fallback)
docker run -d \
--name skillmux-slim \
-v ~/skills:/vault:ro \
-v skillmux-data:/data \
-p 3000:3000 \
-e EMBED_BASE_URL="http://embeddings-host:8080" \
ghcr.io/klhq/skillmux:latest-slimConnect your MCP client to the HTTP endpoint (e.g. standard Streamable HTTP transport):
- POST messages to
http://localhost:3000/mcp
All of the below is [server] config in config.toml, overridable by environment variable — see Environment Variable Overrides.
- Bind address (
hostname, default127.0.0.1) — HTTP transport binds loopback-only by default, so a zero-configskillmux serve --transport httpisn't reachable from the network. Inside Docker (RUNNING_IN_DOCKER=true) this defaults to0.0.0.0instead, since the container's own loopback isn't reachable through port-mapping. Sethostname(orHTTP_HOSTNAME) explicitly to expose the server beyond localhost. - Bearer token auth (off by default) — set
auth_enabled = trueand the token via the env var named byauth_token_env(defaultSKILLMUX_AUTH_TOKEN). Requests needAuthorization: Bearer <token>; missing/mismatched tokens get401, and a configured-but-empty token env var gets500. - CORS —
allowed_origins(default[], deny-by-default) is checked against the request'sOriginheader; disallowed origins get403. Requests with noOriginheader (curl, MCP clients, server-to-server) are unaffected either way — only browser-issued cross-origin requests are gated./healthand/metricsare excluded from auth but still CORS-checked. - Rate limiting (off by default) — per-token (when auth is enabled) or per-IP (
server.requestIP) token-bucket limiting. Enable withrate_limit.enabled = trueand setrate_limit.requests_per_minute(default60). TheX-Forwarded-Forheader is ignored unlessrate_limit.trust_proxy = true— it's client-supplied and spoofable, so only opt in when a trusted reverse proxy sets it. Every response carriesX-RateLimit-Limit/X-RateLimit-Remaining/X-RateLimit-Reset; over-limit requests get429plusRetry-After. GET /health/live— lightweight liveness check. LegacyGET /healthremains an alias.GET /health/ready— readiness with active retrieval capability, skill count, index state, and inference status.GET /metrics— Prometheus text exposition:skill_router_requests_total,skill_router_resolve_outcomes_total,skill_router_resolve_latency_seconds(histogram),skill_router_errors_total,skill_router_rate_limits_exceeded_total.
If your agent runs locally and expects a piped stdio process:
docker run -i --rm \
-v ~/skills:/vault:ro \
ghcr.io/klhq/skillmux:latest serve --transport stdioNo config is required for the battery-included local ONNX mode. See config.example.toml for the minimal local setup, config.remote.example.toml for bring-your-own endpoints, and docs/configuration.md for advanced settings.
- The zero-config default combines SQLite FTS5 with the small
Xenova/gte-smallembedding model and returns an ordered shortlist. - Configured OpenAI-compatible embeddings replace the local embedder. An optional Infinity-compatible reranker enables confident automatic matches.
Run skillmux doctor to verify routing capability. Run skillmux config show to inspect effective configuration; it prints credential variable names, never values.
skillmux scan [<path>] inspects skill content for prompt-injection and data-exfiltration risk indicators
before it's served over MCP or HTTP. It's offline (no network call, no inference config needed),
read-only, and advisory-only — it never blocks skillmux index/sync/init, which don't call it
automatically.
skillmux scan # scan the configured vault_path
skillmux scan ~/skills/some-skill # scan a single candidate skill dir before adding it
skillmux scan --format json # machine-readable { scanned, findings } for CI
skillmux scan --fail-on high # exit 1 if any finding is high severity (for CI gating)The v1 rule set covers four categories, each attached to the finding as rule_id with a fixed
severity:
rule_id |
severity |
Flags |
|---|---|---|
prompt-injection-phrase |
high |
Known instruction-override phrases (e.g. "ignore previous instructions") |
invisible-unicode |
high |
Zero-width/invisible Unicode code points, including hidden tag-character payloads |
secret-pattern |
high |
Hardcoded-credential-shaped strings (AWS-style keys, PEM blocks, api_key=/token= assignments) |
suspicious-url |
medium |
Bare-IP-address URLs, or URLs paired with exfiltration-suggesting text |
skillmux scan is unrelated to the audit SQLite table / skillmux report — that's query telemetry (what got
routed where); skillmux scan is content security (what's in the vault).
skillmux install <repo>[/path] fetches one skill from a git repo (GitHub shorthand, full HTTPS/SSH URL,
or file://) into the configured vault_path, so onboarding doesn't require a second CLI just to
pull a skill in. It's a convenience fetch, not a distribution system:
skillmux install owner/repo # repo root must itself be a skill
skillmux install owner/repo/path/to/skill # select one skill out of a multi-skill repo
skillmux install owner/repo --dry-run # preview id, target path, and scan findings
skillmux install owner/repo --force # overwrite an existing skill_id
skillmux install owner/repo --fail-on high # abort the install if scan findings meet the thresholdThe fetched skill is validated the same way skillmux scan validates the vault — malformed SKILL.md
aborts the install, and scan findings are printed before anything is written (advisory by default;
--fail-on opts into blocking, matching skillmux scan's severity levels). Materialization is a plain
file copy, not a symlink — the temporary clone is deleted once the install completes.
skillmux install intentionally does not handle updates, uninstalls, version pinning, or
core/project/routed tier assignment (that's skillmux sync's domain) — it only ever fetches one skill,
once. Use skillmux sync afterward if the installed skill needs to be pinned into a tier.
All core settings can be overridden via environment variables (handy for Docker):
VAULT_PATH— overridesvault_path(defaults to/vaultinside Docker)STATE_DIR— overridesstate_dir(defaults to/datainside Docker)EMBED_BASE_URL/SKILLMUX_EMBED_BASE_URL— overrides remoteinference.embedding.base_urlEMBED_MODEL/SKILLMUX_EMBED_MODEL— overridesembedding.modelEMBED_DIMENSION/SKILLMUX_EMBED_DIMENSION— overridesembedding.dimensionEMBED_DEVICE/EMBED_DTYPE— overrides localinference.embedding.device/inference.embedding.dtypeRERANK_BASE_URL/SKILLMUX_RERANK_BASE_URL— overrides remoteinference.reranker.base_urlRERANK_MODEL/SKILLMUX_RERANK_MODEL— overridesrerank.modelSKILLMUX_CONFIG— path to customconfig.toml(default~/.config/skillmux/config.toml)SKILLMUX_MODELS_DIR— path to directory storing downloaded local models (default~/.cache/skillmux/models,/modelsinside Docker)PORT— HTTP listen port (default3000, HTTP transport only)HTTP_HOSTNAME— overridesserver.hostname(default127.0.0.1,0.0.0.0inside Docker)HTTP_AUTH_ENABLED— overridesserver.auth_enabled("true"to enable)HTTP_AUTH_TOKEN_ENV— overridesserver.auth_token_envHTTP_ALLOWED_ORIGINS— comma-separated list, overridesserver.allowed_originsHTTP_RATE_LIMIT_ENABLED/SKILLMUX_HTTP_RATE_LIMIT_ENABLED— overridesserver.rate_limit.enabled("true"to enable)HTTP_RATE_LIMIT_RPM/SKILLMUX_HTTP_RATE_LIMIT_RPM— overridesserver.rate_limit.requests_per_minuteHTTP_RATE_LIMIT_TRUST_PROXY/SKILLMUX_HTTP_RATE_LIMIT_TRUST_PROXY— overridesserver.rate_limit.trust_proxy("true"to trustX-Forwarded-For)
Remote API keys are read from the environment variables named by inference.embedding.api_key_env and inference.reranker.api_key_env; no secret ever lives in the config file.
Skillmux includes a built-in evaluation framework to benchmark retrieval accuracy (lexical vs. hybrid vector search) against labeled intent datasets.
Evaluate lexical and local hybrid retrieval against the checked-in labeled queries:
bun run src/cli.ts eval
# holdout queries: 8
# lexical recall@5: 1.000
# hybrid recall@5: 1.000Custom policy calibration can also be performed against domain-specific query logs using skillmux calibrate (see docs/cli.md).
Why did my query return ambiguous instead of matched?
The router returns "outcome": "ambiguous" when multiple candidate skills meet retrieval confidence thresholds, or when no single candidate dominates by a sufficient score margin. In this state, up to 10 candidate skill summaries (skill_id, title, description) are returned so the calling LLM can choose the exact skill and invoke fetch_skill.
Does skillmux require an active internet connection?
No. In default local inference mode (inference.mode = "local"), skillmux operates 100% offline. The default GTE-small embedding model is quantized to q8 and bundled within the binary/Docker image.
What happens when remote embedding or reranking endpoints fail?
Skillmux features automatic fallback degradation. If remote embedding APIs or rerankers become unreachable, the router gracefully falls back to SQLite FTS5 lexical search rather than throwing an error to the calling agent.
When should I use routed skills vs. pinned skills?
- Routed (Default): Best for expanding vaults with dozens or hundreds of skills. Skills are loaded dynamically on demand via
resolve_skill, keeping the agent's initial context window clean. - Pinned (
skillmux sync): Best when running multiple agent surfaces (e.g. Claude Code, opencode) that all require the same core set of 2–5 skills loaded statically at agent startup.
How do I verify server readiness and routing health?
Run skillmux doctor locally or hit the HTTP readiness endpoint (GET /health/ready). doctor checks vault accessibility, state directory permissions, ONNX runtime binding status, and active retrieval lane status.
- Read-only vault — no code path writes under the vault; all state is confined to
state_dir. Covered by tests. - Zero-loss delivery — delivered bytes always hash-match the file on disk at delivery time.
- Live index — a running server folds vault changes (create/modify/delete) into the index within seconds; an unparseable write keeps the previous good entry rather than evicting the skill.
- Audit log — every
resolve_skillcall is appended to a SQLite table instate_dir(timestamp, query, outcome, candidates with scores, latency).
bun test # full suite (contract, hybrid recall, stdio e2e, watcher, eval)
bun run build # single-file binary via bun build --compileReference: docs/configuration.md and docs/schema.json.
