Looking for an end-to-end tutorial on how to write a custom OpenClaw skill from scratch? This post walks through the exact file structure, YAML front-matter, natural-language prompts, tool wiring, local testing, and publishing. We’ll build a practical weather-checker skill you can drop into any OpenClaw agent running on your laptop or on ClawCloud.

Why write a skill instead of hard-coding logic?

Skills are self-contained capability packets. They can be loaded, unloaded, and versioned without touching your agent’s core prompt. You’ll want a skill when:

  • You need reusable functionality (e.g. fetch weather, parse RSS, post to Slack).
  • Different agents share the same capability set.
  • You plan to publish the skill so the community can extend it.

Under the hood, a skill is just a folder with a SKILL.md manifesto and optionally some JavaScript/TypeScript helpers. Because OpenClaw is written in Node 22+, you can keep everything in one repo and import NPM dependencies freely.

Skill project scaffold

Create a new directory and initialise an NPM project:

$ mkdir openclaw-skill-weather $ cd openclaw-skill-weather $ npm init -y # Node 22+ required

You should now have:

  • package.json
  • SKILL.md (we’ll create this next)
  • src/ (optional helper code)

Inside SKILL.md: YAML front-matter + plain English

OpenClaw parses SKILL.md exactly once at load time. The file has two parts:

  1. YAML front-matter (--- ... ---), machine-readable.
  2. Natural-language instructions (markdown body), large-language-model-readable.

OpenClaw merges the YAML into its registry and injects the markdown body into the agent prompt when the skill is active. Keep the YAML concise and the prose focused.

Minimal YAML schema

--- name: weather-checker version: 0.1.0 license: MIT author: "Jane Dev " description: "Return current weather for a given city using Open-Meteo API." tools: - id: getWeather runtime: node entry: src/getWeather.js timeout: 8000 # ms description: "Fetch temperature and conditions for a city." ---

Key fields you’ll almost always want:

  • name & version: semantic versioning. The gateway UI surfaces these.
  • tools: array of callable functions. Each tool needs an id, runtime (always node for now), an entry file, and a short human-readable description. Optional timeout, memory, schedule etc. follow the daemon schema.

Natural-language instructions

Under the closing ---, write plain instructions in the voice you want the agent to use. Example:

### Weather Checker Skill When a user asks about the weather, call getWeather with the requested city. Return the temperature in Celsius and a short condition summary (e.g. "12 °C, light rain"). If the city isn’t recognised, politely ask for clarification. Examples: - User: "weather paris" → call getWeather({ city: "Paris" }) - User: "temp ny" → call getWeather({ city: "New York" })

That’s it. No fancy prompt engineering. Keep examples succinct; the gateway automatically appends tool signature hints so the LLM can fill JSON correctly.

Implementing the tool in JavaScript

Create src/getWeather.js:

import fetch from "node-fetch"; /** * getWeather * @param {Object} input - { city: string } * @returns {Promise} - { city, temperature, condition } */ export default async function getWeather(input) { if (!input || !input.city) throw new Error("city is required"); // Simple free endpoint from open-meteo.com const query = new URLSearchParams({ latitude: 0, // placeholder, we’ll geocode next longitude: 0, current_weather: true }); // First geocode using Nominatim const geo = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(input.city)}`, { headers: { "User-Agent": "openclaw-weather-skill/0.1" } } ).then(r => r.json()); if (!geo.length) throw new Error(`Unknown city: ${input.city}`); const { lat, lon, display_name } = geo[0]; query.set("latitude", lat); query.set("longitude", lon); const weather = await fetch(`https://api.open-meteo.com/v1/forecast?${query}`) .then(r => r.json()); const { temperature, weathercode } = weather.current_weather; return { city: display_name.split(",")[0], temperature: `${temperature} °C`, condition: codeToText(weathercode) }; } function codeToText(code) { const map = { 0: "clear", 1: "mainly clear", 61: "rain" /* ... */ }; return map[code] || "unknown"; }

Dependencies:

$ npm install node-fetch@3

Because node-fetch is ESM-only, ensure your package.json contains "type": "module".

Local test loop

Before wiring to an agent, run the tool directly:

$ node src/getWeather.js <<'JSON' {"city": "Berlin"} JSON

Expect output similar to:

{ "city": "Berlin", "temperature": "13 °C", "condition": "clear" }

If everything works, add the skill to your local OpenClaw gateway.

Linking a skill folder into the gateway

$ clawctl skill link ./openclaw-skill-weather

Restart the daemon or hit reload in the UI. Ask your agent:

User: What's the weather in Tokyo?

You should see the tool call fire, then the agent’s answer.

Writing automated tests

Skills are code, so test them. I like Vitest because it runs ESM without hoops:

$ npm install -D vitest@1 $ npx vitest --init

Create tests/getWeather.test.js:

import getWeather from "../src/getWeather.js"; import { expect, test, vi } from "vitest"; // Mock network calls for speed and determinism vi.stubGlobal("fetch", async (url) => { if (url.includes("nominatim")) return { json: async () => [{ lat: 52.52, lon: 13.405, display_name: "Berlin" }] }; return { json: async () => ({ current_weather: { temperature: 42, weathercode: 0 } }) }; }); test("returns structured weather", async () => { const result = await getWeather({ city: "Berlin" }); expect(result.temperature).toBe("42 °C"); });

Run:

$ npx vitest run

Publishing your skill

There are two ways to share a skill:

  1. NPM package (recommended). Anyone can npm install then clawctl skill link ./node_modules/your-skill.
  2. Git URL. The gateway UI lets you paste a repo URL; it clones at --depth 1 and reads SKILL.md.

Prepare package.json

{ "name": "@jane/openclaw-skill-weather", "version": "0.1.0", "description": "OpenClaw skill: check weather via Open-Meteo", "main": "src/getWeather.js", "type": "module", "keywords": ["openclaw", "skill", "weather"], "peerDependencies": { "openclaw": ">=2.3.0" // adjust to current gateway version }, "files": ["SKILL.md", "src/"] }

Login and publish:

$ npm login $ npm publish --access public

Users can now:

$ npm install @jane/openclaw-skill-weather $ clawctl skill link node_modules/@jane/openclaw-skill-weather

Versioning & breaking changes

Once people depend on your skill, changing the tool signature is a breaking change. Bump major. Non-breaking tweaks (e.g. better error messages) go into minor. The gateway shows semver diff warnings before it updates.

Going further: scheduled runs & memory

Weather is a request/response skill. For an RSS monitor you’d add:

tools: - id: pollRSS runtime: node entry: src/pollRSS.js schedule: "*/15 * * * *" # every 15 min memory: rssMemory # named vector store bucket

The daemon will call pollRSS on schedule and you can write into rssMemory. The agent can later query past posts.

Security checkpoints

  • Never store API keys in the code. Expect the operator to set env vars and list them in docs/env.md.
  • Validate user input. LLM hallucinations can inject shell-like strings.
  • Set realistic timeout and memory limits in YAML so your skill can’t DoS the host.

OpenClaw uses the Node vm sandbox with network on by default. If you require local file reads, add permissions: ["fs"] under the tool entry.

Takeaway

That’s the full life-cycle: write SKILL.md, implement your tool, test, link locally, publish. The weather checker clocks in at ~80 lines of code and one markdown file. Once you’re comfortable, try chaining skills (weather + calendar) or exposing shell access for power users. Feedback and PRs welcome in the OpenClaw GitHub repo.