Skip to content

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.

pip install roomkit

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:

  • name must be kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$), 1-64 chars
  • name must match the directory name
  • description is 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:

  1. Appends an <available_skills> XML block to the system prompt
  2. Registers activate_skill and read_skill_reference tools
  3. 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

  1. 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.

  2. Tool handler wrapping — The channel wraps the user's tool_handler with an internal dispatcher that intercepts skill tool names and delegates everything else to the user handler.

  3. 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_skill and 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_skill returns the body again.
  • skills_in_prompt=False does 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:

count = registry.discover("./skills", strict=False)  # logs warnings, returns valid count

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.