Skip to content

Skills

AgentSkills.io-compatible packaged instruction bundles (SKILL.md files) that agents load on demand via progressive disclosure:

  • L1: the skill catalog (names + descriptions) appears in the system prompt.
  • L2: the agent activates a skill — full instructions are loaded.
  • L3: activation also appends a listing of the skill's resource files (scripts/, references/, assets/) — the first 20 by default (the max_resource_files argument of SkillsPlugin; AgentConfig.skills uses the default). A skill with no folder (path=None) has none. The listing gives paths, not contents, and the skills plugin registers no file reader. To open those files the agent needs a tool that can read them, such as a file or MCP read tool you register, or code execution (code_mode).

Attach via AgentConfig.skills=[skill_or_path, ...].

For the concepts, start with Skills.

Skill dataclass

Skill(name: str, description: str, instructions: str = '', path: Path | None = None, allowed_tools: list[str] | None = None, required_probes: list[RequiredProbe] = list(), min_tool_calls: int | None = None, max_tool_calls: int | None = None, metadata: dict[str, Any] = dict(), license: str | None = None, compatibility: str | None = None)

A skill — packaged instructions for an agent.

Skills follow the AgentSkills.io specification.

Example

skill = Skill( ... name="code-review", ... description="Review code for quality and security issues.", ... instructions="# Code Review\n1. Check error handling...", ... )

Load from filesystem

skill = Skill.from_file(Path("./skills/code-review"))

from_file classmethod

from_file(path: Path | str) -> Skill

Load a skill from a directory containing SKILL.md.

Parameters:

Name Type Description Default
path Path | str

Path to skill directory or SKILL.md file.

required

Returns:

Type Description
Skill

Loaded Skill instance.

Raises:

Type Description
FileNotFoundError

If SKILL.md not found.

ValueError

If required fields missing.

Source code in .sdk/src/tulip/skills/models.py
@classmethod
def from_file(cls, path: Path | str) -> Skill:
    """Load a skill from a directory containing SKILL.md.

    Args:
        path: Path to skill directory or SKILL.md file.

    Returns:
        Loaded Skill instance.

    Raises:
        FileNotFoundError: If SKILL.md not found.
        ValueError: If required fields missing.
    """
    path = Path(path)

    if path.name == "SKILL.md":
        skill_file = path
        skill_dir = path.parent
    elif path.is_dir():
        skill_file = path / "SKILL.md"
        skill_dir = path
    else:
        msg = f"Expected directory or SKILL.md file, got: {path}"
        raise FileNotFoundError(msg)

    if not skill_file.exists():
        msg = f"SKILL.md not found in {skill_dir}"
        raise FileNotFoundError(msg)

    content = skill_file.read_text(encoding="utf-8")
    return cls.from_content(content, path=skill_dir)

from_content classmethod

from_content(content: str, path: Path | None = None) -> Skill

Parse a skill from raw SKILL.md content.

Parameters:

Name Type Description Default
content str

Raw SKILL.md file content.

required
path Path | None

Optional filesystem path for resource resolution.

None

Returns:

Type Description
Skill

Parsed Skill instance.

Raises:

Type Description
ValueError

If required fields (name, description) missing.

Source code in .sdk/src/tulip/skills/models.py
@classmethod
def from_content(cls, content: str, path: Path | None = None) -> Skill:
    """Parse a skill from raw SKILL.md content.

    Args:
        content: Raw SKILL.md file content.
        path: Optional filesystem path for resource resolution.

    Returns:
        Parsed Skill instance.

    Raises:
        ValueError: If required fields (name, description) missing.
    """
    frontmatter, body = _parse_frontmatter(content)

    name = frontmatter.get("name", "")
    description = frontmatter.get("description", "")

    if not name:
        msg = "SKILL.md missing required field: name"
        raise ValueError(msg)
    if not description:
        msg = "SKILL.md missing required field: description"
        raise ValueError(msg)

    # Parse allowed-tools (space-delimited string or list)
    allowed_tools_raw = frontmatter.get("allowed-tools")
    allowed_tools: list[str] | None = None
    if isinstance(allowed_tools_raw, str):
        allowed_tools = allowed_tools_raw.split()
    elif isinstance(allowed_tools_raw, list):
        allowed_tools = [str(t) for t in allowed_tools_raw]

    # `required-probes`: a list of {name, match} mappings.
    # Malformed entries are DROPPED rather than raising — a skill is a
    # document an operations person edits, and refusing to load the whole
    # thing because one probe lost its `match` would take the instructions
    # away too. A dropped probe is one fewer obligation; a failed load is no
    # procedure at all.
    probes: list[RequiredProbe] = []
    # Both spellings, because skill files mix them: `allowed-tools` is
    # hyphenated and `required_probes` often is not. Accepting one would
    # silently drop every probe written the other way.
    declared = frontmatter.get("required_probes") or frontmatter.get("required-probes") or ()
    for entry in declared:
        if not isinstance(entry, dict):
            continue
        probe_name = str(entry.get("name", "") or "").strip()
        probe_match = str(entry.get("match", "") or "").strip()
        if not probe_name or not probe_match:
            continue
        probes.append(
            RequiredProbe(
                name=probe_name,
                match=probe_match,
                description=str(entry.get("description", "") or ""),
            )
        )

    def _bound(*keys: str) -> int | None:
        for key in keys:
            value = frontmatter.get(key)
            if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
                return value
        return None

    return cls(
        name=name,
        description=description,
        instructions=body,
        required_probes=probes,
        min_tool_calls=_bound("min-tool-calls", "min_tool_calls"),
        max_tool_calls=_bound("max-tool-calls", "max_tool_calls"),
        path=path,
        allowed_tools=allowed_tools,
        metadata=frontmatter.get("metadata", {}),
        license=frontmatter.get("license"),
        compatibility=frontmatter.get("compatibility"),
    )

