Skip to content

Skills

A skill is a reusable, task-specific instruction set — an industry-standard SKILL.md file (YAML frontmatter + Markdown body) that tells the model when a technique applies and how to carry it out. Skills are registered explicitly, exposed to the model as a lightweight menu, and read on demand — the same layered-disclosure shape as MCP tools, applied to instructions instead of APIs.

This is a different mechanism from the pre-1.0 skill.md-driven phase-graph workflow engine (removed; see the multi-agent / control-IR docs for the current execution model). A "skill" here is closer to a Claude Skill: a folder with instructions the model chooses to read, not a program the OS executes.

Registration: explicit entries, no directory scan

Skills are registered purely via skills.entries declarations in config — the same model as mcp.servers. There is no directory scan; a SKILL.md file sitting on disk with no config entry is invisible to every session.

# reyn.yaml
skills:
  entries:
    pdf_editing:
      path: skills/pdf-editing/SKILL.md
      description: "Fill, merge, and extract fields from PDF forms"
      enabled: true
      visibility: menu
Field Type Default Meaning
path string required Path to SKILL.md (or its containing directory). Project-root-relative or absolute.
description string "" One-line summary shown in the L1 menu. Truncated to the first line, capped at 1024 characters — the Agent Skills specification's own maximum for the description frontmatter field, so every standard-conformant description survives intact (#3550). A description over the cap is cut at the last word boundary that fits and ends in (ellipsis included in the 1024), so a capped description is distinguishable from a rendering fault on every surface it reaches (#3545).
enabled bool true false removes the entry from the registry entirely (not just hidden). Dominates visibility.
visibility enum menu Which discovery surface the skill reaches: menu | on_demand | hidden. See below.

visibility — which surface names the skill

Value In the L1 menu? Returned by skill_list? Use it when
menu yes yes The skill is broadly relevant and worth its standing token cost.
on_demand no yes The skill exists and should be used when it fits, but should not occupy the system prompt. Costs nothing until the model asks. Builtin skills ship in this state.
hidden no no The model must never use it — it reaches no model-facing surface at all.

enabled and visibility are not independent: enabled: false dominates. A disabled entry is dropped from the registry outright, so its visibility is never consulted. The two fields therefore describe four states, not six — "not registered", plus the three above.

Removed in #2971: auto_invoke. It was a misnomer — nothing has ever auto-invoked a skill, and the flag only ever chose whether the skill was rendered into the menu. Because the menu was then the only surface naming a skill, auto_invoke: false did not merely unadvertise a skill, it made it unreachable. visibility names the axis honestly and adds the state that was missing (on_demand). Config still carrying auto_invoke fails at load with the exact replacement: auto_invoke: truevisibility: menu, auto_invoke: falsevisibility: hidden (hidden preserves the behavior false actually delivered, not the narrower thing its old description promised).

