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: falsedid not merely unadvertise a skill, it made it unreachable.visibilitynames the axis honestly and adds the state that was missing (on_demand). Config still carryingauto_invokefails at load with the exact replacement:auto_invoke: true→visibility: menu,auto_invoke: false→visibility: hidden(hiddenpreserves the behaviorfalseactually 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 nameread_fileand the unqualifiedread_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 seesread_fileand one without seesread_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:
- Discover —
menuskills are already listed in the L1## Skillsblock. For the rest,skill_list(theskill_listtool) returns every registered skill whosevisibilityis nothidden, with itsname,description, andpath. - Load — the model calls
load_skill(load_skill) with thatpathand 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
fileread op'sis_skill_body_pathspecial-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 intoload_skill;file.readis a plain read again for every path, includingSKILL.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, askill_invoke_collisionwarning);_handle_inbox_textreturns immediately, no router turn happens.consumed is False— eithertextwasn't:-shaped after all, or it was and resolved successfully;textis 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:
- builtin —
reyn.builtin.registry'sBUILTIN_SKILLS, viaread_builtin_body_bytes. - registered plugin body — a completed-install
~/.reyn/plugins/<name>/skills/**, viaread_plugin_body_bytes(plugin_install.is_registered_plugin_root). - config-registered entry — a
skills.entriesdeclaration (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:
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 kind — env.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:
~/.reyn/config.yaml— user-globalreyn.yaml— projectreyn.local.yaml— project-local (gitignored).reyn/config/skills.yaml— runtime-dynamic, written by theskill_install_local/skill_install_sourcetools
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:
- One message containing ONLY the completion-signaling tool call, no answer content.
- 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'sread_fileop 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 ownreferences/folder.${CLAUDE_SKILL_DIR}is an invocation-time token expanded only inSKILL.mditself (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.mditself) 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(ordinaryread_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:
- Resolves
SKILL.md(directory →<dir>/SKILL.md, or a direct file path). - Reads
name/descriptionfrom frontmatter (nameoverride argument takes precedence; falls back to the directory basename if frontmatter has none). - Threat-scans the description (strict scope) — blocks on a blocking-severity match.
- Gates the
skills.yamlwrite through the standardrequire_file_writepermission flow. - Writes the entry, records a config generation (crash-recovery — survives WAL truncation), emits a
skill_installedP6 event, and requests a hot-reload.
skill_install_source¶
Fetches a skill from a git/GitHub URL and installs the clone:
- Gates
require_http_getfor the source host. - Shallow-clones the repo (
--depth 1) to.reyn/skills/<name>/. A//subdirsuffix on the URL (mirroring Terraform's module-subdir convention) selects a subdirectory of the clone instead of its root. - Locates
SKILL.mdin the clone, then proceeds through the same frontmatter-read → threat-scan → gate → write → hot-reload pipeline as the local path, with the registeredpathpointing 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-toolsstyle 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_skillintrospection tools or CLI
See also¶
- Reference:
reyn.yaml—skills:block schema - Concepts: MCP — the analogous external-capability registration model
- Concepts: permission model — the file-read/file-write gates skills use
- Concepts: Config hot-reload — the general reload cycle