how it works

From source skills to materialized blend.

Spindle is an assembly line. Authored skills become packages; packages ship in distributions; channels select them per surface; resolution merges them into a blend; profiles render it; a lint gates it; and materialization writes it where the agent loads it.

On this page
  1. The vocabulary
  2. The bind pipeline
  3. Rendering & profiles
  4. Doctrine
  5. The safety floor
  6. Determinism & caching

The vocabulary

Seven nouns carry the whole system. They stack from what an author writes to what an agent loads:

skill package distribution channel blend profile surface
skill
A directory with a SKILL.md (YAML frontmatter name/description + a Markdown body). The canonical, authored-once unit. Kept small on purpose.
package
A pip-installable project that ships skills and advertises capabilities, declared with [tool.spindle.package]. Spindle discovers installed packages through that metadata.
distribution
A pip-installable bundle that groups packages (as ordinary dependencies) and carries surface-wide assets — doctrine, channels, profiles — declared with [tool.spindle.distribution].
channel
A versioned (scope × harness) manifest listing which skills a surface should receive at that scope, plus the absolutes in force and optional routing tiers.
surface
A repo or agent that consumes a blend. Carries a name, a harness, an autonomy mode, one or more app-class clusters, and a target model.
profile
A data-driven, versioned renderer. A harness profile sets dialect; a model profile sets density. Each names a transform: identity, terse, trim, or llm.
blend
The resolved skill subset for one surface — the composition — together with the absolutes in force and a record of every override (shadow). The thing that actually lands.
doctrine
The versioned first-principles set (preferences, absolutes, meta-principles) that skills are checked against. One coherent frame per distribution.
Scope A channel or doctrine entry has a scope: system, cluster, or repo. Scope drives both the order in which channels are selected (broad to narrow) and which layer wins when two define the same skill (narrow wins). Guardrails are the exception — they accumulate across all scopes.

The bind pipeline

spindle bind <repo> --harness claude runs six stages. It is a fail-closed pipeline: if rendering drops a guardrail or the lint finds a problem, nothing is materialized (unless you pass --force).

01

Classify

Read the checkout's pyproject.toml/package.json into a signal; derive an app-class cluster key.

appclass.py
02

Select

Subscribe to channels broad → narrow: system, per-cluster, repo — for this harness.

channels.py
03

Resolve

Merge the channel layers into one blend; narrow overrides broad; record shadows; union absolutes.

composition.py
04

Render

Rewrite each skill for the harness dialect, then the model density. Verify guardrails survived.

render.py
05

Lint

Check the blend for coherence — command collisions, duplicates, unsafe irreversibles. Gate.

composition.py
06

Materialize

Symlink the resolved skills into the harness's native skills dir; touch only spindle-owned links.

materialize.py

01 · Classify

Spindle reads the repo's manifests (pyproject.toml, package.json) into a RepoSignal — language, dependencies, notable files — and classifies it into an app-class with a single stable cluster key like lang:python|network-service|uses-llms. The surface's clusters come from that key; a repo that changes shape gets re-clustered on the next bind.

02 · Select

The surface subscribes to an ordered list of channels, least- to most-specific: (system, system), then one (cluster, <key>) per cluster, then (repo, <name>). Each resolves to a file on disk under the active distribution's source_dir:

channels/<scope>/<name>/<harness>/channel.toml

The harness is applied here: a Claude surface only ever reads the claude/ manifests. Missing manifests are simply skipped — a surface legitimately has only a system channel. A channel manifest is small:

# channels/system/system/claude/channel.toml
version = "0.1.0"
absolutes = ["A1", "A2"]        # doctrine ids in force at this scope
skills = ["clarify", "design", "tasks", "review"]

[tiers]                         # optional routing hints (advisory)
clarify = "judgment"
design  = "judgment"
tasks   = "judgment"
review  = "judgment"

Skill names are resolved through an index built from the installed packages. A name with no installed package is dropped — which is exactly why a bind that finds nothing prints resolved 0 skills: the channel named skills that aren't installed.

03 · Resolve

