---
summary: How the publish path, the bind path, the per-turn resolve, and the progressive-disclosure
  MCP surface fit together.
title: Architecture
path: concepts/architecture
status: published
---

# 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

```mermaid
flowchart LR
    P[Publisher<br/>tar.gz bundle]
    A[Admin<br/>binds and grants]
    R[Runtime<br/>ScaiWave, ScaiCore<br/>per-turn or boot]
    subgraph SG[ScaiGrid /v1/modules/scaiskills/]
        V[validator vendored]
        S[storage<br/>content-addr S3]
        VR[mod_scaiskills_versions]
        MI[matrix_index<br/>best-effort]
        BS[binding_service<br/>- resolve version<br/>- walk requires.skills<br/>- lockfile JSON<br/>- pending_grants flag]
        RS[resolver_service<br/>- Redis cache 60s<br/>- scope precedence<br/>- lightweight manifests]
        MS[mcp_service<br/>- list resolver<br/>- search ScaiMatrix + fallback<br/>- view S3 + tar extract]
        V --> S
        S --> VR
        VR --> MI
    end
    P -- POST /skills/.. --> V
    A -- POST /bindings --> BS
    R -- POST /resolve --> RS
    R -- POST /mcp/skills/list,search,view --> MS
```

## The three paths

ScaiSkills has three distinct request flows. They share state through the same tables but the code paths barely overlap.

### Publish

1. `POST /skills` writes a `mod_scaiskills_skills` row — slug, owner workspace, visibility, description.
2. `POST /skills/{slug}/versions` accepts a multipart upload of a `.tar.gz` bundle.
3. The vendored validator under `modules/scaiskills/_validator/` opens the tar, parses `SKILL.md`'s YAML frontmatter, validates it against the manifest JSON schema, and runs eight stages (slug match, semver monotonicity, references safety, secrets schema, etc.).
4. Storage computes the content hash. If a bundle with the same hash already exists, the put is skipped and the existing URI is reused.
5. The version row goes into `mod_scaiskills_versions` with the parsed manifest stored as JSON text.
6. Best-effort: `matrix_index.index_skill_version` writes a text entry into the ScaiMatrix `__scaiskills` collection so `skills.search` can do semantic recall. Failure here logs and continues — search has a substring fallback.

### Bind

1. `POST /bindings` receives a `skill_id` and a version spec (`0.1.0`, `latest`, `^0.1`, etc.).
2. The version spec resolves against `mod_scaiskills_versions`, filtered to `status = published`. Yanked versions trigger an explicit error.
3. The resolver walks `manifest.requires.skills` depth-first, resolving each dependency reference the same way, detecting cycles via a visiting/visited set.
4. The full dependency tree is serialised into `resolved_deps_json` on the binding row — this is the lockfile. It is never recomputed for the binding's lifetime.
5. The manifest's declared `permissions` and `secrets` are checked. Anything declared-but-not-granted (for permissions) or required-but-not-mapped (for secrets) puts the binding in `pending_grants = true`.
6. The binding row lands in `mod_scaiskills_bindings`. The Redis cache for the scope is invalidated.

### Resolve

1. `POST /resolve` is called by the runtime — per turn for ScaiWave with `scope_type = channel | user`, once at boot for ScaiCore with `scope_type = core`. The request can also carry ancillary workspace, channel, user, and core ids to be merged in.
2. The resolver checks Redis at `scaiskills:resolve:{scope_type}:{scope_id}` and returns the cached list (60 s TTL) on hit.
3. On miss, all active, non-`pending_grants` bindings matching any of the requested scopes are loaded in one query.
4. Precedence collapses duplicates per `skill_id`: `core > user > channel > workspace`. Each skill_id is represented at most once in the response.
5. The winning bindings' manifests are loaded and projected into the lightweight shape: `slug`, `version`, `description`, `triggers`. Full bodies are not returned.
6. The list is written back to Redis and the cache key is recorded in `mod_scaiskills_cache_keys` so 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:

- `list` returns exactly what `resolve` returns — the lightweight manifest set. The LLM scans descriptions and triggers.
- `search` runs 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.
- `view` is the only path that opens the tarball. It fetches the bundle from S3 by content hash, extracts `SKILL.md` (or `references/<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.
