Skip to main content

Tutorial: Build a Skill

What you'll build

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

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:

  1. Compose — mix and match capabilities per conversation
  2. Budget — fit within token limits via knapsack selection
  3. Test — verify each skill in isolation
  4. 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(&params.location)).await {
return Ok(ToolResult::Json(cached));
}

// 3. Call the weather API
let weather = self.fetch_weather(&params).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(&params.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:

BudgetWeather (430)Search (300)Calculator (100)Expected Selection
1000All three
500Weather + Calculator
400Search + Calculator
150Calculator only
50None
#[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

FieldTypeRequiredDescription
nameStringUnique skill identifier
versionSemVerSkill version
descriptionStringHuman-readable description
token_costusizeEstimated token cost of prompt + tool schemas
priorityu32Selection weight (higher = preferred)
prompt.textStringPrompt fragment injected into system prompt
tools[Tool]Tool definitions (name, description, parameters)
dependencies.requires[String]Required skill names
parametersMapRuntime-configurable parameters
tags[String]Tags for filtering

What's Next?