Workshops ... Part 6: The skill tool and prompt injection

Part 6: The skill tool and prompt injection

The loader can read skills. The agent still cannot use them. To connect the two, we expose one tool named skill and tell the model which skills exist.

This mirrors Claude Code and OpenCode at the same basic level. Skills are not magic. They are loaded through ordinary tool calling.

The SkillsTool wrapper

ToyAIKit discovers Python methods and turns them into tool schemas. Wrap the loader in a class with one public method:

class SkillsTool:
    """Wrapper for the skill loader that exposes skill() as a tool."""

    def __init__(self, skill_loader: SkillLoader):
        self.skill_loader = skill_loader

The tool method loads one skill by exact name:

    def skill(self, name: str) -> dict:
        """Load a skill to get specialized instructions.

        Args:
            name: The exact skill name to load.

        Returns:
            Dictionary with skill information including name, description, and content.
        """
        result = self.skill_loader.load_skill(name)
        return {
            "name": result.name,
            "description": result.description,
            "content": result.content,
        }

Create the tool wrapper and test it directly:

skills_tool = SkillsTool(skill_loader)
skills_tool.skill("hello")

This returns the same skill data as the loader. The difference is that ToyAIKit can now expose skill() to the model.

Add it to the existing tool registry:

tools_obj.add_tools(skills_tool)

Look at the tools:

tools_obj.get_tools()

You should now see the original coding tools plus the new skill tool.

The missing piece

Now the agent has a skill tool, but it still does not know when to call it. If you test with a greeting now, the model can respond normally without loading the hello skill because nothing tells it that the skill exists.

The agent needs a compact list of available skills. There are two common places to put that list:

  • In the skill tool description.
  • In the system or developer prompt.

OpenCode puts the list into the tool description. The workshop uses the prompt because it is simpler in ToyAIKit and worked better in the presenter's experiments.

Inject available skills into the prompt

Create a prompt fragment from the loader description:

SKILL_INJECTION_PROMPT = f"""
You have the following skills available which you can load with the skills tool:

{skill_loader.get_description()}
""".strip()

Append it to the base coding-agent instructions:

AGENT_WITH_SKILLS_INSTRUCTIONS = (
    AGENT_INSTRUCTIONS + "\n\n" + SKILL_INJECTION_PROMPT
)

Print it once:

print(AGENT_WITH_SKILLS_INSTRUCTIONS)

The prompt now contains the original general-purpose coding instructions plus the compact skill list:

You have the following skills available which you can load with the skills tool:

  - coding_standards: Use this when writing or reviewing code - provides project-specific coding standards
  - counter: Count things or list items with numbers
  - deploy_app: Deploy the application using deployment scripts and templates
  - hello: Skill for ALL greeting requests
  - joke: Skill for ALL joke requests

This is the lazy-loading tradeoff. The prompt includes only the name and description. The full body is loaded only after the model calls skill().

Run the skills-aware agent

Create a new runner with the updated prompt:

runner = OpenAIResponsesRunner(
    tools=tools_obj,
    developer_prompt=AGENT_WITH_SKILLS_INSTRUCTIONS,
    llm_client=llm_client,
    chat_interface=chat_interface,
)

Run it:

result = runner.run()

Ask for a greeting:

hello

Now the model should call the skill tool with {"name": "hello"} before answering. The sequence is:

  1. User says hello.
  2. Model sees the available skill descriptions.
  3. Model decides the hello skill matches.
  4. Model calls skill(name="hello").
  5. Python executes SkillsTool.skill().
  6. The skill content is added to the message history as a tool result.
  7. The model answers using the loaded instructions.

For your own agents, skill loading happens locally in your runner, like every other tool call. The model requests the tool. Your code runs the tool. The result goes back into context.

The prototype wrapper

The prototype version separates the wrapper into prototype/src/skill_tool.py:

from .skills import SkillLoader

class SkillToolsWrapper:
    """Wrapper class for the skill tool that toyaikit can discover."""

    def __init__(self, loader: SkillLoader):
        self._loader = loader

    def skill(self, name: str) -> dict:
        """Load a skill to get specialized instructions."""
        result = self._loader.load(name)
        return {
            "name": result.name,
            "description": result.description,
            "content": result.content,
        }

The prototype SkillLoader.description is more explicit than the notebook version. It tells the agent to check whether a skill matches before responding, and it reminds the model that the name parameter must exactly match one of the listed skills.

That extra wording exists because smaller models need more direct instructions. The same applies to slash commands: with gpt-4o-mini, be more explicit than you would be with a stronger model.

Continue with Part 7: Implementing commands to add the explicit user-facing command flow.

Questions & Answers (0)

Sign in to ask questions