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:

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.