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.
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.