from_directory classmethod

from_directory(path: Path | str) -> list[Skill]

Load all skills from a parent directory.

Scans subdirectories for SKILL.md files.

Parameters:

Name Type Description Default
path Path | str

Parent directory containing skill subdirectories.

required

Returns:

Type Description
list[Skill]

List of loaded skills.

Source code in .sdk/src/tulip/skills/models.py
@classmethod
def from_directory(cls, path: Path | str) -> list[Skill]:
    """Load all skills from a parent directory.

    Scans subdirectories for SKILL.md files.

    Args:
        path: Parent directory containing skill subdirectories.

    Returns:
        List of loaded skills.
    """
    path = Path(path)
    skills: list[Skill] = []

    if not path.is_dir():
        msg = f"Not a directory: {path}"
        raise FileNotFoundError(msg)

    for child in sorted(path.iterdir()):
        if child.is_dir() and (child / "SKILL.md").exists():
            try:
                skills.append(cls.from_file(child))
            except (ValueError, FileNotFoundError):
                continue  # Skip invalid skills

    return skills

list_resources

list_resources(max_files: int = 20) -> list[str]

List resource files from scripts/, references/, assets/ directories.

Parameters:

Name Type Description Default
max_files int

Maximum number of files to list.

20

Returns:

Type Description
list[str]

List of relative file paths.

Source code in .sdk/src/tulip/skills/models.py
def list_resources(self, max_files: int = 20) -> list[str]:
    """List resource files from scripts/, references/, assets/ directories.

    Args:
        max_files: Maximum number of files to list.

    Returns:
        List of relative file paths.
    """
    if self.path is None:
        return []

    resources: list[str] = []
    for dir_name in _RESOURCE_DIRS:
        resource_dir = self.path / dir_name
        if resource_dir.is_dir():
            for f in sorted(resource_dir.iterdir()):
                if f.is_file() and not f.name.startswith("."):
                    resources.append(f"{dir_name}/{f.name}")
                    if len(resources) >= max_files:
                        return resources

    return resources

SkillsPlugin

SkillsPlugin(skills: list[Skill | str | Path], max_resource_files: int = 20, *, active: Iterable[str] = (), catalog: bool | None = None, enforce_allowed_tools: bool = False, show_paths: bool = True, router: SkillRouter | None = None, active_preamble: str = DEFAULT_ACTIVE_PREAMBLE, catalog_preamble: str = DEFAULT_CATALOG_PREAMBLE, render_skill: Callable[[Skill], str] | None = None, skill_footer: bool = True)

Bases: Plugin

Plugin that provides AgentSkills.io skill discovery and activation.

Injects a compact XML catalog of available skills into the system prompt. Registers a skills tool that the agent calls to load full instructions.

Example

from tulip.skills import Skill, SkillsPlugin

plugin = SkillsPlugin( ... skills=[ ... Skill.from_file("./skills/code-review"), ... Skill( ... name="summarize", description="Summarize text", instructions="..." ... ), ... ] ... )

agent = Agent( ... config=AgentConfig( ... model=model, ... plugins=[plugin], ... ) ... )

Initialize with skill sources.

Parameters:

Name Type Description Default
skills list[Skill | str | Path]

