Architecture
ScaiSkills is a ScaiGrid module — it runs in the same FastAPI process as the rest of the gateway, behind the same auth, accounted against the same workspace. There is no separate skill engine, no separate database. The whole module is a thin orchestration layer over six tables, an S3 bucket, and a Redis cache.
Components#
The three paths#
ScaiSkills has three distinct request flows. They share state through the same tables but the code paths barely overlap.
Publish#
POST /skillswrites amod_scaiskills_skillsrow — slug, owner workspace, visibility, description.POST /skills/{slug}/versionsaccepts a multipart upload of a.tar.gzbundle.- The vendored validator under
modules/scaiskills/_validator/opens the tar, parsesSKILL.md's YAML frontmatter, validates it against the manifest JSON schema, and runs eight stages (slug match, semver monotonicity, references safety, secrets schema, etc.). - Storage computes the content hash. If a bundle with the same hash already exists, the put is skipped and the existing URI is reused.
- The version row goes into
mod_scaiskills_versionswith the parsed manifest stored as JSON text. - Best-effort:
matrix_index.index_skill_versionwrites a text entry into the ScaiMatrix__scaiskillscollection soskills.searchcan do semantic recall. Failure here logs and continues — search has a substring fallback.
Bind#
POST /bindingsreceives askill_idand a version spec (0.1.0,latest,^0.1, etc.).- The version spec resolves against
mod_scaiskills_versions, filtered tostatus = published. Yanked versions trigger an explicit error. - The resolver walks
manifest.requires.skillsdepth-first, resolving each dependency reference the same way, detecting cycles via a visiting/visited set. - The full dependency tree is serialised into
resolved_deps_jsonon the binding row — this is the lockfile. It is never recomputed for the binding's lifetime. - The manifest's declared
permissionsandsecretsare checked. Anything declared-but-not-granted (for permissions) or required-but-not-mapped (for secrets) puts the binding inpending_grants = true. - The binding row lands in
mod_scaiskills_bindings. The Redis cache for the scope is invalidated.
Resolve#
POST /resolveis called by the runtime — per turn for ScaiWave withscope_type = channel | user, once at boot for ScaiCore withscope_type = core. The request can also carry ancillary workspace, channel, user, and core ids to be merged in.- The resolver checks Redis at
scaiskills:resolve:{scope_type}:{scope_id}and returns the cached list (60 s TTL) on hit. - On miss, all active, non-
pending_grantsbindings matching any of the requested scopes are loaded in one query. - Precedence collapses duplicates per
skill_id:core > user > channel > workspace. Each skill_id is represented at most once in the response. - The winning bindings' manifests are loaded and projected into the lightweight shape:
slug,version,description,triggers. Full bodies are not returned. - The list is written back to Redis and the cache key is recorded in
mod_scaiskills_cache_keysso the binding write paths can invalidate it cheaply on the next change.
The MCP surface#
/mcp/skills/list, /mcp/skills/search, and /mcp/skills/view are the progressive-disclosure tools the LLM calls during a turn. They live behind the same auth as everything else, but their semantics are designed for tool-use:
listreturns exactly whatresolvereturns — the lightweight manifest set. The LLM scans descriptions and triggers.searchruns the user's query through ScaiMatrix semantic search over the indexed manifests, intersects against the bound set so out-of-scope skills don't leak, and falls back to a substring filter if ScaiMatrix is unavailable.viewis the only path that opens the tarball. It fetches the bundle from S3 by content hash, extractsSKILL.md(orreferences/<path>for additional files), strips the YAML frontmatter, and returns the markdown body. The LLM only pays the token cost for skills it actually decides to read.
Every MCP call logs a mod_scaiskills_invocations row tagged with the workspace, scope, operation, and bytes returned — the metering hook for downstream billing.
State#
- Skill identities, versions, bindings, grants, invocations, cache keys — six tables under
mod_scaiskills_*in ScaiGrid's MariaDB. - Bundles — content-addressed in S3 under
scaigrid-skills/{skill_id}/{content_hash}.tar.gz. Byte-identical bundles dedup. - Parsed manifests — JSON-serialised on the version row so resolve and MCP never re-open the tarball.
- Search index — a single ScaiMatrix collection (default
__scaiskills) tenant-isolated by workspace id. Best-effort write at publish time. - Resolution cache — Redis, 60 s TTL per scope key, invalidated on every binding write.
There is no other state. Lose Redis and the next resolve repopulates from MariaDB. Lose the ScaiMatrix index and skills.search falls back to substring filtering on the resolved set.
Where the trust boundary is#
The publish path trusts the bundle's manifest only after the validator finishes. The bind path trusts the lockfile only after cycle detection completes. The resolve path trusts nothing transient — every call re-checks enabled and pending_grants against the live binding row (or against a cache entry that was invalidated the last time those flags changed). The MCP path enforces scope gating on every call: skills.view of a slug that isn't in the caller's resolved set returns 404, never the bundle.
What the runtime sees vs. what's stored#
There's a deliberate asymmetry between what POST /resolve returns and what the binding row actually carries. Resolve returns only slug, version, description, and triggers — enough for the LLM to recognise that a skill is in scope, not enough to act on it. The full SKILL.md body and the references/ directory stay in S3 until the LLM explicitly calls skills.view. That's the progressive-disclosure contract: the per-turn cost of having ten skills bound is linear in the number of slugs (a sentence each), not in the sum of bundle sizes.
The bind path is the opposite. The lockfile, the secret mappings, the grant ledger — all of it lives on the binding row and is computed once. The runtime never re-walks requires.skills, never re-validates the manifest, never re-checks grants against a moving target. Anything that could change semantics over a binding's lifetime is frozen at bind time. The only state the resolve path is allowed to read live is the binding's own enabled and pending_grants flags.
How it differs from a static system prompt#
A static system prompt baked into a bot config gives you predictability — every turn sees the same string. ScaiSkills adds:
| Concern | Static prompt | ScaiSkills |
|---|---|---|
| Reuse across bots | Copy-paste | Bind once per scope |
| Versioning | None | Semver + monotonicity + yank |
| Per-turn cost | All instructions every turn | Slug + description; body on demand |
| Permission gating | Manual policy | Declared, granted, audited |
| Search by intent | Not applicable | skills.search with ScaiMatrix |
| Dependency reuse | Copy-paste | requires.skills with lockfile |
If you only need one prompt for one bot, ScaiBot's tone config is the right tool. If you need any of the rows above, ScaiSkills is.