Your First Skill
A complete, working skill from scratch. About thirty minutes, fifty lines of Python.
In this tutorial you'll build a real skill — bike-trainer — that exposes two tools to the LLM: one to start a workout, one to report current stats. By the end, your persona can hold a conversation about your training.
We're not building anything fancy. The point is to walk every step of the loop: package layout, code, persona wiring, restart, talk.
The full source for this tutorial lives at examples/huxley-skill-bike-trainer/ in the Huxley repo. If you get stuck, peek at it.
The shape of a skill package
Every skill is a Python package. Mark this layout in your head — it doesn't change:
The package directory uses underscores. The PyPI/repo name uses hyphens. Both conventions exist; both are fine; pick a side and stay consistent. We'll use the underscore convention internally and the hyphen one externally.
Step 1: scaffold the package
From the Huxley repo root, create the skill alongside the others:
mkdir -p server/skills/bike_trainer/{huxley_skill_bike_trainer,tests}
cd server/skills/bike_trainerCreate pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "huxley-skill-bike-trainer"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["huxley-sdk"]
[project.entry-points."huxley.skills"]
bike_trainer = "huxley_skill_bike_trainer:BikeTrainerSkill"
[tool.uv.sources]
huxley-sdk = { workspace = true }
[tool.hatch.build.targets.wheel]
packages = ["huxley_skill_bike_trainer"]The crucial line is the entry point:
[project.entry-points."huxley.skills"]
bike_trainer = "huxley_skill_bike_trainer:BikeTrainerSkill"The key (bike_trainer) is what your persona will reference. The value (huxley_skill_bike_trainer:BikeTrainerSkill) is the import path Huxley uses to load it.
Add the new package to the workspace's root pyproject.toml so uv sync picks it up:
[tool.uv.workspace]
members = [
"server/runtime",
"server/sdk",
"server/skills/audiobooks",
# ...
"server/skills/bike_trainer", # add this
]Then uv sync. The skill is now installed in the workspace and discoverable via entry points.
Step 2: write the skill
Write the actual skill class:
import json
import time
from huxley_sdk import (
Skill,
ToolDefinition,
ToolResult,
SkillContext,
)
class BikeTrainerSkill:
name = "bike_trainer"
def __init__(self) -> None:
self._workout_started_at: float | None = None
self._distance_km: float = 0.0
@property
def tools(self) -> list[ToolDefinition]:
return [
ToolDefinition(
name="start_workout",
description=(
"Begin a new bike trainer session. Call this when the user "
"says they're starting to ride or wants to begin a workout."
),
parameters={"type": "object", "properties": {}},
),
ToolDefinition(
name="get_workout_stats",
description=(
"Report current workout stats: elapsed time and approximate "
"distance. Only meaningful if a workout has been started."
),
parameters={"type": "object", "properties": {}},
),
]
async def setup(self, ctx: SkillContext) -> None:
self._ctx = ctx
self._logger = ctx.logger
await self._logger.ainfo("setup", language=ctx.language)
async def handle(self, tool_name: str, args: dict) -> ToolResult:
if tool_name == "start_workout":
self._workout_started_at = time.time()
self._distance_km = 0.0
await self._logger.ainfo("workout_started")
return ToolResult(output=json.dumps({"started": True}))
if tool_name == "get_workout_stats":
if self._workout_started_at is None:
return ToolResult(output=json.dumps({
"active": False,
"message": "No workout in progress.",
}))
elapsed_s = time.time() - self._workout_started_at
# Stub: pretend distance is 25 km/h
self._distance_km = (elapsed_s / 3600) * 25
return ToolResult(output=json.dumps({
"active": True,
"elapsed_minutes": round(elapsed_s / 60, 1),
"distance_km": round(self._distance_km, 2),
}))
return ToolResult(output=json.dumps({"error": "unknown tool"}))
async def teardown(self) -> None:
await self._logger.ainfo("teardown")Five things worth noticing:
-
nameis a class attribute. Not a property. Not a method. The framework reads it before instantiating sometimes; making it a property would force needless construction. -
toolsis a property. Read fresh on each load — useful if your tool list depends on persona language or config. -
State lives on the instance.
_workout_started_atis just a Python attribute. The framework keeps your skill instance alive for the duration of the server process. -
Every
handlebranch returns aToolResult. Even errors. The model treats the return as data to narrate; if you return malformed shapes, the model gets confused. -
ctx.loggerfor everything. Don't useprintor the standardloggingmodule —ctx.loggerinjects turn IDs, namespaces events under your skill name (bike_trainer.workout_started), and integrates with the framework's structured log. We cover this in Cookbook: Observability.
Step 3: add it to a persona
Open or create a persona file:
# ... other config ...
skills:
audiobooks:
library: audiobooks
bike_trainer: {} # add this line
system: {}The empty {} means "no config." Skills read whatever config dict the persona gives them; if there's nothing to configure, an empty dict is fine.
You'll also want to update the system prompt so the model knows when to call your tools. Add a sentence:
system_prompt: |
...other instructions...
When the user says they're going to ride, starts a bike workout, or
asks about their stats, use the bike_trainer skill's tools.The model is good at picking tools by description, but a persona prompt nudge helps especially for new tools that don't match common patterns.
Step 4: restart and try it
cd server/runtime
uv run huxleyLook for bike_trainer.setup in the startup logs. If you see it, the skill is loaded.
In the PWA, hold the button and say:
Voy a salir a montar.
The model should call start_workout, get {"started": true} back, and narrate something like "¡Perfecto, comencé el entrenamiento!"
A few minutes later, hold and ask:
¿Cómo va el entreno?
It should call get_workout_stats and narrate the elapsed time and distance.
Step 5: write a test
Tests are easy because skills are just classes. Mock the SkillContext and call handle directly:
import json
import pytest
from unittest.mock import AsyncMock
from huxley_skill_bike_trainer import BikeTrainerSkill
@pytest.mark.asyncio
async def test_start_workout_returns_started():
skill = BikeTrainerSkill()
ctx = AsyncMock()
ctx.language = "es"
await skill.setup(ctx)
result = await skill.handle("start_workout", {})
assert json.loads(result.output) == {"started": True}
assert skill._workout_started_at is not None
@pytest.mark.asyncio
async def test_stats_when_no_workout_says_inactive():
skill = BikeTrainerSkill()
ctx = AsyncMock()
ctx.language = "es"
await skill.setup(ctx)
result = await skill.handle("get_workout_stats", {})
payload = json.loads(result.output)
assert payload["active"] is FalseRun with:
uv run --package huxley-skill-bike-trainer pytest server/skills/bike_trainer/testsTests against the framework (turn coordinator, focus manager) live in server/runtime/tests/. Tests against your skill's pure logic live in your skill's tests/ directory.
What you have now
A real, working, tested skill. It's small — two tools, no side effects, no persistence. But it's real: the model can call it, the turn coordinator dispatches it, the persona narrates the result.
Next, you'll layer in:
- Better tool design (parameters, descriptions that the model picks well).
- Side effects (chimes, audio streams, volume control).
- Proactive turns (the skill speaks first when something happens).
- Publishing it so others can use it.