Building an OpenClaw agent skill
OpenClaw is an agent framework whose capabilities are extended through skills: folders under
~/.openclaw/workspace/skills/<name>/ containing a SKILL.md the agent reads at runtime, plus
optional executable code. This guide covers the recommended layout, frontmatter schema, and a
Python CLI pattern for implementing one.
Prerequisites
uvinstalled (used to run the skill’s Python CLI without manual venv management).- Python
>=3.12available touv.
Steps
1. Create the skill folder layout
skills/myskill/
├── SKILL.md
├── pyproject.toml
├── myskill_api.py # optional library (pure stdlib = no deps)
└── scripts/
├── __init__.py
└── cli.py # CLI entry point
2. Write SKILL.md frontmatter
---
name: myskill
description: "One-liner shown in the skills list."
version: "1.0.0"
pythonVersion: ">=3.12"
metadata:
openclaw:
emoji: "🔧"
primaryEnv: MY_API_KEY # credential highlighted in web UI
requires:
anyBins: [uv] # runtime binaries required
install:
- id: uv-install
kind: shell
command: "curl -LsSf https://astral.sh/uv/install.sh | sh"
bins: [uv]
label: "Install uv"
env: # shown as settings in the web UI
- name: MY_API_KEY
required: true
description: "API key for the service."
- name: MY_BASE_URL
required: false
description: "Base URL (optional, has a built-in default)."
---Use the top-level env: array for declaring settings, not metadata.openclaw.requires.env —
only the top-level form renders with descriptions and required/optional labels in the web UI.
3. Set up pyproject.toml
[project]
name = "myskill"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = [] # add e.g. "httpx>=0.27" if needed
[project.scripts]
myskill = "scripts.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["scripts"]4. Write the CLI entry point
A proper CLI invoked via uv run avoids quoting/escaping issues when the agent passes complex
content (Markdown, special characters, multiline strings) inline to the exec tool:
import argparse, json, os, sys
# Make the skill root importable (e.g. to import myskill_api)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import myskill_api
def cmd_list(args):
items = myskill_api.list_items()
if args.json:
print(json.dumps(items, indent=2))
return
for item in items:
print(f"[{item['id']}] {item['name']}")
def main():
parser = argparse.ArgumentParser(prog="myskill")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("list")
p.add_argument("--json", action="store_true")
p.set_defaults(func=cmd_list)
args = parser.parse_args()
try:
args.func(args)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()Invoke it from SKILL.md’s documented commands as:
SKILL=~/.openclaw/workspace/skills/myskill
uv run --project $SKILL myskill list
uv run --project $SKILL myskill search "term"The first run creates .venv and installs the package (~1s); subsequent runs are fast.
5. Use the file-based content pattern for large inputs
For commands that accept large or complex content (Markdown pages, configs), always add a
--content-file flag rather than relying only on inline --content — this eliminates shell
quoting failures entirely:
p.add_argument("--content-file", metavar="FILE")
p.add_argument("--content") # fallback for short strings
# In the handler:
if args.content_file:
with open(args.content_file) as f:
content = f.read()
elif args.content:
content = args.contentExpected agent workflow: write content to a temp file via heredoc, call the CLI with
--content-file <path>, then clean up the temp file.
6. Resolve secrets from multiple sources
Skills should work regardless of which user Python runs as:
def _get_secret(env_var: str, filename: str) -> str:
if os.environ.get(env_var):
return os.environ[env_var].strip()
candidates = [
os.path.join(os.environ.get("HOME", ""), ".openclaw", "secrets", filename),
os.path.expanduser(f"~/.openclaw/secrets/{filename}"),
f"/home/openclaw/.openclaw/secrets/{filename}", # hard fallback
]
for path in candidates:
if os.path.exists(path):
with open(path) as f:
return f.read().strip()
raise Exception(f"{env_var} not set and {filename} not found.")os.path.expanduser("~") returns /root when running as root, so always include the
/home/openclaw hard fallback.
7. Document the skill in SKILL.md
## Trigger Phrases
- "Do X with [thing]" ← natural language variations the agent matches on
- "Search for [term]"
## Commands
### `list` — short description
\`\`\`bash
uv run --project $SKILL myskill list
\`\`\`
## Setup
| Setting | Source |
|---------|--------|
| API Key | `~/.openclaw/secrets/my-key.txt` or `$MY_API_KEY` |
| Base URL | `~/.openclaw/secrets/my-url.txt` or `$MY_BASE_URL` |
## Notes
- Known workarounds and gotchasWrite trigger phrases that cover how a user would naturally phrase requests — include both imperative (“Add a page”) and interrogative (“What pages are in the wiki?”) forms.
Verify
uv run --project $SKILL myskill listruns without error and the.venvis created on first invocation.- The skill appears in OpenClaw’s web UI with the settings declared under
env:shown with their descriptions and required/optional labels. - A
--content-file-based command round-trips a multiline Markdown file without quoting errors.
Troubleshooting
- Env var settings don’t show descriptions in the web UI: the
env:array must be top-level inSKILL.mdfrontmatter, not nested undermetadata.openclaw.requires.env. - Secret lookup fails when the skill runs as root:
os.path.expanduser("~")resolves to/rootfor the root user — add the hard-coded/home/openclaw/...fallback path. - Quoting/escaping errors when passing Markdown or code inline: switch the affected command to
the
--content-filepattern instead of inline--content.
Related
- Applies to any OpenClaw skill implementation; the same Python CLI +
uv runpattern generalizes to other agent-skill frameworks that shell out to a script.