Writing a skill¶
A skill is a package of tools the SkillRegistry discovers under config/skills/: a
directory with a manifest.yaml that declares the skill and its tools, and a
tools.py that implements them. It is the lightest way to give the model a new tool;
a plugin (examples/02-governed-tool, docs/architecture/plugin-contract.md) is the
way to add anything more -- a deterministic handler, an agent, a job, a channel.
my-skill/
manifest.yaml # declares the skill, its tools, args, and prerequisites
tools.py # implements the tools
The manifest¶
governor_routeclassifies the capability for the governance kernel.system/read= pure/read-only compute;system/net= leaves the box (egress); pick the lightest route that is honest about what the tool does.args[].typeis one ofstring,int,number,enum,bool.enumargs takeoptions. Optional args takerequired: false+default.requiresis checked before loading: a missing package (packages), environment variable (env_vars), file (config_files), vault credential or Python version leaves the skill blocked rather than crashing the runtime. Each block is reported the same way: one INFO line, said once, naming what is missing (missing env FOO_KEY, never its value), andblocked: env:FOO_KEY(orpackage:,config:,credential:) iniris skills list.env_varsare read from the process environment;extranames theiris-harness[...]extra to install when a package is what is missing.
The tools¶
tools.py exposes one or more LangChain BaseTool subclasses and a module-level
SKILL_TOOLS list. Each tool has a Pydantic args_schema (validated before _run)
and returns a JSON-serializable result. Never raise into the agent loop: return an
error value instead.
A worked example: unit-converter¶
The simplest useful skill: pure computation, typed args, no network, no extra
packages. (tests/unit/iris_harness/tools/test_skills/test_skill_guide.py builds this
skill from the two blocks below and runs it, so they stay correct.)
# manifest.yaml
name: unit-converter
version: 0.1.0
description: Convert a value between units of the same kind (length or temperature).
author: you
license: Apache-2.0
tools:
- name: convert_units
description: Convert a numeric value from one unit to another of the same kind.
Length units (m, km, mi, ft) and temperature units (c, f, k) are supported;
converting across kinds (e.g. m -> c) is rejected.
# "system/read" is the lightest route: pure computation, no side effects, no egress.
governor_route: system/read
args:
- name: value
description: The numeric value to convert.
type: number
- name: from_unit
description: The unit the value is currently in.
type: enum
options: ["m", "km", "mi", "ft", "c", "f", "k"]
- name: to_unit
description: The unit to convert to (must be the same kind as from_unit).
type: enum
options: ["m", "km", "mi", "ft", "c", "f", "k"]
requires:
python: ">=3.12"
packages:
- pydantic>=2.0
env_vars: []
config_files: []
agents: []
# tools.py
from __future__ import annotations
from typing import Literal
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
Unit = Literal["m", "km", "mi", "ft", "c", "f", "k"]
# Length units as a factor to metres; temperatures go through Celsius.
_LENGTH_TO_M: dict[str, float] = {"m": 1.0, "km": 1000.0, "mi": 1609.344, "ft": 0.3048}
_TEMP_UNITS = {"c", "f", "k"}
class ConvertUnitsInput(BaseModel):
value: float = Field(description="The numeric value to convert.")
from_unit: Unit = Field(description="The unit the value is currently in.")
to_unit: Unit = Field(description="The unit to convert to (same kind).")
def _to_celsius(value: float, unit: str) -> float:
if unit == "f":
return (value - 32.0) * 5.0 / 9.0
return value - 273.15 if unit == "k" else value
def _from_celsius(celsius: float, unit: str) -> float:
if unit == "f":
return celsius * 9.0 / 5.0 + 32.0
return celsius + 273.15 if unit == "k" else celsius
def _convert(value: float, from_unit: str, to_unit: str) -> float:
if from_unit in _LENGTH_TO_M and to_unit in _LENGTH_TO_M:
return value * _LENGTH_TO_M[from_unit] / _LENGTH_TO_M[to_unit]
if from_unit in _TEMP_UNITS and to_unit in _TEMP_UNITS:
return _from_celsius(_to_celsius(value, from_unit), to_unit)
raise ValueError(f"cannot convert across unit kinds: {from_unit} -> {to_unit}")
class ConvertUnitsTool(BaseTool):
name: str = "convert_units"
description: str = (
"Convert a numeric value between units of the same kind. "
"Length: m, km, mi, ft. Temperature: c, f, k."
)
args_schema: type[BaseModel] = ConvertUnitsInput
def _run(self, value: float, from_unit: Unit, to_unit: Unit) -> dict[str, object]:
try:
result = _convert(value, from_unit, to_unit)
except ValueError as exc:
return {"error": str(exc)}
return {"value": round(result, 6), "unit": to_unit, "from": {"value": value, "unit": from_unit}}
async def _arun(self, value: float, from_unit: Unit, to_unit: Unit) -> dict[str, object]:
return self._run(value=value, from_unit=from_unit, to_unit=to_unit)
# The registry imports this list to discover the package's tools.
SKILL_TOOLS = [ConvertUnitsTool]
Install and test it¶
mkdir -p config/skills/builtin/unit-converter
# save the two files above into it, then restart the API
# (or rely on hot reload when IRIS_SKILL_HOTRELOAD is on)
The SkillRegistry discovers every manifest.yaml under config/skills/ (except the
quarantined auto/ tree) on IrisRuntime.startup(). To test a skill, load it the way
the registry does and call each tool's _run:
from iris_harness.tools.skills.loader import load_skill_manifest, load_skill_tool_classes
manifest = load_skill_manifest(skill_dir)
tool = load_skill_tool_classes(skill_dir)[0]()
assert tool._run(value=100, from_unit="km", to_unit="mi")["value"] == 62.137119