Agent Skills¶
RoomKit supports the Agent Skills open standard for packaging knowledge, instructions, and scripts into reusable skill bundles that AI channels can activate at runtime. This complements MCP (runtime tool integration) with a structured knowledge-packaging format adopted by Claude Code, Cursor, Gemini CLI, VS Code, and others.
No extra dependencies required — skills are built into the core package.
What are Agent Skills?¶
An Agent Skill is a directory containing a SKILL.md file with YAML frontmatter and a markdown body. Skills can optionally include scripts and reference files:
my-skill/
├── SKILL.md # Frontmatter + instructions
├── scripts/ # Optional executable scripts
│ └── run.sh
└── references/ # Optional reference documents
└── api-spec.md
The SKILL.md file follows this format:
---
name: my-skill
description: A brief description of what this skill does
license: MIT
---
# Instructions
Detailed instructions for the AI on how to use this skill...
Key rules:
namemust be kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$), 1-64 charsnamemust match the directory namedescriptionis required, max 1024 chars- Instructions body is standard markdown
Quick start¶
from roomkit import AIChannel
from roomkit.skills import SkillRegistry
from roomkit.providers.ai.mock import MockAIProvider
# 1. Discover skills from a directory
registry = SkillRegistry()
registry.discover("./skills") # scans subdirectories for SKILL.md
# 2. Pass to AIChannel
ai = AIChannel(
"ai-assistant",
provider=MockAIProvider(),
system_prompt="You are a helpful assistant.",
skills=registry,
)
# 3. That's it — the AI now has access to:
# - activate_skill(name) → load full instructions
# - read_skill_reference(...) → read reference files
When the AI channel processes an event, it automatically:
- Appends an
<available_skills>XML block to the system prompt - Registers
activate_skillandread_skill_referencetools - Intercepts skill tool calls and returns skill content
SkillRegistry¶
The registry discovers and manages skills. It uses a two-level loading strategy:
- Level 1 (discover/register) — Parses frontmatter only (~100 tokens). Lightweight enough for startup.
- Level 2 (get_skill) — Loads full instructions on demand and caches for subsequent access.
from roomkit.skills import SkillRegistry
registry = SkillRegistry()
# Scan one or more directories
count = registry.discover("./skills", "./extra-skills")
print(f"Found {count} skills")
# Or register individually
meta = registry.register("./skills/code-review")
# Access metadata (already loaded)
for meta in registry.all_metadata():
print(f" {meta.name}: {meta.description}")
# Load full skill on demand (cached after first call)
skill = registry.get_skill("code-review")
if skill:
print(skill.instructions)
print(skill.list_scripts())
print(skill.list_references())
Prompt XML generation¶
The registry generates a spec-compliant <available_skills> XML block for injection into the system prompt:
xml = registry.to_prompt_xml()
# <available_skills>
# <skill name="code-review">
# <description>Review code for bugs and style issues</description>
# </skill>
# <skill name="test-writer">
# <description>Generate test cases from source code</description>
# </skill>
# </available_skills>
Content is HTML-escaped to prevent prompt injection.
Visibility states¶
A registered skill is in one of three states, which differ in who can see it and who can activate it:
| State | In to_prompt_xml() |
activate_skill / get_skill() |
How it gets there |
|---|---|---|---|
| Available | Yes, in <available_skills> |
Yes | discover() / register() — the default |
| Unlisted | No | Yes | mark_unlisted(name) |
| Unavailable | Name only, in <unavailable_skills> with a reason |
No | mark_unavailable(name, reason) |
Unlisted keeps a skill activatable without advertising it. For catalogues where advertising every entry would drown the ones that matter: the host keeps quiet about a skill while any path that names it — a recommender nudge, a user asking for it — still activates it, which is what lets it earn its listing back.
registry.mark_unlisted("legacy-import")
registry.listed_names # what the prompt manifest shows
registry.skill_names # every registered skill, unlisted included
mark_unlisted on an unknown name is ignored — there is nothing to hide.
Unavailable removes a skill from use entirely but keeps its name and a
reason in the prompt, so the model can explain the gap instead of guessing at
a name that will answer "not found" — for example a requires gate dropping a
skill whose tools are not granted in this execution context:
registry.mark_unavailable("deploy-helper", "requires tools not granted here")
registry.get_unavailable_reason("deploy-helper")
registry.unavailable_skills # {name: reason}
The prompt block then carries both:
<available_skills>
...
</available_skills>
<unavailable_skills>
<skill name="deploy-helper">
<reason>requires tools not granted here</reason>
</skill>
</unavailable_skills>
Re-registering a skill clears either mark and makes it fully available again.
See the skill_visibility.py example for a runnable demo of all three states, including a recommender hook that surfaces an unlisted skill by name.
AIChannel integration¶
Pass the registry (and optionally a script executor) to AIChannel:
from roomkit import AIChannel
from roomkit.skills import SkillRegistry
registry = SkillRegistry()
registry.discover("./skills")
ai = AIChannel(
"ai-assistant",
provider=provider,
system_prompt="You are a helpful assistant.",
skills=registry,
# script_executor=my_executor, # optional — see Script Execution below
)
Auto-registered tools¶
| Tool | When available | What it does |
|---|---|---|
activate_skill(name) |
Always (when skills present) | First call in a conversation returns full instructions + lists scripts/references; later calls return a short ack (see Activation lifecycle) |
read_skill_reference(skill_name, filename) |
Always | Reads a file from the skill's references/ directory |
run_skill_script(skill_name, script_name, arguments) |
Only when script_executor is set |
Executes a script via the integrator's executor |
How it works¶
-
System prompt injection — The channel appends a preamble and
<available_skills>XML to the system prompt. If no script executor is configured, a note is added. -
Tool handler wrapping — The channel wraps the user's
tool_handlerwith an internal dispatcher that intercepts skill tool names and delegates everything else to the user handler. -
Streaming keeps working — a registry with at least one skill counts as a channel tool surface, so a streaming provider takes the streaming tool loop rather than dropping to a single non-streaming call. The model can call
activate_skilland still stream its answer.
Activation lifecycle¶
An activation lasts for the conversation, not for the turn that made it.
A skill body delivered as a tool result only survives the turn that fetched it: the
rebuilt AI context carries message events, not tool calls. Without a memory of the
activation, a model that keeps working on the same task re-calls activate_skill on
every turn and re-pays the whole body each time — on a 9 KB skill over three
exchanges, the reloading outweighs the skill itself.
So the channel records which skills a room activated, and the body moves to where a per-turn rebuild can carry it:
First activate_skill in a room |
Later calls | |
|---|---|---|
| Tool result | Full instructions + scripts + references |
{"ok": true, "already_active": true, ...} — no body |
| System prompt | (nothing yet — the prompt was composed before the call) | # Active skill instructions (binding rules) carrying the body |
| Gated tools | Revealed for the rest of the turn | Still revealed, no re-activation needed |
Consequences worth knowing:
- The ack is safe because the prompt carries the rules. Lose the record (process restart, a channel object replaced) and the prompt block goes with it, so the next activation returns the body again. The mechanism degrades to reloading; it never leaves the model holding an ack with no rules.
- The record is hydrated from the room's persisted tool-call history, so a channel swapped mid-conversation doesn't restart amnesic.
- Bodies are never truncated. Large tool results are normally evicted behind
read_stored_result; a skill's instructions are exempt, because binding rules reduced to a head/tail preview are not rules. References stay evictable — those are data, and paginating data is what eviction is for. - Four skills stay active per room, by recency. A fifth activation retires the
least recently used one, which simply means its next
activate_skillreturns the body again. skills_in_prompt=Falsedoes not disable this. That flag governs the catalogue — a host rendering its own<available_skills>manifest. Active bodies are runtime state a host cannot know, so they are injected either way.
Realtime voice channels run the same lifecycle per session. inline_full
preloads every body at connection time. on_demand advertises metadata and loads
only activated bodies: reconfigurable providers receive system instructions;
fixed providers receive the full instructions and reference inventory in the tool
result. Activation also returns the exact schemas of requires prerequisites
from the session's authorized catalogue. This metadata describes dependencies,
not permission grants; applications still resolve skill availability first.
For fixed providers, on_demand requires
provider.supports_context_preservation. The channel requests
provider_config={"preserve_context": True} and refuses unsupported providers.
Gemini Live disables sliding-window compression for these sessions and stops
with context_preservation_ended before reconnecting with uncertain history.
The existing error callback reports that termination; pending operations are
never replayed. Server duration limits still apply (roughly ten minutes per
connection, potentially less). Start a new session after an explicit stop.
voice = RealtimeVoiceChannel(
"voice", provider=gemini_provider, transport=transport,
skills=registry, skill_delivery_mode="on_demand",
)
The default stays inline_full for fixed providers and on_demand for native
reconfiguration. Gates open only after successful delivery. Fixed-provider gates
automatically enable Tool Search,
including for small catalogues; explicitly setting tool_search=False in that
configuration is an error. Activation never declares unsupported provider tools.
Skill bodies and references bypass the business-result length limit.
See examples/realtime_skills.py for a bounded Live session with the bundled
code-review skill, its unmodified reference, a trace and captured speech. Text
input in this example exercises the Live protocol rather than speech recognition.
Reading which skills are already loaded¶
skills_in_prompt=False hands the manifest to the host — and the catalogue is only
half of what a manifest needs. AIChannel.active_skill_names(room_id) supplies the
other half: the skills whose bodies that room is already carrying.
active = ai.active_skill_names("support-room") # {"code-review"}
rows = [
f"- {meta.name} ({'loaded' if meta.name in active else 'available'}): {meta.description}"
for meta in registry.all_metadata()
]
Without it every row reads available, including the skill whose instructions the
system prompt is already carrying — so the manifest pushes the model toward rules
that are in front of it. The model obeys: an activate_skill round answered by an
ack, and a user watching the same skill load twice. The same applies to anything
else a host writes to steer the model, such as a per-message nudge naming a skill.
It reports what is binding, not what was ever called: keyed on the room, empty for
a room that has activated nothing (and for None), and a skill retired by the
four-per-room limit drops out of it.
See the skill_active_manifest.py example, which renders the same room's manifest with and without it.
Combining with user tools¶
Skills work alongside user-defined tools from binding metadata:
await kit.attach_channel("support-room", "ai-assistant",
category=ChannelCategory.INTELLIGENCE,
metadata={
"tools": [
{"name": "lookup_order", "description": "Look up an order"},
],
},
)
# The AI now has: lookup_order + activate_skill + read_skill_reference
The user's tool_handler receives calls for user-defined tools; skill tools are handled internally.
Script execution¶
Script execution is intentionally left to the integrator — there is no default implementation. This ensures you control sandboxing, timeouts, and allowed interpreters.
The name comes from the model, so run_skill_script resolves it before your executor is called: a name that escapes the skill — including via a symlink planted in scripts/ — is refused and never reaches your code. Which file runs is the framework's call; how it runs is yours.
Use skill.resolve_script(script_name) to get that resolved path rather than joining skill.path / "scripts" / script_name yourself.
Implementing a ScriptExecutor¶
import asyncio
from roomkit.skills import ScriptExecutor, ScriptResult, Skill, SkillPathError
class SubprocessExecutor(ScriptExecutor):
"""Example executor using subprocess — customize for your security needs."""
async def execute(
self,
skill: Skill,
script_name: str,
arguments: dict[str, str] | None = None,
) -> ScriptResult:
try:
script_path = skill.resolve_script(script_name)
except (SkillPathError, FileNotFoundError) as e:
return ScriptResult(exit_code=1, stderr=str(e), success=False)
cmd = [str(script_path)]
if arguments:
for k, v in arguments.items():
cmd.extend([f"--{k}", v])
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(skill.path),
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
return ScriptResult(
exit_code=proc.returncode or 0,
stdout=stdout.decode(),
stderr=stderr.decode(),
success=proc.returncode == 0,
)
Passing to AIChannel¶
executor = SubprocessExecutor()
ai = AIChannel(
"ai-assistant",
provider=provider,
skills=registry,
script_executor=executor, # enables run_skill_script tool
)
When script_executor is set, the run_skill_script tool is added and the AI can execute scripts from skill directories.
Reference files¶
Skills can include reference documents in a references/ directory. The AI can read these via the read_skill_reference tool.
# In a skill directory:
# my-skill/references/api-spec.md
# my-skill/references/schema.json
skill = registry.get_skill("my-skill")
content = skill.read_reference("api-spec.md")
The filename must be a plain name inside the skill's references/ directory. It is resolved and checked for containment, so a symlink planted in references/ cannot serve a file from elsewhere — a clean-looking notes.md pointing at /etc/passwd is rejected. The directory itself is checked the same way: replacing all of references/ or scripts/ with a symlink is refused before anything inside it is listed, read or resolved, since a contained child of an escaped directory proves nothing. Violations raise SkillPathError, which subclasses ValueError.
Error handling¶
Parse and validation errors¶
discover() is strict by default: a missing directory or an invalid skill raises, and nothing is committed to the registry.
from roomkit.skills import SkillError, SkillRegistry
registry = SkillRegistry()
try:
count = registry.discover("./skills")
except SkillError as e:
raise SystemExit(f"Skills are misconfigured: {e}") from e
A malformed skill is a deployment error, not a runtime condition. Skipping it removes the capability from the catalogue while the agent keeps answering — the model is never told anything is missing, and neither is anyone reading the conversation. Failing at startup puts the error where someone reads it.
Pass strict=False when skills come from a source you do not control and a partial catalogue is genuinely better than no service:
Each failure keeps its own type, all deriving from SkillError:
from roomkit.skills import SkillDiscoveryError, SkillParseError, SkillValidationError
try:
registry.discover("./skills")
except SkillDiscoveryError as e:
print(f"Directory problem: {e}") # missing or unreadable directory
except SkillParseError as e:
print(f"Parse error: {e}") # missing frontmatter, etc.
except SkillValidationError as e:
print(f"Validation error: {e}") # bad name, missing description, etc.
Skill not found¶
When the AI calls activate_skill with an unknown name, the handler returns an error with the list of available skills so the AI can self-correct.
Configuration reference¶
SkillMetadata fields¶
| Field | Type | Required | Description |
|---|---|---|---|
name |
str |
Yes | Kebab-case identifier (1-64 chars) |
description |
str |
Yes | Brief description (1-1024 chars) |
license |
str \| None |
No | License identifier (e.g. "MIT") |
compatibility |
str \| None |
No | Compatibility hint |
allowed_tools |
str \| None |
No | Allowed tool patterns |
extra_metadata |
dict[str, str] |
No | Additional frontmatter keys |
AIChannel constructor params¶
| Param | Type | Default | Description |
|---|---|---|---|
skills |
SkillRegistry \| None |
None |
Registry of available skills |
script_executor |
ScriptExecutor \| None |
None |
Executor for skill scripts |
AIChannel methods¶
| Method | Returns | Description |
|---|---|---|
active_skill_names(room_id) |
set[str] |
Skills binding in that room right now — runtime state, for a host rendering its own manifest |
Example skill directory¶
skills/
├── code-review/
│ ├── SKILL.md
│ ├── scripts/
│ │ └── lint.sh
│ └── references/
│ └── style-guide.md
├── test-writer/
│ ├── SKILL.md
│ └── references/
│ ├── patterns.md
│ └── fixtures.md
└── deploy-helper/
├── SKILL.md
└── scripts/
├── check-status.sh
└── rollback.sh
See the agent_skills.py example for a complete runnable demo.