OpenClaw's 50+ built-in integrations cover the common cases. But the moment you want to connect to your company's internal API, query your own database, or automate something specific to your workflow — you need a custom skill.
This guide walks through building skills from scratch, from a simple API call to a full integration with error handling and parameters.
How Skills Work
A skill is a TypeScript module that exports a single object:
export const mySkill = {
name: "skill_name", // The identifier used in code
description: "...", // What the AI reads to decide when to use this
parameters: { ... }, // JSON Schema for inputs
execute: async (params) => { // The function that runs
// do something
return "result string";
}
};
OpenClaw passes the skill definition to the LLM alongside your message. When the LLM decides the skill is relevant, it calls execute() with the appropriate parameters and injects the result back into the conversation.
Your First Skill: Calling a REST API
Let's build a skill that fetches the current INR to USD exchange rate.
Create skills/exchange-rate.ts in your OpenClaw directory:
export const exchangeRateSkill = {
name: "get_exchange_rate",
description: "Get the current exchange rate between two currencies. Use this when the user asks about currency conversion, exchange rates, or how much something costs in another currency.",
parameters: {
type: "object",
properties: {
from: {
type: "string",
description: "Source currency code (e.g. USD, INR, EUR)",
},
to: {
type: "string",
description: "Target currency code",
},
amount: {
type: "number",
description: "Amount to convert. Defaults to 1 if not specified.",
default: 1,
},
},
required: ["from", "to"],
},
execute: async (params: { from: string; to: string; amount?: number }) => {
const { from, to, amount = 1 } = params;
try {
const response = await fetch(
`https://api.exchangerate-api.com/v4/latest/${from.toUpperCase()}`
);
if (!response.ok) {
return `Error: Could not fetch exchange rate for ${from}`;
}
const data = await response.json();
const rate = data.rates[to.toUpperCase()];
if (!rate) {
return `Currency code "${to}" not found.`;
}
const converted = (amount * rate).toFixed(2);
return `${amount} ${from.toUpperCase()} = ${converted} ${to.toUpperCase()} (rate: ${rate})`;
} catch (error) {
return `Failed to fetch exchange rate: ${error}`;
}
},
};
Register the skill in your OpenClaw config (openclaw.config.ts):
import { exchangeRateSkill } from "./skills/exchange-rate";
export const config = {
skills: [
exchangeRateSkill,
// ... other skills
],
};
Restart OpenClaw and test:
What's ₹5000 in USD right now?
The AI recognises the intent, calls get_exchange_rate with {from: "INR", to: "USD", amount: 5000}, and responds with the current rate.
Skills with Multiple Steps
A skill that checks if a website is up and measures response time:
import { execSync } from "child_process";
export const websiteCheckSkill = {
name: "check_website",
description: "Check if a website is up and measure its response time. Use when the user wants to test if a URL is reachable or measure site performance.",
parameters: {
type: "object",
properties: {
url: {
type: "string",
description: "The URL to check (include https://)",
},
},
required: ["url"],
},
execute: async (params: { url: string }) => {
const { url } = params;
const start = Date.now();
try {
const response = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(10000), // 10 second timeout
});
const latency = Date.now() - start;
const status = response.status;
const statusText = response.statusText;
if (response.ok) {
return `✅ ${url} is UP — HTTP ${status} in ${latency}ms`;
} else {
return `⚠️ ${url} returned HTTP ${status} (${statusText}) in ${latency}ms`;
}
} catch (error: any) {
const latency = Date.now() - start;
if (error.name === "TimeoutError") {
return `❌ ${url} timed out after ${latency}ms`;
}
return `❌ ${url} is DOWN — ${error.message}`;
}
},
};
Skills That Run Shell Commands
For power users who want OpenClaw to interact with their system directly:
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export const runCommandSkill = {
name: "run_shell_command",
description: "Run a safe shell command and return the output. Only use for read-only commands like git status, disk usage, process list, or file listing. Never use for commands that modify or delete files.",
parameters: {
type: "object",
properties: {
command: {
type: "string",
description: "The shell command to run",
},
working_directory: {
type: "string",
description: "Directory to run the command in. Defaults to home directory.",
},
},
required: ["command"],
},
execute: async (params: { command: string; working_directory?: string }) => {
const { command, working_directory = process.env.HOME } = params;
// Safety: block destructive commands
const blockedPatterns = ["rm ", "rmdir", "mkfs", "dd ", "format", "> /", "| bash"];
for (const pattern of blockedPatterns) {
if (command.toLowerCase().includes(pattern)) {
return `Blocked: command contains potentially destructive pattern "${pattern}". Confirm manually if intentional.`;
}
}
try {
const { stdout, stderr } = await execAsync(command, {
cwd: working_directory,
timeout: 15000, // 15 second timeout
});
const output = stdout.trim() || stderr.trim();
return output.length > 2000
? output.slice(0, 2000) + "\n... (truncated)"
: output;
} catch (error: any) {
return `Command failed: ${error.message}`;
}
},
};
Database Query Skill
A skill that queries your own database — useful for personal finance tracking, project management, or any structured data you maintain:
import Database from "better-sqlite3";
export const queryNotesSkill = {
name: "search_notes",
description: "Search through saved notes and tasks in the local database. Use when the user asks to find, recall, or list notes, tasks, or saved information.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "Search term to look for in notes",
},
limit: {
type: "number",
description: "Maximum number of results to return. Default 5.",
default: 5,
},
},
required: ["query"],
},
execute: async (params: { query: string; limit?: number }) => {
const db = new Database("./data/notes.db");
const { query, limit = 5 } = params;
try {
const rows = db
.prepare(
`SELECT title, content, created_at
FROM notes
WHERE title LIKE ? OR content LIKE ?
ORDER BY created_at DESC
LIMIT ?`
)
.all(`%${query}%`, `%${query}%`, limit) as any[];
if (rows.length === 0) {
return `No notes found matching "${query}"`;
}
return rows
.map((row) => `**${row.title}** (${row.created_at})\n${row.content.slice(0, 200)}`)
.join("\n\n---\n\n");
} finally {
db.close();
}
},
};
Writing Good Skill Descriptions
The description is the most important part of a skill. The LLM decides whether to use a skill entirely based on it.
Bad description:
"Fetches data from an API"
Good description:
"Get the current weather for a city, including temperature, conditions, humidity, and wind speed.
Use this when the user asks about weather, whether to bring an umbrella, or what to wear."
Guidelines for descriptions:
- State exactly what the skill does (first sentence)
- Give concrete examples of when to trigger it (second sentence)
- Mention what types of questions it answers
- Keep it under 100 words
Skill with Confirmation
For skills that take actions (not just read data), add a confirmation step:
export const sendEmailSkill = {
name: "send_email",
description: "Send an email. Always confirm with the user before sending — show them the recipient, subject, and body first.",
parameters: {
type: "object",
properties: {
to: { type: "string", description: "Recipient email address" },
subject: { type: "string", description: "Email subject" },
body: { type: "string", description: "Email body" },
confirmed: {
type: "boolean",
description: "Whether the user has confirmed they want to send this email",
default: false,
},
},
required: ["to", "subject", "body"],
},
execute: async (params: {
to: string;
subject: string;
body: string;
confirmed?: boolean;
}) => {
const { to, subject, body, confirmed = false } = params;
if (!confirmed) {
// Return a preview for the user to confirm
return (
`📧 Ready to send:\n\n` +
`**To:** ${to}\n` +
`**Subject:** ${subject}\n\n` +
`${body}\n\n` +
`Reply "send it" to confirm, or "cancel" to abort.`
);
}
// Actually send the email (using your email library of choice)
// await sendEmailViaGmail({ to, subject, body });
return `✅ Email sent to ${to}`;
},
};
The LLM calls the skill without confirmed, gets the preview, shows it to you, and only calls again with confirmed: true after you approve.
Publishing to the Community
To share your skill with other OpenClaw users:
- Create a repo:
openclaw-skill-[your-skill-name] - Export your skill from
index.ts - Add a
README.mdwith: what it does, setup requirements, example prompts - Submit a PR to the OpenClaw skills registry
Others can then install it:
openclaw skill install your-skill-name
Skill Development Tips
Test skills in isolation first — write a quick test script before integrating with OpenClaw:
// test-skill.ts
import { exchangeRateSkill } from "./skills/exchange-rate";
const result = await exchangeRateSkill.execute({ from: "INR", to: "USD", amount: 5000 });
console.log(result);
Log everything during development — add console.log inside execute() and watch the OpenClaw logs:
docker compose logs -f openclaw
Keep skills focused — one skill, one capability. A skill called handle_money_stuff is hard to trigger reliably. get_exchange_rate and check_bank_balance are separate, predictable skills.
Handle errors gracefully — the LLM will read your error message and decide what to tell the user. Return human-readable strings, not stack traces.