Skills
On-demand modular knowledge between role and tools. The framework's answer to: how do I keep multi-step procedures and domain knowledge out of the always-on system prompt without losing them when they're actually relevant?
The com.tnsai.skills package in tnsai-core ships the primitives:
Skill— record carrying a name, a description (the trigger phrase the resolver scores against), the markdown body, and per-skill scoping (allowedTools,userInvocable,disableModelInvocation).SkillStore— SPI for discovery; the framework shipsInMemorySkillStore(test seam) andFileSystemSkillStore(Claude Code-compatible<root>/<skill>/SKILL.mdlayout).SkillResolver— picks top-N candidates per user turn; defaults toKeywordSkillResolver(no extra round-trip), upgradeable toLLMSkillResolver(semantic match, one extra LLM call per turn).SkillManager— per-agent facade combining store + resolver + session state. Handles/skill-nameparsing, invocation source gating, and prompt-section rendering.SkillSession+ActiveSkill— runtime state tracking which skills the agent has activated this session.SkillScopedToolCallFilter—ToolCallFilterthat enforces the union ofallowed-toolsacross active skills.
Skills sit between the always-on role layer and the always-on tool layer:
| Layer | Loaded | Granularity |
|---|---|---|
| Role / CLAUDE.md | Always | Stable agent identity |
| Tool | Always | Atomic action |
| Capability | Always (compile-time) | Method-level declaration |
| RAG hit | Per query (similarity) | Evidence text |
| Skill | On demand (intent-matched) | Multi-step procedure |
| Hook | Always (system gate) | Cross-cutting policy |
This is the "Skills" layer in Claude Code's 5-layer architecture (CLAUDE.md / Skills / Hooks / Subagents / Plugins). TnsAI was missing it before TNS-289.
Why a separate layer
Three forces motivate skills as a distinct primitive:
- Token budget hygiene —
RoleSpecandCLAUDE.mdcontent rides every prompt. Procedures that only matter when invoked ("deploy procedure", "API design conventions", "customer escalation protocol") shouldn't pin tokens on every turn. - Author-time portability — Claude Code, Cursor, and other tools are converging on the
agentskills.ioSKILL.mdstandard. A skill authored once works across every framework that supports it. - Per-skill scoping —
allowed-toolsdeclares which tools a skill expects to use;userInvocableanddisableModelInvocationdeclare who may activate it. Both are recorded on the activation event and enforceable throughSkillScopedToolCallFilter.
Quick start
import com.tnsai.skills.*;
import com.tnsai.agents.AgentBuilder;
import java.nio.file.Path;
// 1. Pick a store. FileSystemSkillStore reads the Claude Code-compatible
// layout: <root>/<skill-name>/SKILL.md. Programmatic registration
// works through InMemorySkillStore for tests / embedded use.
SkillStore store = new FileSystemSkillStore(Path.of(".tnsai/skills"));
// 2. Wire on the AgentBuilder. Wiring a store auto-upgrades the
// policy from OFF to AUTO (the consumer's intent: "I configured
// skills, surface them"). Override with .skillResolverPolicy(...)
// if you want MANUAL_ONLY or OFF.
Agent agent = AgentBuilder.create()
.id("research-agent")
.llm(...)
.role(myRole)
.skillStore(store)
// accountability wiring (TNS-298) elided for brevity
.build();From this point on:
- The resolver runs on every chat turn against the registered skill descriptions; the top
maxActiveSkillscandidates appear in the per-message system prompt under## Skill candidates for this turn. - The user types
/deploy stagingto manually activate a skill — the framework intercepts BEFORE the LLM round-trip, so manual invocation costs zero LLM tokens. - Once activated, the skill's substituted body lives in the system prompt under
# Skills > ## Active skill bodiesfor the remainder of the session. agent.invokeSkill("name", args, env)lets framework code activate a skill programmatically, bypassinguserInvocable=false.
SKILL.md format
---
name: deploy
description: Production deploy procedure with rollback support
when-to-use: When the user asks to ship a build to prod or staging
allowed-tools:
- bash
- kubectl
argument-hint: <environment>
arguments:
- environment
disable-model-invocation: false
user-invocable: true
---
# Deploy procedure
1. Verify CI is green.
2. `kubectl apply -f manifests/$0/`The $0 placeholder gets substituted with the first positional argument when the skill is invoked. See Skill format for the full frontmatter reference.
Resolver policies
| Policy | Behaviour |
|---|---|
AUTO (default when a store is wired) | Resolver runs on every user turn; top-N candidate descriptions appear in the system prompt; the LLM may invoke via the synthetic invoke_skill tool; users may invoke via /skill-name. |
MANUAL_ONLY | Resolver does NOT run; only /skill-name and agent.invokeSkill(...) activate skills. Useful when the deployment wants deterministic skill loading. |
OFF | Skill layer disabled. Framework default when no store is wired. |
Lifecycle
- Discovery — store enumerates registered skills at startup. Only the
descriptionfield is in the always-on system prompt. - Resolution — per user message,
SkillResolverranks candidates by relevance. - Activation — user invocation or model tool call moves the full body into context for the rest of the session.
- Re-activation — invoking an active skill again replaces the previous snapshot; the latest invocation's substituted body wins.
SkillActivationEvent (sealed branch of TnsAIEvent) is emitted on every activation, carrying the source (USER_SLASH_COMMAND / MODEL_TOOL_CALL / PROGRAMMATIC), the skill name, and the supplied arguments.
Per-skill tool scope
When a skill declares allowed-tools, callers can attach SkillScopedToolCallFilter to the agent so tool calls outside the union of active-skill allowed-tools are blocked with a Guide action that names the active skills:
agent.setToolCallFilter(new SkillScopedToolCallFilter(agent.getSkillManager()));The filter is permissive when no active skill declares allowed-tools — the field is opt-in scoping, not a default constraint.
Subagent context fork
SkillManager.preloadFrom(parent) copies the parent's active-skill snapshots into a child manager so AgentGroup-spawned subagents inherit the procedural context their parent had loaded. Mirrors Claude Code's context: fork pattern. Parent's snapshot wins on name collision; the child's pre-existing activations are preserved on top.
What's not in this layer (deferred)
pathsglob auto-activation — file-context-aware activation; v2 (the v1 trigger surface is user-message-aware via the resolver)- Plugin distribution — packaging skills into the Plugins layer; tracked separately
context: forksemantics for full isolation — current preload is a copy, not a fork; v2- GUI skill registry — visual catalog in
TnsAI.Web; v3 - Live file-watcher — call
FileSystemSkillStore.refresh()instead
See also
- Skill format — full SKILL.md frontmatter reference and substitution rules
- Registration —
AgentBuilderAPI + custom resolvers + custom stores - Hooks — skill activation events flow through the hook bus
- Approvals and Annotations —
@ApprovalRequiredworks alongside skills (approvals gate access; skills supply the procedure) - Accountability —
SkillActivationEventrides the same trace as the resulting liability records