List of Skill instances, paths to skill directories, or paths to parent directories containing skills.

required
max_resource_files int

Max resource files to list per skill.

20
active Iterable[str]

Names of skills the host activates for the run up front. Their full instructions are sent with every model call (as a system message after the system prompt, never written to the conversation state). Unknown names raise ValueError.

()
catalog bool | None

Whether the model sees the L1 catalog and gets the skills tool to activate more skills itself. Defaults to True without active and False with it: a host that routes skills in code usually does not want the model to route too.

None
enforce_allowed_tools bool

Make the active skills' allowed-tools a hard limit. A tool call outside the union of the lists the active skills declare is cancelled before it runs, with a reason the model reads. Skills that declare no list add no tools; when no active skill declares one there is no limit. The skills tool itself is always allowed.

False
show_paths bool

Include each skill's filesystem location in the catalog and in activation responses. Set False for a server, where paths reveal the deployment layout and help nobody.

True
router SkillRouter | None

(text, run) -> skill names choosing the active skills PER RUN, in code: text is the run's latest user message and run its :class:~tulip.core.events.RunInfo (run.metadata is what agent.run(..., metadata=) passed). Sync or async. Asked once per run, before its first model call; what it returns replaces active for that run (None keeps active; an empty list means no skill). An unknown name is logged and ignored, and a router that raises leaves the run on active — a live turn is never lost to a routing mistake. The router cannot change the tools the model is offered; use enforce_allowed_tools to make the routed skills' lists bind.

None
active_preamble str

The text before the active skills' instructions.

DEFAULT_ACTIVE_PREAMBLE
catalog_preamble str

The text before the catalog.

DEFAULT_CATALOG_PREAMBLE
render_skill Callable[[Skill], str] | None

(skill) -> text rendering one active skill. The default is <skill name="..."> around its instructions and, with skill_footer, its metadata footer.

None
skill_footer bool

Whether the default rendering of an active skill ends with its Allowed tools / Compatibility / location footer and resource listing. The model never needs them when the host routes skills and enforces the tool list.

True
Source code in .sdk/src/tulip/skills/plugin.py
def __init__(
    self,
    skills: list[Skill | str | Path],
    max_resource_files: int = 20,
    *,
    active: Iterable[str] = (),
    catalog: bool | None = None,
    enforce_allowed_tools: bool = False,
    show_paths: bool = True,
    router: SkillRouter | None = None,
    active_preamble: str = DEFAULT_ACTIVE_PREAMBLE,
    catalog_preamble: str = DEFAULT_CATALOG_PREAMBLE,
    render_skill: Callable[[Skill], str] | None = None,
    skill_footer: bool = True,
) -> None:
    """Initialize with skill sources.

    Args:
        skills: List of Skill instances, paths to skill directories,
               or paths to parent directories containing skills.
        max_resource_files: Max resource files to list per skill.
        active: Names of skills the host activates for the run up front.
            Their full instructions are sent with every model call (as a
            system message after the system prompt, never written to the
            conversation state). Unknown names raise ``ValueError``.
        catalog: Whether the model sees the L1 catalog and gets the
            ``skills`` tool to activate more skills itself. Defaults to
            True without ``active`` and False with it: a host that routes
            skills in code usually does not want the model to route too.
        enforce_allowed_tools: Make the active skills' ``allowed-tools``
            a hard limit. A tool call outside the union of the lists the
            active skills declare is cancelled before it runs, with a
            reason the model reads. Skills that declare no list add no
            tools; when no active skill declares one there is no limit.
            The ``skills`` tool itself is always allowed.
        show_paths: Include each skill's filesystem location in the
            catalog and in activation responses. Set False for a server,
            where paths reveal the deployment layout and help nobody.
        router: ``(text, run) -> skill names`` choosing the active skills
            PER RUN, in code: ``text`` is the run's latest user message and
            ``run`` its :class:`~tulip.core.events.RunInfo` (``run.metadata``
            is what ``agent.run(..., metadata=)`` passed). Sync or async.
            Asked once per run, before its first model call; what it
            returns replaces ``active`` for that run (``None`` keeps
            ``active``; an empty list means no skill). An unknown name is
            logged and ignored, and a router that raises leaves the run on
            ``active`` — a live turn is never lost to a routing mistake.
            The router cannot change the tools the model is offered; use
            ``enforce_allowed_tools`` to make the routed skills' lists bind.
        active_preamble: The text before the active skills' instructions.
        catalog_preamble: The text before the catalog.
        render_skill: ``(skill) -> text`` rendering one active skill. The
            default is ``<skill name="...">`` around its instructions and,
            with ``skill_footer``, its metadata footer.
        skill_footer: Whether the default rendering of an active skill ends
            with its ``Allowed tools`` / ``Compatibility`` / location
            footer and resource listing. The model never needs them when
            the host routes skills and enforces the tool list.
    """
    self._skills: dict[str, Skill] = {}
    self._max_resource_files = max_resource_files
    self._show_paths = show_paths
    self._enforce = enforce_allowed_tools
    self._router = router
    self._active_preamble = active_preamble
    self._catalog_preamble = catalog_preamble
    self._render_skill = render_skill
    self._skill_footer = skill_footer
    # Per-run activation, keyed by run id; ``None`` is the bucket for calls
    # made outside any run (a tool called directly, an event built by hand).
    self._runs: dict[str | None, _RunSkills] = {}
    self._last_activated: list[str] = []

    for source in skills:
        if isinstance(source, Skill):
            self._skills[source.name] = source
        elif isinstance(source, (str, Path)):
            path = Path(source)
            if (path / "SKILL.md").exists():
                skill = Skill.from_file(path)
                self._skills[skill.name] = skill
            elif path.is_dir():
                for skill in Skill.from_directory(path):
                    self._skills[skill.name] = skill

    active_names = list(dict.fromkeys(active))
    unknown = [n for n in active_names if n not in self._skills]
    if unknown:
        available = ", ".join(sorted(self._skills)) or "none"
        msg = f"Unknown active skill(s): {', '.join(unknown)}. Available: {available}"
        raise ValueError(msg)
    self._active: tuple[str, ...] = tuple(active_names)
    self._last_activated = list(self._active)
    self._catalog = (not self._active and router is None) if catalog is None else catalog

