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.
The vocabulary
Seven nouns carry the whole system. They stack from what an author writes to what an agent loads:
- skill
- A directory with a
SKILL.md(YAML frontmattername/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, orllm. - 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.
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).
Classify
Read the checkout's pyproject.toml/package.json
into a signal; derive an app-class cluster key.
Select
Subscribe to channels broad → narrow: system, per-cluster, repo — for this harness.
channels.pyResolve
Merge the channel layers into one blend; narrow overrides broad; record shadows; union absolutes.
composition.pyRender
Rewrite each skill for the harness dialect, then the model density. Verify guardrails survived.
render.pyLint
Check the blend for coherence — command collisions, duplicates, unsafe irreversibles. Gate.
composition.pyMaterialize
Symlink the resolved skills into the harness's native skills dir; touch only spindle-owned links.
materialize.py01 · 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:
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.
Density
identity— keep all scaffolding (what weaker models want).trim— strip author-marked scaffold fences, thenterse.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:
- preferences — "favor X over Y," scoped, with a note and provenance.
- absolutes —
ALWAYS/NEVERhard rules, scoped and rare; densest at system scope. - meta-principles — rules about how the other principles are written.
# 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.