This guide explains how to develop custom actions (skills) for OpenClaw, allowing you to extend its capabilities to interact with your specific systems and services.
Important: OpenClaw is primarily a TypeScript/JavaScript project. Custom actions should be written in TypeScript or JavaScript for native integration.
OpenClaw uses a skills/plugins system built on TypeScript/JavaScript. Custom actions can:
Official Approach: OpenClaw skills are TypeScript modules that export action definitions with metadata.
ClawHub: Browse and install community skills at ClawHub, the official OpenClaw skills registry. You can also publish your custom skills there for others to use.
First, ensure you have access to your OpenClaw skills directory:
# Navigate to your OpenClaw configuration directory
cd ~/.openclaw
# Create a skills directory if it doesn't exist
mkdir -p skills/custom
cd skills/custom
Initialize a TypeScript project:
npm init -y
npm install typescript @types/node --save-dev
npx tsc --init
Create a TypeScript file for your first skill:
// system-info.ts
/**
* Custom action to get system information
*/
import os from 'os';
export interface SystemInfo {
timestamp: string;
platform: string;
arch: string;
cpus: number;
memoryTotal: number;
memoryFree: number;
uptime: number;
hostname: string;
}
export const action = {
name: 'get_system_info',
description: 'Get system information including OS, CPU, memory, and uptime',
parameters: {
type: 'object',
properties: {},
required: [] as string[]
},
execute: async (): Promise<SystemInfo> => {
return {
timestamp: new Date().toISOString(),
platform: os.platform(),
arch: os.arch(),
cpus: os.cpus().length,
memoryTotal: os.totalmem(),
memoryFree: os.freemem(),
uptime: os.uptime(),
hostname: os.hostname()
};
}
};
export default action;
Compile the TypeScript file:
npx tsc system-info.ts --module commonjs --target es2020 --outDir .
More complex actions can accept parameters:
// file-operations.ts
/**
* Custom action for file operations
*/
import fs from 'fs/promises';
import path from 'path';
export interface FileReadResult {
success: boolean;
filePath: string;
content?: string;
size?: number;
error?: string;
}
export interface FileWriteResult {
success: boolean;
filePath: string;
message?: string;
error?: string;
}
export interface DirectoryListResult {
success: boolean;
directory: string;
items?: Array<{
name: string;
type: 'file' | 'directory';
size?: number;
modified?: string;
}>;
error?: string;
}
export const actions = [
{
name: 'read_file',
description: 'Read the content of a specified file',
parameters: {
type: 'object',
properties: {
file_path: {
type: 'string',
description: 'The path to the file to read'
}
},
required: ['file_path']
},
execute: async (params: { file_path: string }): Promise<FileReadResult> => {
try {
const content = await fs.readFile(params.file_path, 'utf-8');
const stats = await fs.stat(params.file_path);
return {
success: true,
filePath: params.file_path,
content,
size: stats.size
};
} catch (error) {
return {
success: false,
filePath: params.file_path,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
},
{
name: 'write_file',
description: 'Write content to a specified file',
parameters: {
type: 'object',
properties: {
file_path: {
type: 'string',
description: 'The path to the file to write'
},
content: {
type: 'string',
description: 'The content to write to the file'
}
},
required: ['file_path', 'content']
},
execute: async (params: { file_path: string; content: string }): Promise<FileWriteResult> => {
try {
// Ensure directory exists
const dir = path.dirname(params.file_path);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(params.file_path, params.content, 'utf-8');
return {
success: true,
filePath: params.file_path,
message: `Successfully wrote ${params.content.length} characters to file`
};
} catch (error) {
return {
success: false,
filePath: params.file_path,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
},
{
name: 'list_directory',
description: 'List files and directories in a specified path',
parameters: {
type: 'object',
properties: {
directory_path: {
type: 'string',
description: 'The directory path to list (defaults to current directory)'
}
},
required: []
},
execute: async (params?: { directory_path?: string }): Promise<DirectoryListResult> => {
try {
const dirPath = params?.directory_path || process.cwd();
const items = await fs.readdir(dirPath, { withFileTypes: true });
const fileList = await Promise.all(
items.map(async (item) => {
const itemPath = path.join(dirPath, item.name);
const stats = await fs.stat(itemPath);
return {
name: item.name,
type: item.isDirectory() ? 'directory' as const : 'file' as const,
size: item.isDirectory() ? undefined : stats.size,
modified: stats.mtime.toISOString()
};
})
);
return {
success: true,
directory: dirPath,
items: fileList
};
} catch (error) {
return {
success: false,
directory: params?.directory_path || 'unknown',
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
}
];
export default actions;
Here’s an example of integrating with an external API:
// weather-api.ts
/**
* Custom action for weather API integration
*/
interface WeatherInfo {
city: string;
country: string;
temperature: number;
feelsLike: number;
humidity: number;
description: string;
windSpeed: number;
}
interface WeatherError {
error: string;
message?: string;
details?: string;
}
export const action = {
name: 'get_weather',
description: 'Get current weather information for a specified city using OpenWeatherMap',
parameters: {
type: 'object',
properties: {
city: {
type: 'string',
description: 'The city name to get weather for'
},
units: {
type: 'string',
enum: ['metric', 'imperial', 'kelvin'],
description: 'Units for temperature (default: metric)',
default: 'metric'
}
},
required: ['city']
},
execute: async (params: { city: string; units?: string }): Promise<WeatherInfo | WeatherError> => {
const apiKey = process.env.OPENWEATHER_API_KEY;
if (!apiKey) {
return {
error: 'Weather API key not configured',
message: 'Set OPENWEATHER_API_KEY environment variable'
};
}
try {
const url = new URL('http://api.openweathermap.org/data/2.5/weather');
url.searchParams.append('q', params.city);
url.searchParams.append('appid', apiKey);
url.searchParams.append('units', params.units || 'metric');
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`API responded with status: ${response.status}`);
}
const data = await response.json();
return {
city: data.name,
country: data.sys.country,
temperature: data.main.temp,
feelsLike: data.main.feels_like,
humidity: data.main.humidity,
description: data.weather[0].description,
windSpeed: data.wind.speed
};
} catch (error) {
return {
error: 'Failed to fetch weather data',
details: error instanceof Error ? error.message : 'Unknown error'
};
}
}
};
export default action;
// secure-command.ts
/**
* Custom action for secure command execution with validation
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface CommandResult {
command: string;
returnCode: number;
stdout: string;
stderr: string;
error?: string;
}
interface CommandError {
error: string;
allowed?: string[];
details?: string;
}
// Define allowed commands (whitelist approach)
const ALLOWED_COMMANDS = ['ls', 'ps', 'df', 'free', 'whoami', 'pwd', 'uname', 'uptime'];
// Dangerous characters that could enable command injection
const DANGEROUS_CHARS = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r'];
export const action = {
name: 'secure_command',
description: 'Safely execute system commands from an allowed whitelist',
parameters: {
type: 'object',
properties: {
command: {
type: 'string',
description: 'The command to execute (must be in allowed list)'
},
args: {
type: 'array',
items: { type: 'string' },
description: 'Optional arguments for the command'
}
},
required: ['command']
},
execute: async (params: { command: string; args?: string[] }): Promise<CommandResult | CommandError> => {
const { command, args = [] } = params;
// Validate base command is in allowed list
if (!ALLOWED_COMMANDS.includes(command)) {
return {
error: `Command '${command}' not allowed`,
allowed: ALLOWED_COMMANDS
};
}
// Validate no dangerous characters in arguments
const allInput = [command, ...args].join(' ');
const hasDangerousChars = DANGEROUS_CHARS.some(char => allInput.includes(char));
if (hasDangerousChars) {
return {
error: 'Input contains potentially dangerous characters',
details: 'Command injection prevention triggered'
};
}
try {
// Build safe command with proper escaping
const safeArgs = args.map(arg => `'${arg.replace(/'/g, "'\\''")}'`).join(' ');
const fullCommand = `${command} ${safeArgs}`;
const { stdout, stderr } = await execAsync(fullCommand, {
timeout: 30000, // 30 second timeout
cwd: '/tmp', // Restrict working directory
maxBuffer: 1024 * 1024 // 1MB max output
});
return {
command: fullCommand,
returnCode: 0,
stdout,
stderr
};
} catch (error) {
const execError = error as Error & { code?: number; stderr?: string };
return {
command: `${command} ${args.join(' ')}`,
returnCode: execError.code || -1,
stdout: '',
stderr: execError.stderr || '',
error: execError.message
};
}
}
};
export default action;
Create an index file to export all your skills:
// index.ts
/**
* Custom Skills Registry
* Export all custom actions for OpenClaw
*/
export { action as systemInfo } from './system-info';
export { actions as fileOperations } from './file-operations';
export { action as weatherApi } from './weather-api';
export { action as secureCommand } from './secure-command';
// Export all actions as array for OpenClaw
import { action as systemInfo } from './system-info';
import { actions as fileOperations } from './file-operations';
import { action as weatherApi } from './weather-api';
import { action as secureCommand } from './secure-command';
export const allActions = [
systemInfo,
...fileOperations,
weatherApi,
secureCommand
];
export default allActions;
Update your OpenClaw configuration to include custom skills. Edit ~/.openclaw/openclaw.json:
{
"skills": {
"enabled": true,
"custom": {
"enabled": true,
"path": "~/.openclaw/skills/custom",
"allowUnsafe": false
}
}
}
Note: The exact configuration structure may vary. Check the official documentation for the current format.
Create a test file to validate your actions:
// test-actions.test.ts
/**
* Test suite for custom actions
*/
import { allActions } from './index';
describe('Custom Actions', () => {
describe('systemInfo', () => {
it('should return system information', async () => {
const systemInfoAction = allActions.find(a => a.name === 'get_system_info');
expect(systemInfoAction).toBeDefined();
if (systemInfoAction) {
const result = await systemInfoAction.execute();
expect(result).toHaveProperty('platform');
expect(result).toHaveProperty('cpus');
expect(result).toHaveProperty('memoryTotal');
}
});
});
describe('fileOperations', () => {
it('should list directory contents', async () => {
const listDirAction = allActions.find(a => a.name === 'list_directory');
expect(listDirAction).toBeDefined();
if (listDirAction) {
const result = await listDirAction.execute({ directory_path: '.' });
expect(result.success).toBe(true);
expect(result.items).toBeDefined();
}
});
});
});
Run tests with Jest or your preferred test runner:
npm install --save-dev jest @types/jest ts-jest
npx jest
After creating your custom skills:
# Compile TypeScript files
npx tsc
# Restart OpenClaw to pick up new skills
openclaw gateway restart
# Or if using Docker
docker compose restart openclaw-gateway
Always include comprehensive error handling:
async function robustAction(params: any): Promise<ResultType> {
try {
// Your action logic here
const result = await doSomething(params);
return { success: true, result };
} catch (error) {
if (error instanceof ValueError) {
return { success: false, error: `Invalid input: ${error.message}` };
}
if (error instanceof FileNotFoundError) {
return { success: false, error: `File not found: ${error.message}` };
}
return { success: false, error: `Unexpected error: ${error instanceof Error ? error.message : 'Unknown'}` };
}
}
Validate all inputs before processing:
function validateInput(input: unknown): ValidationResult {
if (typeof input !== 'string') {
return { valid: false, error: 'Input must be a string' };
}
const sanitized = input.trim();
if (sanitized.length > 1000) {
return { valid: false, error: 'Input too long (max 1000 characters)' };
}
const dangerousChars = [';', '&', '|', '`', '$'];
if (dangerousChars.some(c => sanitized.includes(c))) {
return { valid: false, error: 'Input contains invalid characters' };
}
return { valid: true, value: sanitized };
}
Be mindful of resource usage:
async function resourceConsciousAction(data: any): Promise<ResultType> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
try {
const result = await processData(data, { signal: controller.signal });
clearTimeout(timeoutId);
return { success: true, result };
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, error: 'Action timed out' };
}
throw error;
}
}
Use TypeScript interfaces for all data structures:
interface ActionResult<T> {
success: boolean;
result?: T;
error?: string;
}
interface ActionDefinition<P, R> {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, { type: string; description?: string }>;
required: string[];
};
execute: (params: P) => Promise<ActionResult<R>>;
}
allowUnsafe is properly configuredopenclaw gateway --verboseopenclaw logs or docker compose logs openclaw-gatewayagents.defaults.sandbox.mode: "all") for enhanced securityAny questions?
Feel free to contact us. Find all contact information on our contact page.