Skip to content

Skills: progressive disclosure for agent capabilities

Date: 2026-06-03 Status: Approved design — ready for implementation planning Relationship: Builds directly on two shipped subsystems and reuses their mechanics: the append-only cache strategy work (2026-05-29-cache-strategy-composition-design.md) and the persistent, cache-friendly memory injection work (2026-05-30-persistent-cacheable-memory-design.md). Skills are, structurally, a hybrid of the workspace feature (a config field that auto-injects tools + a system-prompt fragment) and memory injection (deduped, append-only, cache-stable content carried in the transcript).


Problem

Helix agents declare their full capability surface up front: every tool is sent to the model on every step, and all standing guidance lives in the one systemPrompt string. This does not scale:

  • Token cost / context dilution. A library of 50 specialized workflows (PDF handling, a deploy runbook, a code-review protocol, …) cannot all live in the system prompt — it would bloat every request and dilute the model's attention even when none are relevant to the current task.
  • No on-demand capability loading. There is no first-class way to say "here is a catalog of things you could do; pull in the full instructions only when a task matches." Today the choice is binary: in the prompt always, or not at all.
  • Caching pressure. Anything added to the system prompt to describe more capabilities enlarges the cached prefix for every single request, and any change to it invalidates the whole prefix.

Anthropic's Agent Skills pattern (Claude Code, the Claude Agent SDK, claude.ai) solves exactly this with progressive disclosure: cheap metadata always resident, full instructions loaded on demand, bundled resources loaded only when referenced. We want this as a first-class, runtime-agnostic Helix feature, with delivery modes that fit the framework's "composability over configuration" philosophy.

Goal

Add a Skills feature implementing 3-level progressive disclosure, where:

  1. Level 1 (metadata) — every skill's name + description is always resident in the system prompt (cheap, ~tens of tokens each), living in the stable, cacheable prefix.
  2. Level 2 (instructions) — a skill's full body is loaded on demand via a tool, landing in the transcript append-only (never mutating the cached prefix).
  3. Level 3 (resources) — bundled reference files / scripts / assets are read only when referenced, via a second tool.

Delivery is pluggable via a SkillProvider interface. v1 ships two providers: in-code (plain data; the "boring code files" mode) and filesystem (SKILL.md directories, the Anthropic on-disk format).

Caching is a first-order constraint, not an afterthought: skill loading MUST be purely additive. The always-on catalog must be byte-stable within a session; loaded bodies and resources must only ever append after the cached prefix.

Decisions (locked during brainstorming)

  1. Two providers in v1: in-code (inCodeSkillProvider, lives in core, portable to Cloudflare Workers) + filesystem (SKILL.md dirs, lives in a new @helix-agents/skill-fs package so node:fs stays out of the core/Workers bundle). Claude-Code auto-discovery (~/.claude/skills) and a workspace-backed provider are out of scope for v1 (both are trivial follow-ons — see Future work).
  2. Activation = stateful additive injection. A loaded skill's body persists in the transcript and is deduped, but is delivered as the load_skill tool's result (immediate, no wasted round-trip) rather than as a separate end-of-transcript injection. The "stateful" part is framework-managed tagging (SKILL_INJECTION metadata) + dedup of re-loads + a basis for future compaction reattachment. Tool results are append-only by construction, so this is fully cache-safe. (Rationale below.)
  3. Full 3-level disclosure. Catalog → body → on-demand resource files.
  4. Tool surface = load_skill + read_skill_file. No search_skills / embedding index in v1 (catalog-in-prompt scales to ~100 skills; semantic search is future work for very large libraries).
  5. The skills feature discloses content; it does not execute code. Level-3 "scripts" are readable via read_skill_file; running them is delegated to the agent's existing shell/workspace tools. This keeps the feature decoupled from any execution environment (consistent with not choosing the workspace-backed provider for v1).

Why deliver the body as the tool result (not a separate injection)

The literal reading of "stateful additive injection" (mirror memory injection: load_skill returns an ack, body appended at end of transcript on the next step) costs an extra LLM round-trip — the model calls load_skill, gets nothing useful back, and must step again to see the body. Returning the body as the tool result is:

  • Immediate — the model can act on the instructions in the same continuation (this is what Claude Code, Mastra, and opencode all do).
  • Still additive — a tool-result message only ever appends to history; it never touches the cached prefix. The anthropicCache rolling breakpoints cover it on subsequent steps exactly as they cover any tool result.
  • Still stateful — we tag the result message (SKILL_INJECTION + skillIds) and dedup re-loads, giving us UI filtering, recovery-safety, and a hook for future compaction reattachment.

