Tutorial: Build a Skill
A weather lookup skill with a prompt fragment, tool binding, dependency declaration, and tests. You'll see how the token-budget knapsack selector picks (or skips) your skill at runtime.
Prerequisites
- Completed Build a Provider tutorial
- Basic understanding of LLM tool calling
What Is a Skill?
A skill is a composable unit of agent behavior, defined as:
$$ \text{Skill} = \text{Prompt Fragment} + \text{Tool Bindings} + \text{Parameters} + \text{Dependencies} $$
Why Skills?
Instead of a monolithic system prompt that grows without bound, skills let you:
- Compose — mix and match capabilities per conversation
- Budget — fit within token limits via knapsack selection
- Test — verify each skill in isolation
- Version — update tool implementations without touching the prompt
Step 1: Define the Skill in TOML
Create a new skill definition file:
File: skills/weather.toml
[skill]
name = "weather"
version = "1.0.0"
description = "Look up current weather conditions for a given location."
author = "ClawDesk Team"
# Token cost estimate for the prompt fragment + tool schema
token_cost = 280
# Priority weight (higher = more likely to be selected)
priority = 50
# Tags for filtering
tags = ["utility", "information", "weather"]
[prompt]
# The prompt fragment injected into the system prompt when this skill is active
text = """
You have access to a weather lookup tool. When the user asks about weather \
conditions, current temperature, forecasts, or related topics, use the \
`get_weather` tool to fetch real-time data. Always include the temperature, \
conditions, and humidity in your response. Format temperatures in both \
Celsius and Fahrenheit.
"""
[[tools]]
name = "get_weather"
description = "Get current weather conditions for a location."
[tools.parameters]
type = "object"
required = ["location"]
[tools.parameters.properties.location]
type = "string"
description = "City name, optionally with country code (e.g., 'London, UK')"
[tools.parameters.properties.units]
type = "string"
enum = ["celsius", "fahrenheit"]
default = "celsius"
description = "Temperature unit preference"
[dependencies]
# This skill depends on the 'geocoding' skill for location resolution
requires = ["geocoding"]
# But does NOT require internet access to be declared separately —
# the tool implementation handles network calls internally.
[parameters]
# Runtime parameters that can be overridden per-agent
api_provider = "openweathermap"
cache_ttl_seconds = 300
Step 2: Implement the Tool
Tool implementations live in clawdesk-skills/src/tools/:
// clawdesk-skills/src/tools/weather.rs
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Tool, ToolContext, ToolError, ToolResult};
/// The weather lookup tool implementation.
pub struct WeatherTool {
client: reqwest::Client,
api_key: String,
cache_ttl: std::time::Duration,
}
/// Input parameters (deserialized from JSON).
#[derive(Debug, Deserialize)]
pub struct WeatherInput {
pub location: String,
#[serde(default = "default_units")]
pub units: String,
}
fn default_units() -> String {
"celsius".to_string()
}
/// Weather data returned by the API.
#[derive(Debug, Serialize)]
pub struct WeatherData {
pub location: String,
pub temperature_c: f64,
pub temperature_f: f64,
pub conditions: String,
pub humidity: u32,
pub wind_speed_kmh: f64,
pub feels_like_c: f64,
}
#[async_trait]
impl Tool for WeatherTool {
fn name(&self) -> &str {
"get_weather"
}
async fn execute(
&self,
input: Value,
ctx: &ToolContext,
) -> Result<ToolResult, ToolError> {
// 1. Deserialize and validate input
let params: WeatherInput = serde_json::from_value(input)
.map_err(|e| ToolError::InvalidInput(e.to_string()))?;
// 2. Check cache first
if let Some(cached) = ctx.cache.get(&cache_key(¶ms.location)).await {
return Ok(ToolResult::Json(cached));
}
// 3. Call the weather API
let weather = self.fetch_weather(¶ms).await?;
// 4. Cache the result
let result = serde_json::to_value(&weather)
.map_err(|e| ToolError::Internal(e.to_string()))?;
ctx.cache.set(
&cache_key(¶ms.location),
&result,
self.cache_ttl,
).await;
// 5. Return structured result
Ok(ToolResult::Json(result))
}
}
impl WeatherTool {
pub fn new(api_key: String, cache_ttl: std::time::Duration) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
cache_ttl,
}
}
async fn fetch_weather(
&self,
params: &WeatherInput,
) -> Result<WeatherData, ToolError> {
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
params.location, self.api_key
);
let response: Value = self.client
.get(&url)
.send()
.await
.map_err(|e| ToolError::Network(e.to_string()))?
.json()
.await
.map_err(|e| ToolError::InvalidResponse(e.to_string()))?;
let temp_c = response["main"]["temp"]
.as_f64()
.ok_or(ToolError::InvalidResponse("Missing temperature".into()))?;
Ok(WeatherData {
location: params.location.clone(),
temperature_c: temp_c,
temperature_f: temp_c * 9.0 / 5.0 + 32.0,
conditions: response["weather"][0]["description"]
.as_str()
.unwrap_or("unknown")
.to_string(),
humidity: response["main"]["humidity"]
.as_u64()
.unwrap_or(0) as u32,
wind_speed_kmh: response["wind"]["speed"]
.as_f64()
.unwrap_or(0.0) * 3.6,
feels_like_c: response["main"]["feels_like"]
.as_f64()
.unwrap_or(temp_c),
})
}
}
fn cache_key(location: &str) -> String {
format!("weather:{}", location.to_lowercase())
}
Step 3: Declare Dependencies
The weather skill depends on the geocoding skill. The skill selector resolves this via topological sort:
If weather is selected, geocoding is automatically included. The total cost becomes:
$$ \text{total_cost}(\text{weather}) = 280 + 150 = 430 \text{ tokens} $$
Step 4: Register the Skill
Skills are registered via the SkillRegistry:
// In clawdesk-skills/src/registry.rs
pub fn register_skills(
registry: &mut SkillRegistry,
config: &SkillsConfig,
) -> Result<(), SkillError> {
// Load skill definitions from TOML files
for path in &config.skill_paths {
let definitions = load_skill_definitions(path)?;
for def in definitions {
registry.register(def)?;
}
}
// Register tool implementations
registry.register_tool(Box::new(WeatherTool::new(
config.weather_api_key.clone(),
Duration::from_secs(300),
)))?;
Ok(())
}
Step 5: Understand the Selector Algorithm
The skill selector solves a weighted 0/1 knapsack problem:
$$ \max \sum_{i=1}^{k} w_i \cdot x_i \quad \text{subject to} \quad \sum_{i=1}^{k} c_i \cdot x_i \leq B $$
Where:
- $w_i$ = priority weight of skill $i$
- $c_i$ = token cost of skill $i$
- $x_i \in {0, 1}$ = selected or not
- $B$ = remaining token budget
The selector uses a greedy approximation after topological sort:
/// In clawdesk-skills/src/selector.rs
pub fn select_skills(
available: &[SkillDefinition],
context: &NormalizedMessage,
budget: usize,
) -> Vec<SelectedSkill> {
// 1. Filter skills that match the current context
let candidates: Vec<_> = available
.iter()
.filter(|s| s.matches(context))
.collect();
// 2. Topological sort by dependencies
let sorted = topological_sort(&candidates);
// 3. Sort by priority/cost ratio (descending) — O(k log k)
let mut ranked: Vec<_> = sorted.into_iter().collect();
ranked.sort_by(|a, b| {
let ratio_a = a.priority as f64 / a.total_cost() as f64;
let ratio_b = b.priority as f64 / b.total_cost() as f64;
ratio_b.partial_cmp(&ratio_a).unwrap()
});
// 4. Greedy packing
let mut remaining = budget;
let mut selected = Vec::new();
for skill in ranked {
let cost = skill.total_cost(); // includes dependencies
if cost <= remaining {
remaining -= cost;
selected.push(SelectedSkill::from(skill));
}
}
selected
}
Step 6: Test the Skill
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
fn weather_skill() -> SkillDefinition {
SkillDefinition::from_toml(include_str!("../../../skills/weather.toml"))
.unwrap()
}
fn geocoding_skill() -> SkillDefinition {
SkillDefinition::from_toml(include_str!("../../../skills/geocoding.toml"))
.unwrap()
}
#[test]
fn skill_loads_from_toml() {
let skill = weather_skill();
assert_eq!(skill.name, "weather");
assert_eq!(skill.token_cost, 280);
assert_eq!(skill.tools.len(), 1);
assert_eq!(skill.tools[0].name, "get_weather");
}
#[test]
fn skill_includes_dependency_cost() {
let weather = weather_skill();
// total_cost = self (280) + geocoding dependency (150)
assert_eq!(weather.total_cost(), 430);
}
#[test]
fn selected_when_budget_sufficient() {
let skills = vec![weather_skill(), geocoding_skill()];
let msg = NormalizedMessage::test("What's the weather in London?");
let selected = select_skills(&skills, &msg, 500);
assert!(selected.iter().any(|s| s.name == "weather"));
assert!(selected.iter().any(|s| s.name == "geocoding"));
}
#[test]
fn skipped_when_budget_insufficient() {
let skills = vec![weather_skill(), geocoding_skill()];
let msg = NormalizedMessage::test("What's the weather?");
// Budget of 200 tokens — too small for weather (430 total)
let selected = select_skills(&skills, &msg, 200);
assert!(selected.is_empty());
}
#[test]
fn priority_ordering_respected() {
let high_priority = SkillDefinition {
name: "important".to_string(),
priority: 100,
token_cost: 200,
..Default::default()
};
let low_priority = SkillDefinition {
name: "optional".to_string(),
priority: 10,
token_cost: 200,
..Default::default()
};
let skills = vec![low_priority, high_priority];
let msg = NormalizedMessage::test("test");
// Budget only fits one skill
let selected = select_skills(&skills, &msg, 250);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].name, "important");
}
#[tokio::test]
async fn tool_execution_returns_weather_data() {
// Use a mock HTTP server for the weather API
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({
"main": {
"temp": 15.5,
"humidity": 72,
"feels_like": 14.0
},
"weather": [{"description": "partly cloudy"}],
"wind": {"speed": 5.2}
})
))
.mount(&server)
.await;
let tool = WeatherTool::new(
"test-key".to_string(),
Duration::from_secs(0), // no cache for tests
);
let input = serde_json::json!({
"location": "London, UK",
"units": "celsius"
});
let result = tool.execute(input, &ToolContext::test()).await.unwrap();
match result {
ToolResult::Json(data) => {
assert_eq!(data["temperature_c"], 15.5);
assert_eq!(data["conditions"], "partly cloudy");
assert_eq!(data["humidity"], 72);
}
_ => panic!("Expected JSON result"),
}
}
}
Token Budget Scenarios
Test the selector under various budgets to verify correct behavior:
| Budget | Weather (430) | Search (300) | Calculator (100) | Expected Selection |
|---|---|---|---|---|
| 1000 | ✅ | ✅ | ✅ | All three |
| 500 | ✅ | ❌ | ✅ | Weather + Calculator |
| 400 | ❌ | ✅ | ✅ | Search + Calculator |
| 150 | ❌ | ❌ | ✅ | Calculator only |
| 50 | ❌ | ❌ | ❌ | None |
#[test]
fn budget_scenarios() {
let skills = vec![
skill("weather", 50, 430),
skill("search", 40, 300),
skill("calculator", 30, 100),
];
let msg = NormalizedMessage::test("test");
// Budget 1000: all fit
let s = select_skills(&skills, &msg, 1000);
assert_eq!(s.len(), 3);
// Budget 500: weather (430) + calculator (100) = 530 > 500
// So: weather (430) fits, calculator doesn't fit after
// Actually weather alone at 430, remaining 70 — calc needs 100 → skip
let s = select_skills(&skills, &msg, 500);
assert_eq!(s.len(), 1); // just weather
// Budget 400: weather too big, search (300) + calc (100) = 400
let s = select_skills(&skills, &msg, 400);
assert_eq!(s.len(), 2);
// Budget 50: nothing fits
let s = select_skills(&skills, &msg, 50);
assert!(s.is_empty());
}
Skill Definition Reference
| Field | Type | Required | Description |
|---|---|---|---|
name | String | ✅ | Unique skill identifier |
version | SemVer | ✅ | Skill version |
description | String | ✅ | Human-readable description |
token_cost | usize | ✅ | Estimated token cost of prompt + tool schemas |
priority | u32 | ✅ | Selection weight (higher = preferred) |
prompt.text | String | ✅ | Prompt fragment injected into system prompt |
tools | [Tool] | ❌ | Tool definitions (name, description, parameters) |
dependencies.requires | [String] | ❌ | Required skill names |
parameters | Map | ❌ | Runtime-configurable parameters |
tags | [String] | ❌ | Tags for filtering |
What's Next?
- Type Algebra Deep Dive — understand the type system underpinning skills
- Context Assembly Deep Dive — how skills fit into the token budget alongside history and context
- Architecture Explorer — see where skills sit in the full system