Writing an OpenClaw skill: SKILL.md anatomy, secrets, and publishing
A skill is a directory with one required file and no build step — most of what
makes a skill work or fail to work is getting the frontmatter's gating fields right and knowing
that "call an API" means "tell exec to run curl," not a dedicated primitive.
On this page
SKILL.md anatomy
A skill is a directory (conventionally alongside any scripts it needs) containing a
SKILL.md with YAML frontmatter followed by a markdown body. The frontmatter is what
gates whether the skill even loads:
---
name: weather-alerts
description: Check a weather API and notify if a severe alert is active for a saved location.
version: 1.0.0
user-invocable: true
metadata:
openclaw:
requires:
env: [WEATHER_API_KEY]
bins: [curl, jq]
config: [ALERT_WEBHOOK_URL]
---
name and description are required; description is what
the agent's own skill-selection logic matches against, so it's worth writing as a precise
statement of when to reach for this skill rather than a generic one-liner. version and
user-invocable (whether a person can trigger it directly, versus the agent picking it
up implicitly) are optional but conventional. metadata.openclaw.requires is the actual
gate: env lists environment variables that must resolve for the
skill to be available at all; bins lists executables the skill's
instructions assume exist on the runtime's PATH (almost always curl, and
jq if the skill parses JSON responses itself rather than leaving that to the model);
config lists keys the skill expects to find set somewhere in
openclaw.json beyond a plain secret. A skill missing any of these simply doesn't show
up as available — there's no partial-degradation mode where it loads and fails at call time
instead.
The body: what the agent actually reads
Everything after the frontmatter is markdown the model reads as instructions, not documentation for a human maintainer (though it can double as both). Write it the way you'd write a runbook step for a person who has your tools but none of your context — concrete commands, concrete decision points, and an explicit stop condition rather than "use good judgment":
## Instructions
1. `curl -s "https://api.weather.example/v1/alerts?loc=$SAVED_LOCATION" \
-H "authorization: Bearer $WEATHER_API_KEY"` and parse the `alerts[]` array.
2. If any alert has `severity: "severe"` or higher, POST a summary to `$ALERT_WEBHOOK_URL`.
3. If the API call fails or times out, do not assume "no alert" — report the failure instead
of staying silent about it.
That last line matters more than it looks: a skill that fails closed on its own error paths is far less likely to produce a confident wrong answer than one whose instructions only ever describe the happy path.
How a skill calls an API: exec, not a primitive
There's no dedicated "declare an HTTP call" field in a skill — a skill calls out to an external
service by instructing the agent's exec (shell) tool to run curl, gated
by listing curl (and jq, if used) under requires.bins. This
is a deliberate simplicity tradeoff: it means any HTTP API is reachable from a skill with zero
new code, but it also means the skill's actual behavior lives in prose instructions the model has
to follow correctly each time, not in a typed function signature the runtime enforces. Write the
exact curl invocation in the body rather than describing it abstractly ("call the
weather API") — the more literally copy-pasteable the command is, the less room there is for the
model to improvise a slightly different one that behaves differently.
Secrets: SecretRefs and the sandbox caveat
Don't put an API key as a literal string in openclaw.json. Wire it as an
indirection object instead — a SecretRef that names where to actually resolve the
value from:
{
"skills": {
"entries": {
"weather-alerts": {
"apiKey": { "source": "env", "provider": "default", "id": "WEATHER_API_KEY" }
}
}
}
}
The caveat worth remembering: this form resolves against the Gateway process's own environment,
so it only works for host-process runs. A skill running inside a Docker sandbox
doesn't inherit that environment — the key has to be supplied via
sandbox.docker.env instead, a separate configuration path from the
SecretRef shown above. Decide which execution mode you're targeting before writing
your secrets config, since the two aren't interchangeable and a config that works host-process
will silently fail to resolve inside a sandbox. If you want the skill to never see the raw key at
all — only a per-run sentinel token that a local proxy swaps for the real credential at the HTTPS
layer — that's what the optional secret-egress proxy is for, worth the extra setup once a skill is
calling something more sensitive than a scoped, low-privilege endpoint.
Versioning
A new skill starts at 1.0.0 by convention — don't ship a placeholder
0.1.0 as your first public release; save that for a working directory you know
you'll bump before publishing. Bump the version whenever the frontmatter's gating fields or the
body's instructions change in a way that could alter behavior — ClawHub doesn't auto-detect a
meaningful change for you, and a stale version number showing the same value across several real
revisions is a bad signal to anyone deciding whether to trust an update.
Publishing to ClawHub: the checklist
clawhub skill publish ./weather-alerts \
--slug weather-alerts --owner your-name \
--categories automation,agents
Before running that:
- Pick a real slug. A short, generic-sounding name may already be taken — don't assume it's free.
- Categories: max 3, chosen from ClawHub's fixed list — not free text.
- Topics: max 5, free-form, in addition to categories.
- Expect an automated security review before install-surface visibility. A
skill whose only external call is
curlto one fixed host plus a single required secret should be a straightforward review, but budget for the delay between publish and it actually showing up for others to install — it isn't instant. - Decide your sandbox story deliberately rather than by accident — document whether you're targeting host-process runs, Docker-sandboxed runs, or both, since the secrets config differs between them as covered above.
One gotcha for cron/isolated sessions
If your skill's instructions ever call ask_user to get a human decision, know that
it's documented as main-session-only — a skill invoked from a cron job or a
--session isolated run has no chat surface to render a prompt into, and
ask_user simply has no audience there. See
OpenClaw cron jobs can't ask_user for the poll-by-id
pattern that works in both a live chat and an unattended cron run without a special-cased branch
for either.