activated_skills property

activated_skills: list[str]

Skills active in the most recent run (most recent activation last).

On a shared Agent serving runs at once this is whichever run touched its skills last; use :meth:allowed_tools with a run id, or a hook reading event.run, for one run's own view.

available_skills property

available_skills: list[str]

Get list of available skill names.

on_before_model_call async

on_before_model_call(event: Any) -> None

Inject the active skills and/or the catalog before each model call.

Only the messages sent to the model change; the run's state (and so every checkpoint) never holds them.

Source code in .sdk/src/tulip/skills/plugin.py
@hook
async def on_before_model_call(self, event: Any) -> None:
    """Inject the active skills and/or the catalog before each model call.

    Only the messages sent to the model change; the run's state (and so
    every checkpoint) never holds them.
    """
    from tulip.core.messages import Message

    current = await self._route(getattr(event, "run", None), list(event.messages))
    injected: list[Message] = []
    if current.active:
        injected.append(
            Message.system(self._active_preamble + self._active_instructions(current.active))
        )
    if self._catalog:
        catalog = self._generate_catalog_xml()
        if catalog:
            injected.append(Message.system(self._catalog_preamble + catalog))
    if not injected:
        return

    # Insert after the first system message (if any)
    messages = list(event.messages)
    insert_idx = 1 if messages and messages[0].role.value == "system" else 0
    messages[insert_idx:insert_idx] = injected
    event.messages = messages

allowed_tools

allowed_tools(run_id: str | None = None) -> frozenset[str] | None

The tools a run's active skills allow, or None when nothing limits them.

The union of the allowed-tools lists the run's active skills declare (routed, or active); None when no active skill declares one (or none is active). Also whatever the model activated itself through the skills tool in that run. run_id=None is the state outside any run, which starts from active.

Source code in .sdk/src/tulip/skills/plugin.py
def allowed_tools(self, run_id: str | None = None) -> frozenset[str] | None:
    """The tools a run's active skills allow, or None when nothing limits them.

    The union of the ``allowed-tools`` lists the run's active skills
    declare (routed, or ``active``); None when no active skill declares
    one (or none is active). Also whatever the model activated itself
    through the ``skills`` tool in that run. ``run_id=None`` is the state
    outside any run, which starts from ``active``.
    """
    current = self._runs.get(run_id)
    names = current.activated if current is not None else list(self._active)
    declared = [
        self._skills[n].allowed_tools
        for n in names
        if n in self._skills and self._skills[n].allowed_tools is not None
    ]
    if not declared:
        return None
    return frozenset(t for tools in declared for t in (tools or ()))

on_before_tool_call async

on_before_tool_call(event: Any) -> None

With enforce_allowed_tools, cancel calls the skills do not allow.