The registry never reads SKILL.md itself — only path and description from the config entry populate the L1 menu and the skill_list result. The file is loaded by the model at L2, on demand, via the dedicated load_skill op (load_skill, FP-0066 P0/#3247) — which additionally expands invocation-time ${REYN_*}/${CLAUDE_*}/${env:VAR} tokens in the body before returning it (see Skill-load variable expansion below). The ordinary file-read op does NOT special-case SKILL.md — reading one with read_file returns its bytes byte-identical, same as any other file.

On the name read_file, used throughout this page. The file-read op has two spellings — the qualified catalog name read_file and the unqualified read_file — and both always dispatch. Which one the model is shown depends on the tool-use cell and on the operator's file-permission scope: a cell that composes the base tools with the flat catalog advertises each operation once, under the unqualified spelling where the base tools supply one, so a session with a configured read scope sees read_file and one without sees read_file. This page names the op, not the advertised row.

Discovering and using a skill

There is no run_skill tool, by design. A skill body is instructions for the model, not code to execute, so loading the file is the invocation:

  1. Discovermenu skills are already listed in the L1 ## Skills block. For the rest, skill_list (the skill_list tool) returns every registered skill whose visibility is not hidden, with its name, description, and path.
  2. Load — the model calls load_skill (load_skill) with that path and follows the instructions for the current task.

Builtin skills ship inside the installed package, physically outside any project root; load_skill resolves those paths through the same least-privilege carve-out file.read uses, scoped to the package's skills/ and pipelines/ directories, so they load cleanly in a non-interactive run without an operator to approve anything.

Prior to FP-0066 P0 (#3247), this hop rode the ordinary file read op's is_skill_body_path special-case (#2971's "reading is the invocation, no dedicated verb" — a drift from ADR 0064 §3.5's original call for one). That special-casing has been extracted into load_skill; file.read is a plain read again for every path, including SKILL.md.

Operator-explicit invocation: the :skill namespace (#3100)

The model reads a skill on its own when it judges the task matches the L1 menu / skill_list description (above). An operator can also invoke a skill explicitly by typing :name [trailing args] at the chat prompt — a namespace separate from / slash commands (reyn.interfaces.slash), deliberately: a slash command is OS-executed Python; a skill is always model-instructions the LLM follows (Axis 2 below). Splitting them onto their own prefixes makes "is this a skill or a built-in?" a syntactic, closed-type distinction rather than a runtime name-precedence lookup — the root cause class of Claude Code issue #13586 (an undocumented / shadow between a skill and a built-in command).

:skill still reuses the exact mechanism above — loading SKILL.md (skill- load token expansion included) IS the invocation, no skill__<name> op exists for it either. reyn.interfaces.skill_invoke.resolve_skill_body calls the same read_builtin_body_bytes / read_plugin_body_bytes / load_skill_body primitives the load_skill op wraps, directly — the : path has never gone through file.read or the load_skill op's own dispatch; it is its own lightweight call site over the shared primitives (no OpContext / permission-op round-trip needed, since an operator typing :name at their own prompt grants no capability beyond what they already declared in skills.entries).

Stacking. :a :b <trailing> invokes both skills in ONE turn — one LLM wake loads both SKILL.md bodies into context, capped at 6 stacked names (Claude Code's own limit). Expansion stops at the first token that isn't :name-shaped; everything after that (including a further :something once the cap or a non-: token is hit) is trailing text, not another stacked skill.

Parameters. $ARGUMENTS (the whole trailing text) / $0/$1/... (a shell-style-quoted positional split of the trailing text) / $name (frontmatter arguments: named positions) / \$ escapes a literal $. The trailing text is dual-purpose (Claude Code convention): it fills any placeholders AND is always appended to the composed message as additional instructions, even when the skill body has no placeholder at all. Two new SKILL.md frontmatter keys support this: arguments (a list of {name, description}, positional) and argument-hint (a display string, currently parsed but not yet surfaced anywhere the operator sees before typing — no consumer wired yet). disable-model-invocation (Claude Code's "user- invocable only" flag) is not yet read by this module — enforcing it would mean reading every registered skill's frontmatter at prompt-build time, which conflicts with on_demand's "costs nothing until read" invariant above; it needs its own caching design and stays open.

Collision — LOUD, never silent. The : namespace structurally avoids a skill-vs-built-in shadow, but a same-NAME collision across skills.entries config tiers can still happen (~/.reyn/config.yaml vs reyn.yaml vs a skill_install_*-written .reyn/config/skills.yaml). reyn.config.loader._merge tags each tier while merging and records any name that appears under two different tier labels into config.skills["_collisions"]; the LAST tier still wins (unchanged resolution), but :name invocation of a collided name fires BOTH a skill_invoke_collision audit-event and an operator-visible outbox warning naming the tiers involved — never a silent shadow.

Unknown name. :typo never falls through as a no-op — it errors with a closest-match suggestion (prefix + fuzzy match, same algorithm as an unknown /command) and a pointer to :list. A bare : or :list lists every :-invocable skill (same menu + on_demand surface skill_list returns — hidden reaches no surface, including this one).

No new permission gate. :name resolves against the operator's OWN registered skills.entries — a set already declared in config or installed through a permission-gated skill_install_* call. Reading that entry's SKILL.md for the operator grants no capability beyond what the operator already put there themselves (there is no LLM choosing the path), so :skill does not add a require_file_read gate around the read.

Implementation: reyn.interfaces.skill_invoke (the parser / substitution / collision-lookup helpers, pure functions) and Session._maybe_handle_skill_invoke (the dispatch point in _handle_inbox_text).

Audit trail. The : invoke path emits its own skill_invoke_body_loaded audit-event for each loaded skill body (name, path), scoped separately from the ordinary file.read/load_skill op's skill_body_loaded event (see Skill-load variable expansion). This lets a replay distinguish "the model read this skill on its own" from "the operator explicitly invoked it via :name".

Call-site mechanics (_handle_inbox_text)

Session._maybe_handle_skill_invoke returns a (consumed, text) tri-state read at the top of _handle_inbox_text, the turn body every text-bearing inbox kind reaches. : and / are separate namespaces with separate homes: : is a SESSION-side step (it composes text a model then reads, so it belongs to the turn), while / is a COMMAND the client interprets and never reaches the session as text at all (#3595 — step 1 split them apart, S5 moved the / half out of Session entirely into reyn.interfaces.slash.dispatch):

  • consumed is True — the invocation was fully handled here (e.g. :list, an unknown-name error, a skill_invoke_collision warning); _handle_inbox_text returns immediately, no router turn happens.
  • consumed is False — either text wasn't :-shaped after all, or it was and resolved successfully; text is REPLACED with the composed skill body(ies) + trailing args (or left unchanged), and execution falls through into the ordinary router turn below. One turn, one LLM wake, regardless of how many :names were stacked (see "Stacking" above).

This fallthrough is why : dispatch cannot be folded into the / slash lookup's precedence chain: a / handler returns-and-stops by construction, while a successful : handler must hand a rewritten text onward into the very turn machinery / short-circuits. Collapsing the two into one precedence-ordered lookup reintroduces the shadow-name ambiguity class described in Claude Code issue #13586 — a name that could be read as either a skill or a built-in resolves by lookup order instead of by an unambiguous prefix.

Skill-load variable expansion

Loading a SKILL.md body is not a byte-identical file read: the dedicated load_skill op (reyn.core.op_runtime.load_skill.handle, FP-0066 P0/#3247) routes the request through a skill-load pass (reyn.plugins.skill_load, ADR 0064 §3.5) when the resolved path falls into a registered provenance class. Prior to #3247 this pass rode the ordinary file read op's is_skill_body_path special-case; it is now load_skill's own responsibility exclusively — file.read no longer inspects the path's filename or provenance at all.

Provenance gate (#3196). The filename check alone is NOT the trust boundary — a file literally named SKILL.md anywhere under the project root (planted by a cloned third-party repo, a scraped page, anything already default-readable under the permission model's "read anywhere under the project root" default) used to have its ${env:VAR_NAME} tokens expanded to real secret values on an ordinary read, regardless of registration. The resolved (symlink/..-collapsed) path must ALSO match one of exactly three registered provenance classes, enumerated from the same registries every other skill surface uses — never a hand-curated path list:

  1. builtinreyn.builtin.registry's BUILTIN_SKILLS, via read_builtin_body_bytes.
  2. registered plugin body — a completed-install ~/.reyn/plugins/<name>/skills/**, via read_plugin_body_bytes (plugin_install.is_registered_plugin_root).
  3. config-registered entry — a skills.entries declaration (build_skill_registry), matched against the session's live registered-skill snapshot.

A SKILL.md that resolves to none of the three is still loaded — but byte-identical, no expansion, no skill_body_loaded audit-event (an ordinary, unremarkable load; fails closed, never open).

Resolve-once (#3196 co-vet round 2). load_skill resolves its path argument EXACTLY ONCE per call (reyn.core.op_runtime.context. resolve_path_for_gate) and reuses that single resolved string for the permission gate, the provenance classification, AND the actual byte read — never a second, independent resolve for "is this trusted" vs "what do I read". A split there is exactly the symlink-swap TOCTOU window #3196 closed.

${env:VAR} allowlist gate (#3198) — orthogonal to the provenance gate above. The provenance gate (#3196) answers "is this SKILL.md trustworthy at all"; it does NOT answer "what may a trustworthy body read". Without a further gate, a REGISTERED skill (one that passes provenance) could still write ${env:GITHUB_TOKEN} in its own prose and have it expanded into the LLM's context on an ordinary read — installing a plugin would be equivalent to handing it every credential in the process environment. ${env:VAR_NAME} therefore additionally requires VAR_NAME to be declared on the caller's PermissionDecl.env_expand allowlist (reyn.security.permissions.permissions) — deny-by-default: an empty/unset allowlist expands nothing. A denied token (like an unset one) is left unexpanded, never blanked — the two cases collapse to the same harmless "stray literal token" shape, never a hard read failure.

Declare the allowlist in reyn.yaml (or reyn.local.yaml) under the same permissions: block every other capability axis uses (file.read, http.get, secret.write, …) — reusing the existing permission-consent surface rather than inventing a new one:

permissions:
  env.expand:
    - LANG        # a specific name — only this exact var expands

Do not use the "*" wildcard here. env.expand's "*" LOOKS like secret.write's "*" but its risk is NOT the same: secret.write: ["*"] is safe-ish because the actual write still goes through a per-value OPERATOR PROMPT at execution time (the prompt is the real gate; the wildcard just says "the key set isn't known until runtime"). env.expand has no such prompt, no backstop of any kindenv.expand: ["*"] unconditionally expands every ${env:VAR} a skill body writes, straight into the LLM's plain-text context, with no operator confirmation at read time. This is exactly the credential-exposure path #3198 exists to close: an operator who reads "mirrors secret.write's shape" and infers "about as safe as secret.write's wildcard" would be wrong. Enumerate the specific names a skill actually needs instead.

The skill_body_loaded audit-event reports the gate's outcome by name and count only, never by value: env_tokens_expanded/env_names_expanded (substituted) and env_tokens_denied/env_names_denied (rejected by the allowlist) — an audit-event is not a second place a secret's value could leak. Location tokens (${REYN_*}/${CLAUDE_*} below) carry no credential and are unaffected by this gate.

Three token kinds expand, in order:

Token Source Resolved
${REYN_PLUGIN_ROOT} the skill's plugin directory (a plugin.json marker found walking up from the skill's own directory; falls back to the skill's own directory for a standalone, non-plugin skill) every load (see note below)
${REYN_SKILL_DIR} the skill's own containing directory every load
${REYN_PROJECT_DIR} the current session's workspace root every load, freshly
${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_SKILL_DIR} / ${CLAUDE_PROJECT_DIR} alias of the three ${REYN_*} tokens above (ADR §3.6) — SKILL.md is a shared open standard (agentskills.io), so this alias is always active for a skill-load, not gated behind a separate provenance check every load
${env:VAR_NAME} os.environ — namespaced (env: prefix), deliberately NOT the bare ${VAR} syntax mcp spawn config uses, so a literal ${VAR}-shaped code example in a skill body's prose is never mistaken for a token; only when VAR_NAME is declared on permissions.env.expand (#3198, deny-by-default — see above); an unset OR undeclared ${env:VAR_NAME} is left untouched rather than blanked every load, freshly

${REYN_PLUGIN_ROOT}/${REYN_SKILL_DIR} are, per the ADR, "stable location" values meant to be baked once at plugin-install copy time (plugin_install, plugin-model P2 — not yet built) rather than re-expanded per read; skill-load expands them anyway today because no installed skill body has ever had them baked, and doing so is a no-op once P2 starts baking them (a baked body has no ${...} left to match). ${REYN_PROJECT_DIR} and ${env:VAR_NAME} are genuinely dynamic and are always resolved fresh, never baked.

#3629 — "stable location" describes the copy-time bake, not what a load persists to history. The load-time-expanded VALUE (the string load_skill returns) still goes into .reyn/agents/<id>/history.jsonl as the tool result — and history is immutable. A rename or move of the plugin/skill directory after that point (#3588's shipped-skill rename above was one instance) used to leave the OLD absolute path baked into an old history entry forever, replayed to the model every later turn with no way to tell it apart from a live one. Since #3629, what gets persisted differs from what the model reads that turn: ${REYN_SKILL_DIR}/ ${REYN_PLUGIN_ROOT} (+ their ${CLAUDE_*} aliases) are left LITERAL in the persisted entry (reyn.plugins.skill_load.load_skill_body's persisted return value; the resolved values ride along as audit-completeness metadata, token_map, never as a re-expansion source), and a wire-serialise pass re-resolves them FRESH against the current filesystem every time that entry is replayed (reyn.plugins.skill_load.refresh_location_tokens, wired into RouterHistoryBuffer._serialise_turn) — the same "resolved fresh each call, never baked into a durable copy" discipline ${REYN_PROJECT_DIR} already had, extended to the two tokens that were missing it. ${REYN_PROJECT_DIR}/${env:VAR_NAME} needed no change; they were already safe by this measure. Already-persisted (pre-#3629) history is neither rewritten nor annotated — the fix is forward-only; see Not-found suggestions surface the current structure below for what happens when the model tries to act on one of those old, now-dead paths.

Reuses reyn.plugins.tokens (PluginTokenContext / expand_reyn_tokens) — the same expansion primitive a pipeline's ctx params use (ADR §3.4's "uniform across capabilities" split) — rather than a skill-specific reimplementation. An mcp server's mcp.json no longer shares this primitive: #4570 conversion D gave it a field-aware, standard-vocabulary bake (${PLUGIN_ROOT}/${PLUGIN_DATA}, expanded only in args/env/cwd, never command/url — see Control IR: plugin_install).

Config cascade

skills.entries merges across the same tiers as every other config section, later tiers winning on name collision:

  1. ~/.reyn/config.yaml — user-global
  2. reyn.yaml — project
  3. reyn.local.yaml — project-local (gitignored)
  4. .reyn/config/skills.yaml — runtime-dynamic, written by the skill_install_local / skill_install_source tools

Hand-editing any of the first three is a normal way to register a skill; the fourth is written automatically by the install tools below and reflects what a session installed for itself.

Writing a SKILL.md

---
name: pdf-editing
description: Fill, merge, and extract fields from PDF forms
---

# PDF editing

Use `pypdf` for form-field operations...

name and description are frontmatter keys read by the install tools (see below) to prefill a skills.yaml entry — the config entry's own description is what actually reaches the model, so keep it accurate and short (first line only; longer detail belongs in the body). The Markdown body is free-form: this is model-facing instruction text, not a schema the OS parses.

name spelling. The Agent Skills specification constrains name to lowercase letters, numbers, and hyphens only (max 64 characters, no leading/trailing hyphen, no consecutive hyphens, and it must match the parent directory name). Every skill reyn itself ships obeys that rule — reyn-cheat-sheet, draft-judge-revise, reactive-orchestration-plugins, and the rag plugin's build-and-query-rag-corpus (#3567 renamed all four from an earlier underscore spelling; there is no alias for the old spelling, so :draft-judge-revise is the only way to invoke it). Note what reyn does not do: it does not enforce the rule on an operator-registered or third-party skill. The :name token grammar and the install-time path-safety check (below) both still accept _, because rejecting a non-conformant third-party SKILL.md outright would be a separate decision from spelling reyn's own skills to the standard.

Design pattern: don't bundle a final answer with a completion-signaling tool call

If a skill (or any agent-authoring convention on top of reyn) has the model signal "I'm done" via a tool call — a .turn_done-style exec, a completion marker, anything the loop reads as "stop here" — instruct the model to send that signal in its OWN message, never in the same message as the final answer text. Correct order:

  1. One message containing ONLY the completion-signaling tool call, no answer content.
  2. The next message (after the tool result comes back) contains ONLY the answer text.

Why this matters (architect's measurement, a real reyn-self incident, 2026-08-14): when a message carries both real answer content AND a tool call, the presence of the tool call keeps the loop going — the LLM reading that history back sees its own answer sitting alongside a still-open tool call and treats the answer as intermediate, not final, so it writes the answer again on the next turn. Contrast, from the same history.jsonl (all 22 entries after a /clear, not a filtered sample):

Turn shape content tool_calls Outcome
3 normal answer-only turns (seq 976/982/988) present 0 answer stands, no repeat
3 completion-signal-only turns (seq 974/980/986) empty 1 signal fires, no repeat
1 mixed turn (seq 1010) 2,060 chars (a full answer) 1 (.turn_done exec) the SAME ~2,252-char answer was written again at seq 1012

What this data does and doesn't support. The "answer becomes intermediate output, triggering another turn" mechanism is well-supported — 6 contrasting examples land exactly where the pattern predicts. The duplicate-answer OUTCOME itself is N=1 — only one mixed turn has been observed, so "a mixed message always duplicates the answer" is not established, only "a mixed message keeps the loop open, and in the one case observed, that produced a duplicate." Design against the mechanism (don't mix), not against a guaranteed-duplication claim.

Three-layer exposure

Layer What the model sees Mechanism
L1 — menu A dedicated ## Skills system-prompt block, one line per enabled + auto-invoke skill: name — description [path]. Built once per turn from the registry; no dedicated dispatch.
L2 — instructions The full SKILL.md body, loaded only when the model judges the current task matches an entry's description. The dedicated load_skill op (load_skill, FP-0066 P0/#3247) — the body passes through invocation-time variable expansion (see Skill-load variable expansion) before it reaches the model. read_file no longer special-cases this path.
L3 — bundled assets Any additional files the skill's instructions reference (templates, scripts, reference data) sitting alongside SKILL.md. Ordinary read_file, gated by the standard permission model like any other path — except for a builtin or installed-plugin skill (below), where skills/**/pipelines/** content bypasses the gate the same way SKILL.md itself does.

There is no dedicated "run this skill" primitive at any layer — a skill is discovered via L1, loaded via L2, and its assets are just files. The model decides relevance from the L1 description; the OS does not gate which skill the model may load, only which paths it may read/load (the standard permission model — reading inside the project root is a default; outside requires the usual declaration + approval).

Builtin/plugin body reads bypass the read-zone gate; everything else doesn't. A builtin skill/pipeline's path (reyn.builtin.registry's BUILTIN_SKILLS/BUILTIN_PIPELINES entries) and an installed plugin's skills/**/pipelines/** content (~/.reyn/plugins/<name>/, ADR 0064 §3.3) both resolve OUTSIDE project_root in every deploy — the standard out-of-root gate would hard-deny them non-interactively, with no operator present to approve. reyn.builtin.docs.read_builtin_body_bytes (#2913/#2914) and reyn.plugins.body_read.read_plugin_body_bytes (owner ruling + architect firm) short-circuit that gate for exactly this content — load_skill (L2, SKILL.md itself), read_file (L3, any bundled asset under skills//pipelines/, including the ${CLAUDE_SKILL_DIR}-referenced files described just below), and :name skill-invoke (reyn.interfaces.skill_invoke.resolve_skill_body) all route through them. The plugin bypass is gated on install-registration, not on the presence of a .reyn-plugin/ marker: a plugin only qualifies once plugin_install has completed (source-resolve → manifest-validate → operator-permission-gated global copy → capability-register all succeeded — reyn.core.op_runtime.plugin_install.is_registered_plugin_root), so a hand-placed marker under ~/.reyn/plugins/ can never forge the bypass. ~/.reyn/plugins/.staging/ (pre-approval git-clone staging content) and anything outside skills//pipelines/ (scripts/, requirements.txt, mcp.json) are explicitly excluded — least-privilege, mirroring the builtin bypass's own package-body-dir scoping. Enable/disable state never gates this: it is a project-local "use it or don't" toggle over content already approved once, globally, at install time.

Splitting a large skill: ${CLAUDE_SKILL_DIR} references (#3162)

SKILL.md's body is loaded via the dedicated load_skill op, so it is subject to that op's own inline-read cap — the model-unresolved default floor is MAX_CONTROL_IR_RESULT_INLINE_BYTES (src/reyn/core/context_builder.py, currently 8,192 chars). A body at or above that floor is silently truncated whenever no model (or a small-window model) resolves at read time — the worst kind of failure, because the same file behaves differently depending on an orthogonal runtime variable. When a skill genuinely cannot shrink below the floor without losing its value as a single-topic index (splitting it by sub-topic would destroy the thing that makes it useful — see #3162), it can split into an L2 router + L3 bundled references instead, using the standard Agent Skills mechanism for referencing a bundled file: a Markdown link in the SKILL.md body whose target is ${CLAUDE_SKILL_DIR}/references/<file>.md (CLAUDE_SKILL_DIR is reyn's alias for REYN_SKILL_DIR, src/reyn/plugins/tokens.py):

---
name: reyn-cheat-sheet
description: ...
---

Deeper detail on hooks and MCP:
[hooks-and-events.md](${CLAUDE_SKILL_DIR}/references/hooks-and-events.md)
  • A bare relative path (e.g. references/foo.md) does not work here — reyn's read_file op resolves a non-absolute path against the workspace root, not the skill's own directory (src/reyn/core/op_runtime/file.py), so it would silently miss the skill's own references/ folder. ${CLAUDE_SKILL_DIR} is an invocation-time token expanded only in SKILL.md itself (never in a bundled file — src/reyn/plugins/skill_load.py), so the expanded link resolves to an absolute path regardless of the workspace the skill is read from.
  • The router (SKILL.md itself) should stay small enough to let the model decide whether it needs to go deeper, and which reference to read, without having read the references yet — name each reference file for the question it answers, and keep the one-line "when to read this" note next to each link.
  • Each reference is read the same way as SKILL.md (ordinary read_file), so it is subject to the same default inline cap.

tests/builtin/test_skill_references_gate_3162.py gates, for every shipped skill (builtin registry + plugin skills-on-disk, the same registry-plus-disk-walk enumeration test_skill_md_default_inline_cap_gate.py and test_builtin_registry_disk_parity.py use): every ${CLAUDE_SKILL_DIR}- or ${REYN_SKILL_DIR}-prefixed link in a SKILL.md body resolves to a real file under the skill's directory; the references/ directory's file set and the set of such links pointing into it match exactly in both directions (no orphan file, no dangling link); every .md file under a skill's directory is strictly under the default inline cap; and no file under a skill's references/ directory itself contains a ${CLAUDE_SKILL_DIR}-/ ${REYN_SKILL_DIR}-prefixed Markdown link pointing at another file. That last check enforces a one-level-deep invariant: L1 (menu) -> L2 (router, SKILL.md) -> L3 (reference) is the whole chain, and an L3 file is always a leaf — a link from inside a reference to yet another file would be unreachable anyway (only SKILL.md gets token expansion), so an L3-to-L3 link is always a bug, not a valid deeper level.

Not-found suggestions surface the current structure

3629: already-persisted history is never rewritten (see the "stable

location" note above), so a renamed/moved skill directory leaves old history entries pointing at a path that no longer exists — history outliving the filesystem is accepted as a permanent, forward-only-fixed cost. What changed instead is the surface where that stale reference actually causes harm: when the model tries to read a path whose PARENT directory is itself gone (not just empty — see the distinction below), file.read's not_found result's suggestions list (_nearby_files, src/reyn/core/op_runtime/file.py) includes the nearest EXISTING ancestor directory instead of an empty list, so the model can discover the current structure on the spot rather than guessing a replacement path from a dead one. This is deliberately general (renames, moves, plugin reinstalls all produce the same "parent is gone" shape) — it distinguishes "no parent" (a renamed/moved directory — this fallback fires) from "no neighbours" (an existing-but-empty directory — the suggestions list legitimately stays [], unchanged from before #3629).

Hot-reload

Edits to .reyn/config/skills.yaml take effect at the next turn boundary via the "skills" reload seam — no session restart needed. Editing reyn.yaml / reyn.local.yaml directly follows the same general config hot-reload path as other sections; see Concepts: Config hot-reload.

Per-session visibility toggle

A skill can be hidden from a single session without touching config, via the same status-bar-style visibility override used for tools / MCP servers / categories: set_capability_visible("skill", name, visible). This is restrict-only — toggling a skill name that isn't in the registered set (or that a topology/delegation envelope already denies) is a silent no-op; visibility can never grant access beyond what's registered.

Installing skills

Two chat-callable tools under the skill_management category write skills.yaml entries — there is no reyn skill CLI equivalent in v1/v2 (skill management is a chat-driven, in-conversation flow).

skill_install_local

Registers a local skill directory (or a direct path to its SKILL.md) into .reyn/config/skills.yaml:

  1. Resolves SKILL.md (directory → <dir>/SKILL.md, or a direct file path).
  2. Reads name / description from frontmatter (name override argument takes precedence; falls back to the directory basename if frontmatter has none).
  3. Threat-scans the description (strict scope) — blocks on a blocking-severity match.
  4. Gates the skills.yaml write through the standard require_file_write permission flow.
  5. Writes the entry, records a config generation (crash-recovery — survives WAL truncation), emits a skill_installed P6 event, and requests a hot-reload.

skill_install_source

Fetches a skill from a git/GitHub URL and installs the clone:

  1. Gates require_http_get for the source host.
  2. Shallow-clones the repo (--depth 1) to .reyn/skills/<name>/. A //subdir suffix on the URL (mirroring Terraform's module-subdir convention) selects a subdirectory of the clone instead of its root.
  3. Locates SKILL.md in the clone, then proceeds through the same frontmatter-read → threat-scan → gate → write → hot-reload pipeline as the local path, with the registered path pointing at the installed copy.

Path-safety hardening (both tools, since the resolved name feeds a filesystem path under .reyn/skills/): the derived name — from the name argument, SKILL.md frontmatter, or a URL/subdir basename — is rejected outright unless it is a single safe path component ([A-Za-z0-9._-]+, no .., no leading dot, no separators). A belt-and-suspenders containment check (resolve() + relative_to()) additionally refuses any install destination that would resolve outside .reyn/skills/, guarding against a gap in the name check itself. Neither check silently rewrites an unsafe name — installation is refused with an explicit error instead.

Body threat-scan is at load_skill, not here (#4699)

The install-time scan above (step 3 of both tools) only covers the frontmatter description — the one-line menu text, never the place an attacker would put a payload. It also only runs when a skill is registered THROUGH one of these two tools; a .reyn/config/skills.yaml entry written by hand (or by any other means) never passes through it. The load_skill op is the actual gate: every skill body crosses it regardless of how the entry was registered, so content_guard.scan_for_threats (same scope="strict" shape) runs there, on the fully-expanded body, before it can reach the model's context — see Skill-load variable expansion and docs/reference/runtime/control-ir.md's load_skill section for the exact step. The description scan above stays as an install-time fail-fast (reject before persisting to disk); it is additive UX, not what makes a loaded body safe — that guarantee comes from load_skill's own scan alone.

References get the same treatment via a directory-containment tag (#4701)

references/*.md files a skill body points at are read through the ordinary file.read op when the model opens them, not through load_skill — so the scan above never sees them by default. Owner ruling (#4701): a skill's reference files are the SAME content class as its SKILL.md body — both are instructions the model reads and follows — so file.read applies the SAME strict+block scan (reusing the identical event kinds, skill_body_threat_match /skill_body_threat_blocked) whenever the resolved path is CONTAINED under a registered skill's own directory (the parent of its SKILL.md, checked against ctx.available_skills the same way load_skill's own provenance check is — never a hand-curated path list). This is deliberately narrower than "scan every read": file.read's other callers (ordinary project files) are completely unaffected — applying strict+block to every read would spread false-positives across all file reading, which the ruling explicitly rejected.

A blocked reference never reaches the model (status: "blocked", empty content, same as a blocked SKILL.md body); a CLEAN reference is additionally tagged _external_source on the op's own result — a per-call override of the (otherwise static) returns_external_content tool flag, so only THIS read gets fenced at the tool-result chokepoint, not every read_file call. An undeterminable containment check (e.g. a broken/malicious symlink in a registered skill's own path) errs toward treating the read as skill content rather than silently excluding it — the reverse would let a single symlink evade the check entirely.

What's out of scope (for now)

Deliberately not part of the current model — planned for a future layer, not a gap in this one:

  • Per-skill tool-permission scoping (an allowed-tools style activation scope)
  • Dynamic shell-command execution syntax inside skill instructions
  • A marketplace / registry index for discovering skills (unlike MCP's official registry)
  • list_skills / describe_skill introspection tools or CLI

See also