This is the same conclusion the memory "as-built" note reached (append-after, let it flow through the per-step commit slice), adapted to a tool-triggered load.

Prior art (researched)

Cloned to /tmp/skills-research/ (read-only): mastra, opencode, langgraphjs + langgraph. Plus Anthropic primary sources and local SKILL.md files under ~/.claude/.

  • Anthropic Agent Skills (gold standard). Directory-per-skill, SKILL.md = YAML frontmatter (name, description, optional license, compatibility, metadata, allowed-tools) + markdown body, optional scripts/, references/, assets/. 3-level disclosure: name+description always in system prompt (~100 tok/skill) → body on trigger (<5k tok) → resources/scripts on demand (scripts run, only stdout enters context). Description-writing rule (load-bearing): third-person, "what it does + Use when…" — and critically, do NOT summarize the workflow in the description; Anthropic's own testing showed the model then follows the description instead of reading the body. Caching: ≤4 breakpoints, strict tools → system → messages order, exact-prefix match, append-only is the cardinal rule.
  • Mastra ships a real Anthropic-spec Skills feature: catalog (name+desc) in a separate, deterministically-sorted system block + a small fixed set of meta-tools. Stateless activation (body lives in tool-result history; re-invoke after compaction). Tiered/dynamic skills via request context. Key warning: models confuse skills with tools → an explicit "Skills are NOT tools, call skill" guardrail is required in the prompt.
  • opencode does the same two-tier disclosure, ships skills with a resource directory + sampled file list, is Claude-Code-compatible (~/.claude/skills), and collapses the system prompt to ≤2 strings and sorts tools alphabetically specifically to lock cache breakpoints. Empirical note: verbose catalog in the prompt, terse description in the tool — not vice-versa.
  • LangGraph has no first-class skills; contributes two ideas — declare the tool superset once but bind a subset per step, and inject context via a pre-model hook into a separate channel (don't pollute history).

Synthesis adopted: Anthropic's SKILL.md contract + 3-level model, Mastra's fixed-tool-surface + guardrail + deterministic catalog sort, opencode's resource-directory + cache-locking discipline — implemented on Helix's existing append-only/cacheable substrate.

Design

New core types — packages/core/src/types/skill.ts

ts
/** Level-1 metadata (always resident in the system prompt). */
export interface SkillMetadata {
  /** [a-z0-9-], ≤64 chars, no leading/trailing/double hyphen. For the
   *  filesystem provider this MUST equal the skill's directory name. */
  name: string;
  /** ≤1024 chars. Third-person, "what + Use when…". Triggers only — do NOT
   *  summarize the workflow (see authoring guidance). */
  description: string;
  license?: string;
  compatibility?: string;
  /** Arbitrary client-specific string→string map (open-standard field). */
  metadata?: Record<string, string>;
  /** Experimental open-standard field. Parsed + carried in v1; enforcement
   *  (pre-approving these tools) is deferred — see Open decisions. */
  allowedTools?: string[];
}

/** A resource file bundled with a skill (Level 3). Listed (path only) when a
 *  skill is loaded; contents read on demand via read_skill_file. */
export interface SkillResourceRef {
  /** Skill-relative path, e.g. "references/forms.md", "scripts/extract.py". */
  path: string;
  description?: string;
}

/** Level-2 payload: full body + the listing of available resources. */
export interface Skill extends SkillMetadata {
  body: string;
  resources?: SkillResourceRef[];
}

export interface ReadResourceRange {
  startLine?: number;
  endLine?: number;
}

/**
 * Pluggable skill source. All methods are async so filesystem / remote
 * providers fit. Implementations MUST be side-effect-free w.r.t. agent state.
 */
export interface SkillProvider {
  /** Level 1. The framework sorts the result by name for cache stability;
   *  providers need not pre-sort. */
  listSkills(): Promise<SkillMetadata[]>;
  /** Level 2. Returns null if the name is unknown. */
  getSkill(name: string): Promise<Skill | null>;
  /** Level 3. Returns null if the skill or path is unknown/forbidden. */
  readResource(name: string, path: string, range?: ReadResourceRange): Promise<string | null>;
}

In-code provider (core) — packages/core/src/skills/in-code-provider.ts

ts
export interface SkillDefinition extends SkillMetadata {
  body: string;
  /** path → content (string) or lazy loader. Keys are the skill-relative
   *  paths surfaced as SkillResourceRef.path. */
  resources?: Record<string, string | (() => string | Promise<string>)>;
}

/** Validation + identity helper (Zod), mirroring defineTool/defineAgent. */
export function defineSkill(def: SkillDefinition): SkillDefinition;

/** Pure, dependency-free provider over an in-memory array. Portable to
 *  Cloudflare Workers. */
export function inCodeSkillProvider(skills: SkillDefinition[]): SkillProvider;

This is the "plain old boring code files" mode: skills are TypeScript data, bundled with the agent, no filesystem, works on every runtime.

Filesystem provider (new package) — @helix-agents/skill-fs

packages/skill-fs/ (depends on core; uses node:fs/promises + a YAML parser). Kept out of core so the Workers bundle never imports node:fs (same rationale as store-redis / memory-redis being separate).

ts
export interface FileSystemSkillProviderOptions {
  /** Directories scanned for <root>/<skill-name>/SKILL.md. */
  roots: string[];
  /** mtime staleness re-scan cooldown. Default 2000ms (mirrors Mastra). */
  stalenessCheckCooldownMs?: number;
}
export function fileSystemSkillProvider(opts: FileSystemSkillProviderOptions): SkillProvider;

Behavior:

  • Discovery: glob <root>/*/SKILL.md; parse YAML frontmatter via a Zod schema (name/description required; name must match the directory name — warn + skip on mismatch). Skip and warn on invalid skills rather than throwing (one bad skill shouldn't break the agent).
  • Caching + staleness: cache the discovered catalog; re-scan only when a directory/SKILL.md mtime changed AND the cooldown elapsed (avoid re-scanning every step — Mastra's pattern).
  • readResource: resolve path against the skill directory, reject any resolved path that escapes the skill dir (path-traversal guard), apply the optional line range, sniff binary (\0) and refuse, and cap size with a truncation suffix (mirror the workspace tool-result truncation contract).

AgentConfig integration — packages/core/src/types/agent.ts

ts
/** Provider, or an array of in-code definitions (sugar for inCodeSkillProvider). */
skills?: SkillProvider | SkillDefinition[];

defineAgent validates: if an array, validate each SkillDefinition via the Zod schema (name regex, description length, reserved-word check — name may not be/contain "anthropic"/"claude" per the open standard); if a provider, accept as-is. The type-erased AnyAgentConfig alias is unaffected.

Level 1 — catalog injection into the system prompt

New helper generateSkillsSystemPromptFragment(skills: SkillMetadata[]): string in packages/core/src/skills/system-prompt-fragment.ts, mirroring generateWorkspaceSystemPromptFragment:

  • Sort by name (deterministic → byte-stable across steps/turns → cache hit). Empty list → returns '' (no-op).
  • Render the guardrail + an XML catalog (verbose-in-prompt per opencode's finding):
## Skills

You have specialized Skills available. A Skill is NOT a tool: to use one, call
the `load_skill` tool with its `name` to load its full instructions, then follow
them. Load a Skill proactively whenever a task matches its description — you do
not need to ask permission. After loading, use `read_skill_file` to read any
resource files it references (references/, scripts/, assets/) on demand.

<available_skills>
  <skill>
    <name>pdf-processing</name>
    <description>Extract text and tables from PDFs, fill forms, merge documents.
      Use when working with PDF files or when the user mentions PDFs or forms.</description>
  </skill>

</available_skills>

Wired into buildMessagesForLLM via a new BuildMessagesOptions.skillsCatalog?: string field, appended to finalPrompt after the workspace fragment (so ordering is stable: completion-instruction → workspace → skills). Because buildMessagesForLLM must stay synchronous, the catalog string is resolved once per run (async listSkills()) by the runtime — at the same place memory retrieval is resolved (where IO/non-determinism is allowed; on Temporal/DBOS that is the per-step activity/step, not workflow code) — and threaded in as a plain string. In-code catalogs are deterministic data and are safe to resolve in workflow code directly.

Level 2 — load_skill tool (auto-injected)

Injected by buildEffectiveTools (state-operations.ts) when agent.skills is present, alongside __finish__ / companion__* / workspace_*. Marked _isSkillLoadTool: true on the Tool interface (mirroring _isFinishTool). Tool name load_skill (reserved — see below).

execute({ name }, ctx):

  1. provider.getSkill(name).
  2. Unknown → return an error result listing the available skill names (so the model can self-correct).
  3. Otherwise → return the body wrapped for clarity, plus the resource listing and a usage hint:
    <skill name="pdf-processing">
    …full SKILL.md body…
    </skill>
    <skill_resources skill="pdf-processing">
      references/forms.md
      scripts/extract.py
    </skill_resources>
    To read a resource file, call read_skill_file with skill="pdf-processing" and
    the relative path. To run a script, use your shell/workspace tools.
  4. ctx.emit('skill_loaded', { name }) for the stream/UI.

Idempotency / re-loads (v1). ToolContext exposes getState/updateState/ emit/workspaces but not the message transcript, so programmatic "already-loaded" suppression cannot live inside the tool cleanly. v1 therefore returns the body on every load_skill call — which is correct (append-only, cache-safe) and rarely triggered because the model can see its own prior load_skill results in context. The catalog guardrail tells the model "each skill only needs to be loaded once; its instructions remain available above." Programmatic re-load suppression is deferred (Future work) — it needs the dispatch layer (which does have state.messages) to short-circuit, not the tool.

Tracking building blocks (shipped in v1, used opportunistically).

  • New metadata key COMMON_METADATA_KEYS.SKILL_INJECTION: 'skillInjection', paired with a skillIds: string[] field — mirrors MEMORY_INJECTION.
  • collectLoadedSkillNames(messages) in packages/core/src/skills/skill-injection.ts — derives the loaded-skill set from the transcript (scan AssistantMessage.toolCalls for load_skill calls, read args.name, confirm a non-TOOL_FAILED ToolResultMessage by toolCallId). Pure, recovery-safe, replay-deterministic.
  • Stamping { [SKILL_INJECTION]: true, skillIds: [name] } onto load_skill result messages (recognized via _isSkillLoadTool) is a fast-follow, not v1. The key + collectLoadedSkillNames ship now so UI/compaction work and the stamping task have a stable target; stamping itself lands once the cross-runtime tool-result construction seam is traced. It is non-essential — the loaded-skill set is derivable from the load_skill tool calls themselves, not the stamp.

Level 3 — read_skill_file tool (auto-injected)

Marked _isSkillReadTool: true. Name read_skill_file (reserved).

execute({ skill, path, startLine?, endLine? }, ctx):

  • provider.readResource(skill, path, { startLine, endLine }).
  • Unknown skill/path or traversal-rejected → error result.
  • Success → wrap in <skill_file skill="…" path="…">…</skill_file> with the same untrusted-content disclaimer the workspace fragment uses (filesystem content is untrusted), and the same truncation contract.

read_skill_file results are ordinary tool messages → append-only → cacheable. No metadata tagging needed (only load_skill bodies are tracked for dedup).

Reserved tool names & how the skill tools are constructed

Add a RESERVED_TOOL_NAMES = ['load_skill', 'read_skill_file'] guard in defineTool (alongside the existing RESERVED_TOOL_NAME_PREFIXES for subagent__ / companion__). User tools cannot shadow the skill tools. We use plain readable names rather than a skill__ prefix because model ergonomics matter here (see Open decisions for the alternative).

The injected skill tools themselves are built via an internal factory (createSkillTools(provider) returning direct Tool objects), not the public defineTool — exactly as createFinishTool / createSubAgentTool bypass the public builder. Otherwise the reserved-name guard would reject the framework's own tools.

Safety property worth noting: skill names are constrained to [a-z0-9-] (no underscores), so a skill name can never collide with the load_skill / read_skill_file tool names. The "Skills are NOT tools" guardrail handles the conceptual confusion; the charset handles the literal one.

Prompt caching — additive by construction, zero strategy changes

  • Catalog (Level 1) lives in the system block. anthropicCache already stamps cacheControl on the last system message. The catalog is deterministically sorted and stable within a session → it stays in the cached prefix and never invalidates it (provided the available-skill set is stable; see edge cases for dynamic skills). The catalog MUST NOT be annotated with per-skill loaded-state (e.g. "✓ loaded") — that would make the prefix volatile and bust the cache every time a skill loads. "Already loaded" handling lives entirely in the load_skill tool result, never in the catalog.
  • Bodies (Level 2) and resources (Level 3) arrive as tool-result messages = append-only history, covered by anthropicCache's rolling latest/previous-turn breakpoints.
  • As-built caveat (inherited from memory injection): on the turn a body lands, the rolling latest-turn breakpoint sits on/after it, so that one breakpoint won't cache-hit across that turn boundary; the system/tools/ previous-turn breakpoints still do. Acceptable and identical to memory's behavior.

No change to anthropicCache, applyCacheStrategies, or the Vercel adapter. If a future need arises for skill-specific breakpoint placement, the clean extension is a new CacheStrategy, not loop edits.

Cross-runtime coverage

All logic is in shared core (buildEffectiveTools, buildMessagesForLLM, skill-injection dedup) plus a small per-run catalog-resolution hook in each runtime (the same location as memory retrieval). Therefore:

RuntimeIn-code providerFilesystem provider
JS
Temporal✅ (resolve catalog + tool reads in activities)
DBOS✅ (resolve in steps)
Cloudflare DO/Workflows❌ (no node:fs; use in-code or a future workspace/D1 provider)

Determinism note (Temporal/DBOS): load_skill / read_skill_file run as tool activities/steps, so their filesystem IO is fine. Catalog resolution for the filesystem provider must happen where IO is allowed (the per-step activity/step), never in workflow code; in-code catalogs are deterministic data and need no special handling.

Package & export surface

  • core: types/skill.ts, skills/in-code-provider.ts, skills/system-prompt-fragment.ts, skills/skill-injection.ts; new SKILL_INJECTION metadata key; _isSkillLoadTool / _isSkillReadTool markers; skills field on AgentConfig. Exports: defineSkill, inCodeSkillProvider, SkillProvider, Skill, SkillMetadata, SkillDefinition, SkillResourceRef.
  • @helix-agents/skill-fs (new): fileSystemSkillProvider.
  • sdk: re-export defineSkill, inCodeSkillProvider, and skill types (filesystem provider is an opt-in separate import, like store-redis).

What changes

AreaChange
core/src/types/skill.ts (new)SkillProvider, Skill, SkillMetadata, SkillResourceRef, SkillDefinition, Zod schemas.
core/src/skills/in-code-provider.ts (new)inCodeSkillProvider, defineSkill.
core/src/skills/system-prompt-fragment.ts (new)generateSkillsSystemPromptFragment (sorted, guardrail, XML).
core/src/skills/skill-injection.ts (new)collectLoadedSkillNames(messages) dedup helper.
core/src/types/agent.tsAdd skills? field; validate in defineAgent.
core/src/types/tool.tsAdd _isSkillLoadTool / _isSkillReadTool markers; RESERVED_TOOL_NAMES.
core/src/types/state.tsAdd COMMON_METADATA_KEYS.SKILL_INJECTION.
core/src/orchestration/message-builder.tsAdd BuildMessagesOptions.skillsCatalog?; append after workspace fragment.
core/src/orchestration/state-operations.ts (buildEffectiveTools)Inject load_skill + read_skill_file when agent.skills present; closure over provider; memoize like workspaceTools.
Runtimes (JS / Temporal / Cloudflare / DBOS)Resolve the catalog string once per run (where IO is allowed) and pass via skillsCatalog; stamp SKILL_INJECTION on load_skill results. Mostly threading; logic is in core.
packages/skill-fs (new package)fileSystemSkillProvider (Node fs + YAML).
sdkRe-export in-code skill API + types.
llm-vercel, anthropicCache, applyCacheStrategiesUnchanged.

Edge cases

  • Empty skill set / no skills field: fragment is '', no tools injected — total no-op (matches workspace behavior).
  • Unknown skill name in load_skill: error result enumerating available names; not a hard failure.
  • Re-loading an already-loaded skill: short "already active" result; body not re-dumped (token + cache friendly).
  • Path traversal / binary / oversized resource in read_skill_file: rejected / truncated per the workspace tool-result contract.
  • name ≠ directory name (fs): warn + skip that skill at discovery.
  • Duplicate skill names across roots (fs): last-root-wins with a warn (note: v1 keeps this simple; a stricter identity model is future work).
  • Dynamic skill set (provider returns different skills across turns): the catalog changes → cache prefix invalidates that turn. v1 assumes a stable per-session skill set; document that providers should be stable within a session for cache hits (request-context-tiered skills are future work).
  • Compaction: v1 stamps SKILL_INJECTION metadata but does not yet re-attach dropped bodies on compaction; if a body is compacted away the model re-calls load_skill (dedup no longer sees it → body returns again). Full budget-based reattachment is future work.
  • Sub-agents: a sub-agent with its own skills gets its own catalog + tools; no inheritance from the parent in v1.

Testing

  • Validation: SkillDefinition / frontmatter Zod (name regex, ≤64; desc ≤1024; reserved-word "anthropic"/"claude" rejected).
  • In-code provider: list (sorted), get, readResource (string + lazy loader, line range).
  • Filesystem provider: discovery, frontmatter parse, staleness cache (no re-scan within cooldown), path-traversal guard, line ranges, binary refusal, name≠dir skip.
  • Catalog rendering: deterministic sort (byte-identical across calls → cache-stability assertion), guardrail text present, XML well-formed, empty → ''.
  • buildMessagesForLLM: catalog appended after workspace fragment; idempotent across repeated calls.
  • buildEffectiveTools: load_skill + read_skill_file injected iff agent.skills; reserved-name collision rejected by defineTool.
  • load_skill: returns wrapped body + resource listing; dedup (2nd load → "already active"); unknown → error with available list; stamps SKILL_INJECTION metadata; emits skill_loaded.
  • read_skill_file: reads resource, range honored, traversal blocked, unknown path → error, untrusted wrapper present.
  • Caching: with a skill loaded mid-conversation, assert the system/tools/ previous-turn cache breakpoints are unchanged (prefix not invalidated); catalog stays in the cached system block.
  • Cross-runtime parity (e2e/integration): in-code skills load + drive a multi-step turn on JS / Temporal / Cloudflare / DBOS; filesystem skills on JS / Temporal / DBOS. (Reuse the existing cross-runtime parity harness.)

Versioning

minor. Additive: new optional AgentConfig.skills field, new core exports, new @helix-agents/skill-fs package (initial 0.x release), new COMMON_METADATA_KEYS.SKILL_INJECTION. No breaking changes. Changeset with an "Observable behavior changes" note: agents with skills now auto-inject load_skill / read_skill_file tools and a ## Skills system-prompt section.

Resolved decisions (confirmed at spec review)

  1. New package name: @helix-agents/skill-fs (dir packages/skill-fs).
  2. Tool names: load_skill / read_skill_file (readable names) + a RESERVED_TOOL_NAMES guard. (Not the skill__-prefixed variant.)
  3. allowed-tools: parse-and-carry only in v1. The field is validated and exposed on SkillMetadata but NOT wired into the permission/approval layer; enforcement is a follow-up (see Future work).
  4. Description budget: defer — v1 includes every skill's full description verbatim in the catalog. A per-skill char cap + least-used-drop is future work for very large libraries.
  5. Catalog format: hard-code XML in v1 (matches opencode/Mastra default; no format knob yet).
  6. Sub-agent skills: a sub-agent uses skills only if its own config declares them; it does not inherit the parent's catalog. Matches how sub-agents already scope their own tools/state.

Future work (explicitly deferred)

  • Claude-Code-compatible auto-discovery (~/.claude/skills, ./.claude/skills) — trivial: a fileSystemSkillProvider pointed at those roots.
  • Workspace-backed provider (read skills + run scripts through the workspace abstraction; enables skills on Cloudflare + true Level-3 script execution).
  • search_skills semantic/BM25 tool for very large libraries (Mastra's triad).
  • Compaction reattachment of skill bodies within a token budget.
  • Request-context-tiered / dynamic skill sets (per-user/tenant), with explicit cache-invalidation accounting.
  • Authoring lint that flags workflow-summarizing descriptions (enforce the "triggers-only" rule Anthropic's testing established).

Released under the MIT License.