Chapter 24: Building Custom Skills
ClawHub has hundreds of community skills, but sometimes you need something that doesn't exist yet — your company's internal API, a proprietary data source, or a custom workflow. This chapter walks you through building a complete custom skill from scratch.
Skill Architecture
An OpenClaw skill is a Node.js package that exposes one or more tools following the Model Context Protocol (MCP). Each tool has:
- A name and description (used by the AI to decide when to call it)
- An input schema (JSON Schema defining the parameters)
- A handler function (the actual implementation)
When the AI decides to use a tool, OpenClaw calls your handler with the validated input and returns the result to the AI.
Project Setup
mkdir my-openclaw-skill
cd my-openclaw-skill
npm init -y
npm install @openclaw/skill-sdk
Minimal Skill Example
This skill adds a single tool that fetches weather data:
// index.js
const { defineSkill } = require('@openclaw/skill-sdk');
module.exports = defineSkill({
name: 'weather',
version: '1.0.0',
description: 'Get current weather for any city',
config: {
apiKey: { type: 'string', required: true, envVar: 'WEATHER_API_KEY' }
},
tools: [
{
name: 'get-weather',
description: 'Get the current weather and forecast for a city',
inputSchema: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'The city name, e.g. "Karachi" or "London, UK"'
},
units: {
type: 'string',
enum: ['metric', 'imperial'],
default: 'metric',
description: 'Temperature units'
}
},
required: ['city']
},
handler: async ({ city, units = 'metric' }, { config }) => {
const res = await fetch(
`https://api.weatherapi.com/v1/current.json?key=${config.apiKey}&q=${encodeURIComponent(city)}`
);
const data = await res.json();
if (data.error) {
return { error: data.error.message };
}
const temp = units === 'metric' ? data.current.temp_c : data.current.temp_f;
const unit = units === 'metric' ? '°C' : '°F';
return {
city: data.location.name,
country: data.location.country,
temperature: `${temp}${unit}`,
condition: data.current.condition.text,
humidity: `${data.current.humidity}%`,
wind: `${data.current.wind_kph} km/h`
};
}
}
]
});
Tool Design Best Practices
Write Clear Descriptions
The AI reads your tool name and description to decide whether to use it. Be specific:
// Bad
description: 'Get data'
// Good
description: 'Fetch customer order history from the internal orders API. Returns order IDs, dates, amounts, and status.'
Return Structured Data
Return objects rather than strings when possible — the AI can reason better over structured data:
// Less good
return `The weather in Karachi is 32°C and sunny`;
// Better
return {
city: 'Karachi',
temperature: '32°C',
condition: 'Sunny',
humidity: '65%'
};
Handle Errors Gracefully
Never throw uncaught exceptions — return an error object the AI can communicate to the user:
handler: async (input, ctx) => {
try {
const result = await callApi(input);
return result;
} catch (err) {
return { error: `Failed to fetch data: ${err.message}` };
}
}
Multi-Tool Skills
A single skill can expose many tools:
module.exports = defineSkill({
name: 'company-crm',
tools: [
{
name: 'search-customers',
description: 'Search for customers by name, email, or company',
inputSchema: { ... },
handler: async ({ query }, ctx) => { ... }
},
{
name: 'get-customer',
description: 'Get full details for a customer by their ID',
inputSchema: { ... },
handler: async ({ customerId }, ctx) => { ... }
},
{
name: 'create-note',
description: 'Add a note to a customer record',
inputSchema: { ... },
handler: async ({ customerId, note }, ctx) => { ... }
}
]
});
Accessing Context
The handler receives a second argument ctx with runtime context:
handler: async (input, ctx) => {
ctx.config // Your skill's config values
ctx.userId // The user who triggered this tool call
ctx.workspaceId // The current workspace
ctx.log // Logging: ctx.log.info(), ctx.log.error()
ctx.cache // Simple key-value cache (per-user, persists for session)
}
Using the Cache
handler: async ({ customerId }, ctx) => {
const cached = await ctx.cache.get(`customer:${customerId}`);
if (cached) return cached;
const customer = await fetchCustomerFromApi(customerId, ctx.config.apiKey);
await ctx.cache.set(`customer:${customerId}`, customer, { ttl: 300 });
return customer;
}
Testing Your Skill
// test.js
const skill = require('./index');
const { testSkill } = require('@openclaw/skill-sdk/testing');
testSkill(skill, {
config: { apiKey: 'test-key' },
tools: {
'get-weather': [
{
input: { city: 'Karachi' },
expect: (result) => {
console.assert(result.city === 'Karachi', 'City should be Karachi');
console.assert(result.temperature, 'Should have temperature');
}
}
]
}
});
Run tests:
node test.js
Installing Your Custom Skill Locally
# From the skill directory
openclaw hub install ./my-openclaw-skill
# Or install by path in config
{
"skills": {
"weather": {
"enabled": true,
"path": "/path/to/my-openclaw-skill",
"apiKey": "${WEATHER_API_KEY}"
}
}
}
Publishing to ClawHub
# Log in
openclaw hub login
# Verify package.json has "openclaw-skill" keyword
cat package.json | grep openclaw-skill
# Publish
openclaw hub publish
Your skill appears on hub.openclaw.dev within a few minutes after the automated security scan completes.
Next: Chapter 25 — API Integration — How to call OpenClaw's REST API programmatically to send messages, manage workspaces, and build integrations.