Building Custom OpenClaw Skills: Complete Developer Guide
OpenClaw ships with core skills for file operations, web browsing, and shell commands. But the real power comes from writing custom skills tailored to your workflow. Here's how to build, test, and deploy production-ready OpenClaw skills from scratch.
Skill Anatomy: What You're Building
An OpenClaw skill is a TypeScript module that exposes one or more tools the agent can invoke. Each tool has:
- Name: Unique identifier (e.g.,
send_slack_message) - Description: What the tool does (the LLM reads this to decide when to use it)
- Parameters: Input schema (JSON Schema format)
- Handler: Async function that executes the tool
Here's the minimal structure:
// skills/example-skill/index.ts
import { Skill, Tool } from '@openclaw/types';
const exampleTool: Tool = {
name: 'example_tool',
description: 'Does something useful',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Input data' }
},
required: ['input']
},
handler: async (params, context) => {
// Your logic here
return { result: 'success' };
}
};
const exampleSkill: Skill = {
name: 'example-skill',
version: '1.0.0',
tools: [exampleTool]
};
export default exampleSkill;Real Example: Slack Notification Skill
Let's build a production-ready skill that sends Slack notifications. This is a real skill I use daily.
Step 1: Project Setup
Create a new directory in your OpenClaw installation:
mkdir -p ~/.openclaw/skills/slack-notify
cd ~/.openclaw/skills/slack-notify
npm init -y
npm install @slack/web-apiCreate tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src/**/*"]
}Step 2: Write the Skill
Create src/index.ts:
import { Skill, Tool, ToolContext } from '@openclaw/types';
import { WebClient } from '@slack/web-api';
interface SlackParams {
channel: string;
message: string;
urgency?: 'normal' | 'urgent';
}
const sendSlackMessage: Tool = {
name: 'send_slack_message',
description: 'Send a message to a Slack channel. Use for notifications, alerts, or team updates.',
parameters: {
type: 'object',
properties: {
channel: {
type: 'string',
description: 'Slack channel name or ID (e.g., #general or C1234567890)'
},
message: {
type: 'string',
description: 'Message content (supports Slack markdown)'
},
urgency: {
type: 'string',
enum: ['normal', 'urgent'],
description: 'Message urgency (urgent = @channel mention)'
}
},
required: ['channel', 'message']
},
handler: async (params: SlackParams, context: ToolContext) => {
const { channel, message, urgency = 'normal' } = params;
// Validate Slack token exists
const slackToken = process.env.SLACK_BOT_TOKEN;
if (!slackToken) {
return {
status: 'error',
error: 'SLACK_BOT_TOKEN not set in environment'
};
}
try {
const client = new WebClient(slackToken);
// Format message with urgency prefix
const formattedMessage = urgency === 'urgent'
? `<!channel> ${message}`
: message;
// Send message
const result = await client.chat.postMessage({
channel,
text: formattedMessage,
mrkdwn: true
});
return {
status: 'success',
messageTs: result.ts,
channel: result.channel
};
} catch (error) {
// Handle Slack API errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
return {
status: 'error',
error: errorMessage,
hint: 'Check channel exists and bot has access'
};
}
}
};
const slackSkill: Skill = {
name: 'slack-notify',
version: '1.0.0',
description: 'Send notifications to Slack channels',
tools: [sendSlackMessage]
};
export default slackSkill;Step 3: Build and Register
Compile the TypeScript:
npm run build # or: tscRegister the skill in your OpenClaw config (~/.openclaw/config.yaml):
skills:
- path: ~/.openclaw/skills/slack-notify/dist/index.js
enabled: true
env:
SLACK_BOT_TOKEN: xoxb-your-token-hereRestart the gateway:
openclaw gateway restartKey Patterns for Production Skills
1. Parameter Validation
Always validate inputs before execution. The LLM can pass malformed data.
handler: async (params, context) => {
// Type validation
if (typeof params.channel !== 'string' || params.channel.length === 0) {
return {
status: 'error',
error: 'channel must be a non-empty string'
};
}
// Business logic validation
if (params.message.length > 4000) {
return {
status: 'error',
error: 'message exceeds Slack 4000 character limit'
};
}
// Proceed with execution
}2. Error Handling with Context
Don't just throw errors — return structured error objects with actionable hints.
try {
await dangerousOperation();
} catch (error) {
return {
status: 'error',
error: error.message,
hint: 'Check API credentials or network connectivity',
retryable: true // Agent can retry if true
};
}3. Secrets Management
Never hardcode API keys. Use environment variables or OpenClaw's secrets system.
// Bad
const apiKey = 'sk-1234567890abcdef';
// Good
const apiKey = process.env.API_KEY;
if (!apiKey) {
return { status: 'error', error: 'API_KEY not set' };
}
// Better (OpenClaw secrets)
const apiKey = context.secrets.get('api_key');4. Timeouts and Cancellation
Wrap long-running operations in timeouts. The agent might cancel mid-execution.
handler: async (params, context) => {
const timeout = context.timeoutMs || 30000; // 30s default
const result = await Promise.race([
longRunningOperation(params),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
return result;
}5. Logging and Observability
Log execution details for debugging. OpenClaw provides a logger via context.
handler: async (params, context) => {
context.log.info('Executing tool', { params });
const startTime = Date.now();
const result = await executeTask(params);
const duration = Date.now() - startTime;
context.log.info('Tool completed', { duration, result });
return result;
}Testing Your Skill
Write unit tests before deploying to production. Here's a Jest example:
// src/index.test.ts
import slackSkill from './index';
import ProductCTA from "@/components/ProductCTA";
import EmailCapture from "@/components/EmailCapture";
describe('slack-notify skill', () => {
const tool = slackSkill.tools.find(t => t.name === 'send_slack_message');
it('should validate required parameters', async () => {
const result = await tool.handler({}, mockContext);
expect(result.status).toBe('error');
});
it('should send message successfully', async () => {
const result = await tool.handler(
{ channel: '#test', message: 'Hello' },
mockContext
);
expect(result.status).toBe('success');
});
it('should handle API errors gracefully', async () => {
// Mock Slack API failure
const result = await tool.handler(
{ channel: '#invalid', message: 'Test' },
mockContext
);
expect(result.status).toBe('error');
expect(result.hint).toBeDefined();
});
});Run tests before every commit:
npm testAdvanced: Multi-Tool Skills
A skill can expose multiple related tools. Example: a GitHub skill with tools for creating issues, commenting, and merging PRs.
const githubSkill: Skill = {
name: 'github-integration',
version: '1.0.0',
tools: [
createIssueTool,
addCommentTool,
mergePRTool,
listReposTool
]
};Keep tools focused. One tool = one action. Don't build a "do_github_thing" tool that takes an "action" parameter — the LLM struggles with that pattern.
Deployment Best Practices
1. Version Your Skills
Increment the version in package.json and the skill object when you make changes. OpenClaw logs which version is loaded.
2. Document Tool Usage
Write clear descriptions. The LLM reads these to decide when to use your tool.
// Bad
description: 'Sends a message'
// Good
description: 'Send a message to a Slack channel. Use for notifications, alerts, or team updates. Supports Slack markdown formatting.'3. Handle Rate Limits
If your skill calls external APIs, respect rate limits. Implement exponential backoff:
async function callAPIWithRetry(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error.statusCode === 429 && i < maxRetries - 1) {
const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}4. Monitor Production Usage
Add metrics to track how often your skill is used and whether it succeeds:
handler: async (params, context) => {
context.metrics.increment('slack_notify.invocations');
const result = await sendMessage(params);
if (result.status === 'success') {
context.metrics.increment('slack_notify.success');
} else {
context.metrics.increment('slack_notify.errors');
}
return result;
}Real-World Examples
Here are skills I run in production:
- gmail-advanced: Send emails with attachments, labels, threading
- youtube-upload: Upload videos with metadata, thumbnails, playlists
- stripe-webhooks: Listen for payment events, update local database
- crm-contacts: Query/update SQLite contact database
- video-render: Invoke Remotion rendering pipeline
Each follows the patterns above: validation, error handling, timeout management, logging.
For more on integrating external services, see MCP Servers Explained: Connecting Google Workspace, GitHub, and Slack.
Publishing to ClawHub (Coming Soon)
OpenClaw will soon have a skill registry (ClawHub) where you can publish and discover community skills. To prepare your skill for publishing:
- Add a
README.mdwith usage examples - Include tests (aim for 80%+ coverage)
- Document required environment variables
- Add a license (MIT recommended)
- Tag releases in Git
Common Pitfalls
1. Overly Complex Tools
Don't build a Swiss Army knife tool with 20 parameters and 10 modes. Break it into separate tools. The LLM handles multiple simple tools better than one complex tool.
2. Ignoring Context
Use context.session to access session state, user info, and previous tool results. Don't make the LLM repeat information you already have.
3. Synchronous Operations
Always return Promises. Never block the event loop with sync operations (use fs.promises, not fs).
4. Leaking Sensitive Data
Sanitize return values. Don't include API keys, tokens, or internal IDs in tool responses.
// Bad
return { result, apiKey: process.env.API_KEY };
// Good
return { result };Next Steps
Start with a simple skill — something you do manually that you'd like automated. Build it following the patterns above, test it thoroughly, and iterate based on real usage.
For inspiration, check out 15 Skills I Use Daily to see what's possible.
If you're new to OpenClaw development, start with Setting Up Your First Agent on OpenClaw Playbook to get the basics down before diving into custom skills.
Get the OpenClaw Starter Kit
Complete config templates, 5 production-ready skills, setup checklist, cost calculator, and deployment scripts. Build faster, ship sooner.
Get the Starter Kit ($6.99) →Continue Learning
Ready to build?
Get the OpenClaw Starter Kit — config templates, 5 production-ready skills, deployment checklist. Go from zero to running in under an hour.
$14 $6.99
Get the Starter Kit →Also in the OpenClaw store
Get the free OpenClaw quickstart guide
Step-by-step setup. Plain English. No jargon.