Resolution merges the selected layers into one Composition. Each skill occupies a slot keyed by its command (falling back to its name); a more-specific layer overrides a less-specific one for the same slot, and every override is recorded as a shadow rather than silently dropped. Absolutes are unioned across all layers and never lost to precedence — the guardrail floor only rises. The default precedence is system → cluster → repo.

05 · Lint

With the blend assembled (and already rendered), the linter runs pure coherence checks over its metadata: no two skills may claim the same command, no skill may appear twice, and a genuinely irreversible skill must carry the matching absolute. Problems stop the bind unless forced. The linter inspects structure, not prose — which is why the guardrail check lives in rendering (below), not here.

06 · Materialize

Finally, Spindle symlinks each resolved skill into the harness's native location and reports a per-skill action — linked · updated · kept · removed · skipped. It reads the surface's previous binding to know which links it owns, and removes only those: a file you added by hand is never touched.

Then it records the binding: a stable coordinate over the skills, the doctrine, and the channel versions, appended to a per-surface history so any prior coordinate is a rollback target. spindle unbind reverses it, removing exactly the links Spindle owns.

Rendering & profiles

Selection decides which skills; rendering decides what text each one becomes for this surface. Because rendering produces new text, its output can't be a symlink — it's written to a content-addressed store and the blend is repointed at it. Two profile axes compose, dialect then density:

axis 1 · harness

Dialect

  • identity — verbatim (Claude is the reference dialect).
  • terse — deterministic whitespace collapse; content-preserving by construction.
  • llm — an actual model rewrite into the harness dialect; skipped if no client is configured.
axis 2 · model

Density

  • identity — keep all scaffolding (what weaker models want).
  • trim — strip author-marked scaffold fences, then terse.
  • llm — model-driven densification.

Profiles are tiny, versioned TOML files:

# profiles/claude/profile.toml — harness dialect
version = "1"
transform = "identity"

# profiles/models/frontier/profile.toml — model density
version = "1"
transform = "trim"
tier = "frontier"

The trim transform is the "optimize things out" half of tuning. An author marks a passage that only smaller models need, and a frontier profile removes it — the same canonical skill, rendered denser for a model that reads faster without it:

<!-- scaffold:examples -->
Worked example a smaller model benefits from…
<!-- /scaffold -->

Doctrine

Doctrine is the coherent first-principles frame, kept as a versioned, hand-editable, machine-readable TOML artifact. It has three entry kinds:

# doctrine/doctrine.toml (excerpt)
version = "0.1.0"

[[preference]]
id = "P1"
scope = "system"
favor = "clear acceptance evidence"
over  = "performative process"

[[absolute]]
id = "A2"
scope = "system"
mode = "NEVER"
statement = "fabricate missing facts"

Doctrine matters because it makes "coherence" a defined, versioned, testable property instead of a vibe. Its coordinate<version>+<hash> — is pinned into every render cache key and every binding record, so a rollback can re-fetch the exact doctrine that produced a blend. spindle doctrine validate refuses a malformed doctrine before it can ever render into skills.

The safety floor

Two mechanisms make it structurally hard to compose away safety. First, absolutes accumulate: because resolve unions them across every layer, a narrow channel can add a guardrail but never remove one. Second, rendering verifies preservation:

Every guardrail clause in a skill's source must survive, normalized, in the rendered output — or the bind fails closed.render.verify_preserved

Guardrail clauses are lines matching ALWAYS, NEVER, MUST NOT, or DO NOT. The coherence linter reads metadata and can't catch a paraphrase that quietly drops one — so this deterministic check is the backstop, and it holds even when an llm profile ignores its instructions. A dropped clause raises an error and the binder materializes nothing.

Determinism & caching

Everything is content-addressed, which is what makes the "extra step" cheap to repeat. A rendered skill is cached under sha256(doctrine × harness-profile version × model-profile version × skill content); change any input and the cache invalidates, change nothing and the render is reused. A binding's coordinate is sha256(skills + doctrine + channel versions). Re-binding an unchanged surface is nearly free, and every past coordinate is a precise rollback target.