Source code in .sdk/src/tulip/skills/plugin.py
@hook
async def on_before_tool_call(self, event: Any) -> None:
    """With ``enforce_allowed_tools``, cancel calls the skills do not allow."""
    if not self._enforce or event.tool_name == self.ACTIVATION_TOOL:
        return
    run = getattr(event, "run", None)
    allowed = self.allowed_tools(run.run_id if run is not None else None)
    if allowed is None or event.tool_name in allowed:
        return
    event.cancel = (
        f"The tool {event.tool_name} is not available for this request. "
        f"Use one of: {', '.join(sorted(allowed)) or 'no tools'}."
    )

get_tools

get_tools() -> list[Any]

The skills activation tool when the catalog is on, else none.

(AgentConfig.skills registers it itself; this is what lets a configured plugin in AgentConfig.plugins work the same way.)

Source code in .sdk/src/tulip/skills/plugin.py
def get_tools(self) -> list[Any]:
    """The ``skills`` activation tool when the catalog is on, else none.

    (``AgentConfig.skills`` registers it itself; this is what lets a
    configured plugin in ``AgentConfig.plugins`` work the same way.)
    """
    return [self.get_activation_tool()] if self._catalog else []

get_activation_tool

get_activation_tool() -> Any

Create the skills activation tool.

Returns a Tool that the agent calls to load skill instructions.

Source code in .sdk/src/tulip/skills/plugin.py
def get_activation_tool(self) -> Any:
    """Create the skills activation tool.

    Returns a Tool that the agent calls to load skill instructions.
    """
    skills_dict = self._skills
    plugin = self

    @tool_decorator(
        name=self.ACTIVATION_TOOL,
        description="Activate a skill to load its instructions. "
        "Call with the skill name from the available_skills catalog.",
    )
    def skills(skill_name: str) -> str:  # noqa: ARG001
        """Load a skill's full instructions.

        Args:
            skill_name: Name of the skill to activate.
        """
        if not skill_name:
            return "Error: skill_name is required."

        skill = skills_dict.get(skill_name)
        if skill is None:
            available = ", ".join(sorted(skills_dict.keys()))
            return f"Unknown skill: '{skill_name}'. Available: {available}"

        # Track activation, in the calling run's own state.
        plugin._note_activation(skill_name)

        # Telemetry — opt-in. ``emit_sync`` no-ops outside an
        # active run_context, so SDK users who never enter one
        # pay nothing for this line.
        try:
            from tulip.observability.emit import EV_SKILL_ACTIVATED, emit_sync  # noqa: PLC0415

            emit_sync(
                EV_SKILL_ACTIVATED,
                skill_name=skill_name,
                has_resources=bool(skill.list_resources(max_files=1)),
                instructions_length=len(skill.instructions or ""),
            )
        except Exception:  # noqa: BLE001 — telemetry must never break the SDK
            pass

        return plugin._format_skill_response(skill)

    return skills

on_after_invocation async

on_after_invocation(state: Any, success: bool) -> None

Forget the run's skills once it ends (activated_skills keeps them).

Source code in .sdk/src/tulip/skills/plugin.py
@hook
async def on_after_invocation(self, state: Any, success: bool) -> None:  # noqa: ARG002
    """Forget the run's skills once it ends (``activated_skills`` keeps them)."""
    run_id = getattr(state, "run_id", None)
    entry = self._runs.pop(run_id, None)
    if entry is not None:
        self._last_activated = entry.activated

get_hooks

get_hooks() -> dict[str, Any]

Discover all @hook decorated methods.

Returns:

Type Description
dict[str, Any]

Dict mapping hook method names to bound methods.

Source code in .sdk/src/tulip/hooks/plugin.py
def get_hooks(self) -> dict[str, Any]:
    """Discover all @hook decorated methods.

    Returns:
        Dict mapping hook method names to bound methods.
    """
    hooks: dict[str, Any] = {}
    for attr_name in dir(self):
        if attr_name.startswith("_"):
            continue
        attr = getattr(self, attr_name, None)
        if attr is not None and callable(attr) and getattr(attr, "_is_hook", False):
            hooks[attr_name] = attr
    return hooks

init_agent

init_agent(agent: Any) -> None

Called when plugin is attached to an agent.

Override to perform setup that requires the agent instance.

Parameters:

Name Type Description Default
agent Any

The agent this plugin is being attached to.

required
Source code in .sdk/src/tulip/hooks/plugin.py
def init_agent(self, agent: Any) -> None:
    """Called when plugin is attached to an agent.

    Override to perform setup that requires the agent instance.

    Args:
        agent: The agent this plugin is being attached to.
    """