The fastest way to make an agent useful is to wire it to an API the agent doesn’t know yet. This guide shows exactly how I turn any REST API—public, key-gated, or full OAuth—into an OpenClaw skill that the runtime can call with a single JSON payload. Everything here works on OpenClaw 0.32.1 (Node 22+) and ships just as well on ClawCloud.
Prerequisites & Tooling
- Node 22 or later (OpenClaw is ESM-only now)
- OpenClaw CLI 0.15.0:
npm i -g @openclaw/cli - Basic familiarity with whatever API you are wrapping (endpoints, auth docs)
Anatomy of an OpenClaw Skill
Every skill is just two files and an optional test directory:
- manifest.json — declarative description the agent can introspect
- index.mjs — the actual handler functions
- /spec — Jest or tap tests (recommended)
OpenClaw reads the manifest at runtime, builds a tool schema, and injects it into the LLM prompt. The handler code is executed in the daemon sandbox when the agent decides to call it.
The Generic Template
I keep a vanilla template on disk so I’m not copy-pasting from older projects. Create a new directory and run:
npx openclaw-cli init-skill my-api-skill
cd my-api-skill
The CLI writes boilerplate that looks like this:
{
"name": "my-api-skill",
"version": "0.0.1",
"description": "Talks to Example API",
"schema": {
"type": "object",
"properties": {
"endpoint": {"type": "string", "description": "Relative path like /users"},
"method": {"type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"]},
"body": {"type": "object", "description": "Optional JSON body"}
},
"required": ["endpoint","method"]
}
}
index.mjs:
import fetch from "node-fetch";
export async function run(params, ctx) {
const { endpoint, method = "GET", body } = params;
const baseUrl = ctx.secrets.BASE_URL; // injected via env or cloud console
const key = ctx.secrets.API_KEY; // optional
const res = await fetch(`${baseUrl}${endpoint}`, {
method,
headers: {
"Content-Type": "application/json",
...(key ? { "Authorization": `Bearer ${key}` } : {})
},
body: ["POST","PUT","PATCH"].includes(method) ? JSON.stringify(body||{}) : undefined,
});
if (!res.ok) {
throw new Error(`API ${res.status}: ${await res.text()}`);
}
return await res.json();
}
That’s everything the runtime needs. The schema is small on purpose; the agent can fill in endpoint, method, and body on its own.
Handling Authentication
API keys
Most REST services still use a static header token. I store keys as secret environment variables so they never end up in Git.
# .env (local only)
API_KEY=sk-example-123
Local daemon will load .env automatically. On ClawCloud add the same variable via Settings → Environment.
OAuth 2.0 (authorization code flow)
The annoying part is the initial consent screen—OpenClaw can’t magically spawn it. My approach:
- Create a small Next.js or Express callback URL in the same repo (
/oauth) - Have the page exchange
code→access_token, then POST the token to/api/openclaw/secrets(simple serverless endpoint protected by cookie or JWT) - The endpoint calls
openclaw-cli secrets set TRELLO_TOKEN "..."via the Cloud API
Now the agent can read ctx.secrets.TRELLO_TOKEN at runtime. Rotate tokens in the callback when the refresh token works.
Short-lived tokens (AWS SigV4, JWT, etc.)
If the service uses request-signed headers, do the signing inside run() each time. Keep the private key or AWS secret in ctx.secrets. Aim to isolate the signing code in its own util so the skill remains readable.
Mapping Endpoints to Natural-Language Actions
Agents reason better when the schema describes intent, not URLs. Two strategies:
- Fat schema, skinny handler — Declare every possible field. Great for predictable, well-scoped APIs like Stripe.
- Thin schema, intelligent agent — Expose only generic fields (
endpoint,method). Good for huge or undocumented APIs.
If I know users will ask "add card to Trello list", I’ll prefer:
{
"type": "object",
"properties": {
"action": {"type":"string","enum":["create_card","move_card"]},
"listId": {"type":"string"},
"name": {"type":"string"},
"description": {"type":"string"}
},
"required": ["action"]
}
The handler then switches on action and calls the right REST route. More upfront schema work, but better agent reliability.
Error Handling & Rate Limiting
Never pass raw 500s back to the LLM. Wrap errors with context it can parse:
if (!res.ok) {
const payload = await res.text();
return {
error: true,
status: res.status,
message: payload.slice(0, 500) // avoid token explosion
};
}
For rate limiting the two patterns I use:
- Token bucket in memory — Works when only one agent instance is running locally.
- External limiter (Redis or upstash-ratelimit) — Needed when you scale on ClawCloud’s autoscaling worker pool.
Pseudo-code with Upstash:
import { Ratelimit } from "@upstash/ratelimit";
const rl = new Ratelimit({ redis, prefix: "my-api", rate: { limit: 100, window: "1 h" } });
export async function run(p, ctx) {
const { success, remaining } = await rl.limit("global");
if (!success) throw new Error("Rate limit exceeded");
// proceed
}
Testing the Skill (Local & CI)
I can’t trust a skill until Jest tells me it did the right HTTP calls. Example spec:
// spec/basic.spec.mjs
import { run } from "../index.mjs";
import nock from "nock";
describe("posts skill", () => {
it("returns first post", async () => {
nock("https://jsonplaceholder.typicode.com")
.get("/posts/1")
.reply(200, { id: 1, title: "hi" });
const res = await run({ endpoint: "/posts/1" }, { secrets: { BASE_URL: "https://jsonplaceholder.typicode.com" }});
expect(res.id).toBe(1);
});
});
Add npm run test in CI before you git push.
Worked Example 1 — Read-Only Public API
Goal
Let the agent ask "what’s the title of post 4?" using JSONPlaceholder (no auth).
Manifest
{
"name": "jsonplaceholder",
"schema": {
"type": "object",
"properties": {
"postId": {"type":"integer"}
},
"required": ["postId"]
}
}
Handler
export async function run({ postId }) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
return await res.json();
}
Test
npx openclaw-cli exec jsonplaceholder --postId 4
Agent now answers instantly because the schema is tiny and deterministic.»
Worked Example 2 — Trello-Style API Key Auth
Goal
Create a new card in a list. Requires API_KEY and TOKEN query params.
Manifest
{
"name": "trello",
"schema": {
"type":"object",
"properties": {
"listId": {"type":"string"},
"name": {"type":"string"},
"desc": {"type":"string"}
},
"required": ["listId","name"]
}
}
Handler
export async function run({ listId, name, desc }, ctx) {
const key = ctx.secrets.TRELLO_KEY;
const token = ctx.secrets.TRELLO_TOKEN;
const qs = new URLSearchParams({ key, token, idList: listId, name, desc });
const res = await fetch(`https://api.trello.com/1/cards?${qs}`, { method: "POST" });
if (!res.ok) throw new Error(await res.text());
return await res.json();
}
Agent Prompt
Because we named fields listId, name, and desc, GPT-4 tends to fill them correctly when the user says “add ‘Deploy OpenClaw’ card to Engineering backlog”.
Worked Example 3 — GitHub OAuth with Pagination & Rate Limit
Goal
List all open PRs assigned to me across the org, handling GitHub’s 60 req/min limit for non-App tokens.
Manifest
{
"name": "github-prs",
"schema": {
"type":"object",
"properties": {
"page": {"type":"integer","default":1},
"per_page": {"type":"integer","default":30}
}
}
}
Handler
import { Ratelimit } from "@upstash/ratelimit";
import Redis from "ioredis";
const rl = new Ratelimit({ redis: new Redis(process.env.REDIS_URL), prefix: "gh", rate: { limit: 55, window: "1 m" }});
export async function run({ page = 1, per_page = 30 }, ctx) {
const { success } = await rl.limit("global");
if (!success) return { error: true, message: "try again in a minute" };
const token = ctx.secrets.GITHUB_TOKEN;
const url = `https://api.github.com/search/issues?q=is:pr+state:open+assignee:@me&page=${page}&per_page=${per_page}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" } });
if (!res.ok) throw new Error(`GitHub ${res.status}`);
const json = await res.json();
return json.items.map(i => ({ number: i.number, repo: i.repository_url.split("/").slice(-2).join("/"), title: i.title }));
}
Notes
- Pagination parameters surfaced so the agent can ask for “next page”.
- Rate limiter prevents 403 abuse. 55 instead of 60 gives safety margin.
- The search API returns both PRs and issues; we filter by
is:pr.
Deploying to ClawCloud
Once tests pass locally:
git push origin main # assuming CI builds the skill artefact
openclaw-cli cloud deploy skills/trello
The CLI zips the folder, uploads it, and restarts your agent container. In the UI, you’ll see the new skill under Tools → Custom. Add required environment variables in the same panel and hit “Save”.
Takeaway: Ship the Smallest Useful Schema
Every time I keep the manifest minimal yet descriptive, the agent behaves better and I write less code. Start with a read-only endpoint, confirm the skill contracts, then layer on mutations, OAuth, and rate limits. Push to ClawCloud only after tests catch the obvious edge cases, and production traffic will stay boring—in the good way.