0061: Plugins ship skills every AI path can use: module.skill(...), read-only tools, an explicit export¶
Context and Problem Statement¶
Advice for how to do a piece of work is best written once, as an Agent Skill: a SKILL.md with a name and a
description in its frontmatter, a Markdown body and optional files beside it (agentskills.io,
ADR 0014). Today only the core ships skills, and only for Claude
Code (agent-skills/, hz system agent install). ADR 0050
§7 planned plugin skills as a field of the playbook (PlaybookLet.skills) rendered whole into a turn's instructions.
The maintainer asked for more: plugins and the core contribute skills, and every AI path in hz can use them, Pydantic
AI turns, Claude Code and any MCP client, with per-Heinzelmännchen allowlists, one record type layered general to
specific, and scripts that never run in a turn. A skill's text is instructions by design, written by whoever wrote
the plugin, and it may be company-internal; so the question is also what keeps it from granting anything, leaking, or
running.
Decision Drivers¶
- Data, not code, checked at import, the way themes are (ADR 0044).
- A skill is advice: it can't add a tool, an input, a target or a cap (ADR 0050 §7,
HZC051). - Progressive disclosure: an index costs a line per skill; bodies and files are read when needed.
- One
Skillrecord for every source, so the planned "From Claude" import (found Claude skills) feeds the same type. - Nothing reaches another tool's configuration (
~/.claude/skills) without the person deciding it. - Skill names, descriptions and bodies may be internal: logs and telemetry carry names and fingerprints only.
Considered Options¶
- A
SkillLet,module.skill(path), consumed bysystem.skill(chosen) PlaybookLet.skills(ADR 0050 §7): a playbook is one per module and is consumed by the workflow module (being renamed, ADR 0051); the core and modules without a playbook could not offer skills, and the skill catalog would depend on one consumer.skills/directories found by convention, no let: nothing checks them at import, and nothing ties a skill to the module and layer that ships it.- Export by rewriting the installed
agent-skills/Claude Code plugin: Claude Code owns its plugin cache and replaces it on every update; hz would write into another program's state.
Decision Outcome¶
Chosen option: a SkillLet that a module declares with module.skill(path), checked at import and consumed by
system.skill, which resolves skills along the layer stack and serves them read-only to turns, MCP clients and, on an
explicit export, Claude Code, because it reuses the theme pattern (data checked at import, ties decided by layer)
and keeps every grant in code.
- Format. A skill is a directory
<name>/withSKILL.md. Frontmatter (YAML, at most 4 KB, parsed safely):name(lowercase words joined by dashes, at most 64 characters, equal to the directory name, not starting withhzorheinzel),description(one line, at most 1,024 characters), and optionally the spec's and Claude Code's keys (license,compatibility,metadata,allowed-tools,disallowed-tools,argument-hint,disable-model-invocation,user-invocable,model), which hz keeps as hints: shown, never applied. Any other key is refused, keys starting withhzexplicitly (HZC051: a skill can't grant). Files beside it are text only (strict UTF-8, no NUL), no symbolic links anywhere (dotfiles and__pycache__are ignored: never listed, read or exported), at most 64 files, 4 directories deep, 200 KB each and 1 MB together;SKILL.mdat most 64 KB. A file that looks like it holds a secret (a private key block or a common token prefix) refuses the skill, naming file and line, never the value; so does an invisible, control or bidirectional character (whatclean_textwould remove), so whatshowdisplays is what an export carries. A directoryskills/holding several skills is declared with one call. All of this is checked when the plugin is imported, like a theme's fonts. - Record.
Skill(kernel, public): name, description, body, hints, files (path, kind, size, sha256) and a fingerprint over the sorted, length-prefixed paths and contents, and the SHA-256 ofSKILL.mditself. Kinds arereference,script,asset,templateandother, from the top directory.SkillLetre-checks on construction, asThemeLetdoes. - Layers.
system.skilloffers, by name, the most specific source, in this order from most to least specific: the personal directory ([system.skill] personal_dir, defaultskills/in the user configuration directory, read at listing time with the same checks, its problems reported, not fatal); the layers of the person's stack (ADR 0040), the more specific first; the core; a plugin outside the stack. So a plugin outside the stack never shadows a core skill; two such plugins tie by module name. An org and the site offering one name with nothing more specific deciding is a conflict, left out and reported (ADR 0044's rule). Shadowed skills are listed with what hides them. The team repository'sskills/and the found Claude skills are later sources of the same record; deferred. - One read-only service. The kernel declares
Skills(implemented bysystem.skill):index(allowed),load(name, allowed),read_file(name, path, allowed)andchat_allowed().allowedis enforced in the service, in code, never by the index text.loadreturns the body, cleaned (clean_text,redact_emails), cut at 24,000 characters and framed as data from its source, with the list of its files;read_filereads only a path the skill's record lists, opening every component below the skill's directory without following links, and refuses a file whose content changed since import,SKILL.mdincluded. Each load returns the skill's source and fingerprint, so a turn can record what it read (ADR 0049). - Pydantic AI turns. The kernel ships
skill_tools(skills, allowed), two plain typed functions,load_skill(name)andread_skill_file(name, path), andskill_instructions(entries), the index (name: description, one line each, newlines stripped, at most 40 entries and 8,000 characters). The turn engine wraps them in a Pydantic AIFunctionToolset; the kernel andsystem.skillimport no Pydantic AI. This replaces ADR 0050 §7's rendering of whole bodies: bodies are loaded on demand. - Allowlists. A contract names the skills its Heinzelmännchen may use in
hz.uses(ADR 0050 §7); the engine passes them asallowed. The chat's set is[system.skill] chat, the core's skills by default.personal_dir,chatandexport_dirsare ignored in a projecthz.toml: a repository someone else controls adds no prompt text and picks no directory to write. - MCP. On the stdio MCP head (not on streamable HTTP, which has no authentication, and not in a chat's server),
each skill is a prompt
skill_<name>and each of its files,SKILL.mdtoo, a resourceskill://<name>/<path>, read throughSkillswhen asked for. - Claude Code.
hz system skill export --name NAME(repeated) or--all-skills(every skill but the person's own, which go only by name) writes the chosen skills into each of[system.skill] export_dirs(default~/.claude/skills) asheinzel-<name>/, a prefix no core skill of ADR 0014 uses. The exportedSKILL.mdkeeps onlynameanddescription(Claude Code would honourallowed-toolsandmodel, so hints are dropped), and files are copied from the bytes hz read and checked. A directory is written beside the target and moved into place, with mode 0700 and files 0600; hz replaces or removes only a directory it marked with.heinzel-exportand that its manifest in the state directory lists; an unmarked or linked target is a reported conflict, never overwritten. Afterhz system plugin add,developorremove, the new environment runshz system skill export --sync, which only removes: exports whose skill is gone or whose fingerprint changed (shown as "changed: export again to review"). Nothing is ever added without the person naming it, since a skill in Claude Code can run its scripts.hz system skill unexportremoves exports, never a directory hz didn't write. - Scripts never run in a turn. No tool runs a skill's script. A Heinzelmännchen that needs one hands the task to the coding agent (ADR 0050 §6), which gets the skill's directory in its private working directory; not wired until the engine exists.
- Surfaces and confidentiality.
hz system skill listandshow NAMErun on the CLI and the (paired) web head, not on the unauthenticated REST head and not as MCP tools, so a chat's agent reads no skill beyond its set; MCP clients get skills as the prompts and resources of (7).exportandunexportrun on the CLI only. Logs, doctor, info and telemetry name skills and fingerprints, never bodies or file contents; an error names the file, never its text.
Consequences¶
- Good: one skill directory serves hz's own turns, Claude Code and every MCP client, and the same record will hold skills found in Claude.
- Good: a skill reaches a turn only by name from an allowlist, and Claude Code only by the person's export.
- Bad: a new let kind and a new prompt-injection surface: a plugin's skill text is instructions. It is bounded by the allowlist, cleaning, framing, caps and the shadowing rule, not removed; reviewing a plugin's skills is part of trusting the plugin.
- Bad: two writers to
~/.claude/skills(hz system agent installfor the core's Claude Code skills,hz system skill exportfor the rest), kept apart by theheinzel-prefix and the marker. - Neutral: until the turn engine lands,
skill_toolsis ready and tested but attached by nothing but tests.
Confirmation¶
tests/unit/kernel/skills_test.py (format, caps, hints, secrets, links, fingerprint, the re-check on read, a directory
swapped for a link, and the tools attached to a Pydantic AI TestModel turn as a FunctionToolset),
tests/unit/modules/skill_service_test.py (layer order, the core rule, org/site conflicts, the personal directory,
allowlists, export markers, modes, conflicts, sync and unexport), tests/unit/heads/mcp_skills_test.py (prompts and
resources on stdio only, none over HTTP or in a chat), tests/unit/modules/plugin_service_test.py (a plugin change
runs the sync and survives its failure) and tests/integration/skill_test.py (the CLI). The import-linter contracts
list heinzel.kernel._skills as private and system.skill as an independent submodule.
More Information¶
- Amends ADR 0014 (plugin skills reach Claude Code through
hz system skill export, not the plugin) and ADR 0050 §7 (skills come fromSkillLets, notPlaybookLet.skills; a turn gets an index and loads bodies on demand). - Deferred:
hz.applies(a skill loaded for every matching turn), the team repository'sskills/, the found Claude skills, a web page for skills beyond the operations, and parsinghz.usesin the renamed contract module.