Out of the box, OpenClaw is a capable personal AI assistant. The moment you write your first custom Skill, it becomes something far more powerful: an autonomous agent precisely shaped to your own environment, APIs, and workflows. This article is the advanced guide to OpenClaw’s extension system — Skills, Nodes, Canvas, Sessions, and Cron — and how those four layers combine into workflows that run without you.
By the time you finish this guide, you will have built a working custom Skill from scratch, wired it into a multi-step Node graph, visualized it in Canvas, and scheduled it to run on a cron interval. Every code sample is complete and executable against OpenClaw v2026.4.5.
OpenClaw’s Extension Architecture
Before writing a single line of code, it is worth understanding the three-layer architecture that Skills, Nodes, and Canvas form together. Each layer has a distinct responsibility, and confusing them leads to designs that are harder to maintain and debug.
┌─────────────────────────────────────────────────────────┐
│ CANVAS (Layer 3) │
│ Visual interface — compose, monitor, override │
├─────────────────────────────────────────────────────────┤
│ NODES (Layer 2) │
│ Workflow graph — chain, branch, transform data │
├─────────────────────────────────────────────────────────┤
│ SKILLS (Layer 1) │
│ Atomic capabilities — single-purpose, tested functions │
└─────────────────────────────────────────────────────────┘
Layer 1 — Skills are atomic JavaScript or TypeScript functions that do exactly one thing: call a weather API, send a Slack message, query a database, parse an RSS feed. Skills are the building blocks. They know nothing about each other and nothing about the AI model — they just receive structured input, do their work, and return structured output.
Layer 2 — Nodes are the composition layer. A Node is a Skills wrapper with an explicit input schema, an explicit output schema, and defined connection ports. The OpenClaw Node editor lets you drag ports from one Node to another, creating a directed acyclic graph (DAG) of data flow. The AI can invoke this graph as a single named tool (“run the morning-briefing workflow”), and OpenClaw handles the step-by-step execution.
Layer 3 — Canvas is the visual control plane. It renders your Node graph in real time, highlighting the currently executing node, showing live input/output data at each edge, and allowing you to pause, inspect, and override at any point during a run. Canvas connects to the daemon’s local WebSocket API at localhost:39201 and requires no additional configuration beyond a running daemon.
The three layers are independently useful — you can write a Skill without ever building a Node graph, and you can monitor the Canvas without having written any custom Skills — but their real power emerges when all three are active simultaneously.
Skills: Adding Custom Capabilities
A Skill in OpenClaw is a CommonJS module that exports a default object conforming to the SkillDefinition interface. OpenClaw uses this definition object to register the Skill with the runtime, generate its JSON Schema for the AI model, and validate inputs and outputs at call time.
Skill File Structure
Skills live in the ~/.openclaw/skills/ directory by default. The directory is scanned at daemon startup, and any .js or .ts file that exports a valid SkillDefinition is registered automatically — no manifest file required.
~/.openclaw/
├── skills/
│ ├── weather.js ← a custom skill
│ ├── github-digest.ts ← TypeScript skill (transpiled at load time)
│ └── slack-notify.js
├── nodes/
│ └── morning-briefing.json
└── openclaw.config.json
The SkillDefinition Interface
// TypeScript — ~/.openclaw/skills/types.d.ts (included with openclaw)
export interface SkillDefinition {
name: string; // Unique machine-readable identifier
displayName: string; // Human-readable label shown in Canvas
description: string; // What the AI sees when deciding whether to use this skill
version: string; // Semver — used for upgrade detection
parameters: JSONSchema; // JSON Schema defining accepted input
returns: JSONSchema; // JSON Schema defining output shape
execute: (params: unknown, context: SkillContext) => Promise<unknown>;
}
export interface SkillContext {
session: SessionHandle; // Access to the current session's state store
logger: SkillLogger; // Scoped logger (output visible in Canvas logs)
config: ConfigHandle; // Read access to openclaw.config.json
signal: AbortSignal; // Cancelled when the AI cancels the tool call
}
The description field is the most important from an AI perspective. It is inserted verbatim into the system prompt as the tool description. Write it as if you are explaining to the AI what situations this Skill should handle. Be concrete and specific — vague descriptions cause the model to misfire or skip your Skill entirely.
Registering a Minimal Skill
Here is the simplest possible Skill — one that echoes its input back as output. This is useful for testing whether your Skill is correctly loaded:
// ~/.openclaw/skills/echo.js
/** @type {import('openclaw').SkillDefinition} */
module.exports = {
name: 'echo',
displayName: 'Echo',
description: 'Returns the input message unchanged. Use only for testing skill registration.',
version: '1.0.0',
parameters: {
type: 'object',
required: ['message'],
properties: {
message: {
type: 'string',
description: 'The text to echo back.'
}
}
},
returns: {
type: 'object',
properties: {
echoed: { type: 'string' }
}
},
async execute({ message }) {
return { echoed: message };
}
};
After saving this file, restart the daemon and verify the Skill is loaded:
openclaw daemon restart
openclaw skills list
# ✓ echo v1.0.0 — Returns the input message unchanged.
Now try calling it:
openclaw skills call echo --params '{"message": "hello world"}'
# { "echoed": "hello world" }
If the skill appears in openclaw skills list but skills call returns an error, check openclaw logs --tail 50 for validation or execution errors.
Parameter Schema Best Practices
The parameters JSON Schema is what the AI model receives when deciding how to call your Skill. A well-structured schema produces correct calls; a loose schema produces incorrect or hallucinated argument values.
Rules to follow:
- List all required fields in the
requiredarray — never leave required params optional - Add
descriptionto every property, not just the top level - Use
enumfor fields with a fixed set of valid values - Use
minimum/maximumfor numeric range constraints - Prefer
string+format: "date-time"over raw epoch integers for timestamps — the AI model handles ISO 8601 much better than epoch milliseconds
// Good parameter schema — concrete, constrained, documented
parameters: {
type: 'object',
required: ['city', 'units'],
properties: {
city: {
type: 'string',
description: 'City name for which to fetch weather, e.g. "Tokyo", "New York"'
},
units: {
type: 'string',
enum: ['metric', 'imperial'],
description: 'Temperature unit system. Use metric for Celsius, imperial for Fahrenheit.'
},
includeHourly: {
type: 'boolean',
description: 'When true, include hourly forecast for the next 24 hours.',
default: false
}
}
}
Nodes: Visual Workflow Composition
A Node is a graph vertex — it wraps a Skill, defines labeled input and output ports, and can be connected to other Nodes via directed edges. When you run a Node graph, OpenClaw executes the nodes in topological order, passing data from each node’s output ports to the connected nodes’ input ports.
Node Definition File Format
Nodes are defined as JSON files in ~/.openclaw/nodes/. Unlike Skills, Nodes do not contain executable logic — they are declarative wiring specifications. All logic lives in the underlying Skills.
{
"$schema": "https://openclaw.ai/schemas/node-graph/v1.json",
"name": "morning-briefing",
"displayName": "Morning Briefing",
"description": "Fetches weather and top GitHub notifications, then posts a summary to Slack.",
"version": "1.0.0",
"triggerType": "manual",
"nodes": [
{
"id": "weather",
"skillName": "weather",
"label": "Fetch Weather",
"position": { "x": 100, "y": 200 },
"staticInputs": {
"city": "Seoul",
"units": "metric",
"includeHourly": false
}
},
{
"id": "github",
"skillName": "github-digest",
"label": "GitHub Digest",
"position": { "x": 100, "y": 400 },
"staticInputs": {
"maxItems": 5
}
},
{
"id": "summarize",
"skillName": "llm-summarize",
"label": "AI Summary",
"position": { "x": 400, "y": 300 },
"inputMapping": {
"weather_data": "weather.output.summary",
"github_data": "github.output.items"
}
},
{
"id": "notify",
"skillName": "slack-notify",
"label": "Post to Slack",
"position": { "x": 700, "y": 300 },
"inputMapping": {
"message": "summarize.output.text"
},
"staticInputs": {
"channel": "#daily-briefing"
}
}
],
"edges": [
{ "from": "weather", "to": "summarize" },
{ "from": "github", "to": "summarize" },
{ "from": "summarize", "to": "notify" }
]
}
Data Flow and Input Mapping
The inputMapping field in each node definition uses a dot-notation path to reference outputs from upstream nodes:
"inputMapping": {
"[this-node-input-key]": "[upstream-node-id].[output-key]"
}
When OpenClaw executes the graph, it resolves these references at runtime. If an upstream node has not yet produced the referenced output (because it failed or was not yet executed), OpenClaw raises a DataFlowError and halts graph execution, logging the failure to Canvas.
You can also reference the original trigger event’s data using the special $trigger prefix:
"inputMapping": {
"user_name": "$trigger.session.user.displayName",
"timestamp": "$trigger.ts"
}
Running a Node Graph
# Run manually by graph name
openclaw graph run morning-briefing
# Run with additional runtime inputs (merged with staticInputs)
openclaw graph run morning-briefing --input '{"city": "Tokyo"}'
# Dry-run — validate graph and show execution plan without calling skills
openclaw graph run morning-briefing --dry-run
The --dry-run flag is particularly useful during development. It resolves all input mappings, validates that referenced skill names exist, and prints the execution plan without making any external API calls or writing any state.
Canvas: The Visual Interface
Canvas is OpenClaw’s real-time control plane. When you navigate to http://localhost:39201 with the daemon running, Canvas loads a visual representation of all your Node graphs, the current execution state of each node, and a live log stream.
What Canvas Shows
Canvas renders each node in your graph as a card with three sections:
- Header bar — Node name, skill name, and execution status (idle / running / succeeded / failed)
- Port list — Input ports on the left edge, output ports on the right edge; live data flows along the connecting edges as colored pulses during execution
- Data panel — Click any node to expand a drawer showing the resolved input values and (after execution) the output values with full JSON inspection
During a graph run, Canvas updates in real time via WebSocket push from the daemon. You do not need to refresh the page — the UI reflects the actual execution state within milliseconds of each change.
Manual Override
Canvas is not read-only. While a graph is executing, you can:
- Pause — Click the pause button to halt execution after the current node completes. The graph enters a
suspendedstate; you can inspect all in-flight data. - Edit and resume — While paused, click any node’s data panel to edit its output. When you resume, downstream nodes receive your modified values rather than the original computed ones.
- Inject a node — Drag a new Skill from the skill sidebar and drop it onto any edge. This inserts the skill into the graph mid-execution; subsequent nodes receive output from the injected node.
- Skip a node — Right-click any pending node and select “Skip”. OpenClaw passes
nullto all downstream inputs that depended on this node’s output, which may trigger null-handling in downstream skills.
These override capabilities make Canvas an invaluable debugging tool. When a workflow misfires in production, you can pause it, manually correct an intermediate value, resume it, and observe whether the corrected value produces the expected downstream behavior — all without restarting the graph or rewriting code.
Canvas Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Space | Toggle pause / resume on running graph |
Cmd+K | Open skill search (add a node to canvas) |
Cmd+Click node | Expand data panel |
Cmd+D | Duplicate selected node |
Backspace | Delete selected node or edge |
Cmd+Z / Cmd+Shift+Z | Undo / redo canvas edits (layout only, not execution state) |
Sessions: Managing Conversation State
Every interaction with OpenClaw — whether triggered by a message on Telegram, a Cron job firing, or a manual openclaw graph run command — happens within a Session. Sessions are the state layer that makes OpenClaw feel context-aware rather than stateless.
Session Lifecycle
A session is created when a trigger event arrives and no matching active session exists for that context. For messaging platforms, the matching key is the platform identifier plus the sender’s user ID — so each Telegram user gets their own persistent session that accumulates across days and weeks. For Cron jobs, each named job maintains its own singleton session.
Trigger event arrives
│
▼
Is there an active session for this context?
Yes → Load session state → Execute
No → Create new session → Execute
│
▼
After execution: update session state → persist to SQLite
Sessions are stored in ~/.openclaw/data/openclaw.db as rows in the sessions table. Each row contains:
session_id— UUID primary keycontext_key— platform + user identifiercreated_at,last_active_at— timestampsstate— JSON blob of arbitrary key-value pairs your skills can read and writehistory— JSONL array of message turns (role / content / tool_calls / tool_results)
Reading and Writing Session State from a Skill
The SkillContext object passed to your execute function provides a session handle with read/write methods:
// ~/.openclaw/skills/user-preferences.js
/** @type {import('openclaw').SkillDefinition} */
module.exports = {
name: 'user-preferences',
displayName: 'User Preferences',
description: 'Reads or updates the user\'s stored preferences such as preferred city, language, and notification schedule.',
version: '1.0.0',
parameters: {
type: 'object',
required: ['action'],
properties: {
action: {
type: 'string',
enum: ['get', 'set'],
description: 'Whether to read current preferences or update them.'
},
updates: {
type: 'object',
description: 'Key-value pairs to update. Only used when action is "set".',
properties: {
city: { type: 'string' },
language: { type: 'string', enum: ['en', 'ja', 'ko', 'es'] },
notifyAt: { type: 'string', format: 'time', description: 'HH:MM in local time' }
}
}
}
},
returns: {
type: 'object',
properties: {
preferences: { type: 'object' }
}
},
async execute({ action, updates }, context) {
const { session, logger } = context;
if (action === 'get') {
const prefs = await session.get('preferences') ?? {
city: 'London',
language: 'en',
notifyAt: '08:00'
};
logger.info('Loaded preferences', prefs);
return { preferences: prefs };
}
if (action === 'set') {
const current = await session.get('preferences') ?? {};
const merged = { ...current, ...updates };
await session.set('preferences', merged);
logger.info('Preferences updated', merged);
return { preferences: merged };
}
}
};
Session TTL and Cleanup
Sessions do not expire by default. Left unchecked, a long-running OpenClaw instance accumulates hundreds of sessions in the SQLite database — most of them from one-off tests or platforms you no longer use. Configure TTL-based cleanup in openclaw.config.json:
{
"sessions": {
"ttlDays": 30,
"cleanupIntervalHours": 24,
"maxHistoryTurns": 100
}
}
maxHistoryTurns is particularly important for controlling context window costs. When a session’s history exceeds this count, OpenClaw trims oldest turns while preserving the system prompt and the most recent turns. This prevents runaway context growth on long-lived conversations.
To inspect and manage sessions directly:
# List all active sessions
openclaw sessions list
# Inspect a specific session (shows state blob and recent history)
openclaw sessions inspect <session-id>
# Delete a session (also removes its state and history)
openclaw sessions delete <session-id>
# Prune sessions older than N days immediately
openclaw sessions prune --older-than 14
Building a Custom Skill: End-to-End Example
This section walks through building a complete, production-quality Skill from scratch: a weather fetcher that calls the Open-Meteo API (free, no API key required), formats the result for human reading, and handles errors gracefully.
Step 1: Write the Skill
// ~/.openclaw/skills/weather.js
// Fetches current weather and a 3-day forecast from Open-Meteo (free, no key needed)
const https = require('https');
/**
* Promisified HTTPS GET that returns parsed JSON.
* We avoid external dependencies here so the skill has zero install step.
*/
function fetchJson(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, { timeout: 8000 }, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
return;
}
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (e) {
reject(new Error('Failed to parse JSON response'));
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
});
}
const GEOCODE_URL = (city) =>
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
const WEATHER_URL = (lat, lon, units) => {
const tempUnit = units === 'imperial' ? 'fahrenheit' : 'celsius';
return [
`https://api.open-meteo.com/v1/forecast`,
`?latitude=${lat}&longitude=${lon}`,
`¤t=temperature_2m,weathercode,windspeed_10m,relative_humidity_2m`,
`&daily=temperature_2m_max,temperature_2m_min,weathercode`,
`&temperature_unit=${tempUnit}`,
`&forecast_days=3`,
`&timezone=auto`
].join('');
};
const WMO_DESCRIPTIONS = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Icy fog', 51: 'Light drizzle', 53: 'Moderate drizzle',
55: 'Dense drizzle', 61: 'Slight rain', 63: 'Moderate rain', 65: 'Heavy rain',
71: 'Slight snow', 73: 'Moderate snow', 75: 'Heavy snow',
80: 'Slight rain showers', 81: 'Moderate rain showers', 82: 'Violent rain showers',
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with heavy hail'
};
/** @type {import('openclaw').SkillDefinition} */
module.exports = {
name: 'weather',
displayName: 'Weather Fetcher',
description:
'Fetches current conditions and a 3-day forecast for a named city. ' +
'Use when the user asks about weather, temperature, rain, or what to wear. ' +
'Supports metric (Celsius) and imperial (Fahrenheit) units.',
version: '2.0.0',
parameters: {
type: 'object',
required: ['city', 'units'],
properties: {
city: {
type: 'string',
description: 'City name, e.g. "Tokyo", "New York", "London". English names only.'
},
units: {
type: 'string',
enum: ['metric', 'imperial'],
description: 'metric = Celsius / km·h⁻¹ | imperial = Fahrenheit / mph'
}
}
},
returns: {
type: 'object',
properties: {
city: { type: 'string' },
summary: { type: 'string', description: 'One-sentence plain-English summary' },
current: { type: 'object' },
forecast: { type: 'array', items: { type: 'object' } }
}
},
async execute({ city, units }, context) {
const { logger, signal } = context;
// Step 1 — Geocode city name to lat/lon
logger.info(`Geocoding: ${city}`);
const geoData = await fetchJson(GEOCODE_URL(city));
if (!geoData.results?.length) {
throw new Error(`City not found: "${city}". Try a larger nearby city.`);
}
if (signal.aborted) throw new Error('Skill call was cancelled');
const { latitude, longitude, name, country } = geoData.results[0];
const resolvedCity = `${name}, ${country}`;
// Step 2 — Fetch weather data
logger.info(`Fetching weather for ${resolvedCity} at (${latitude}, ${longitude})`);
const weather = await fetchJson(WEATHER_URL(latitude, longitude, units));
if (signal.aborted) throw new Error('Skill call was cancelled');
const tempUnit = units === 'metric' ? '°C' : '°F';
const speedUnit = units === 'metric' ? 'km/h' : 'mph';
const cur = weather.current;
const daily = weather.daily;
const currentCondition = WMO_DESCRIPTIONS[cur.weathercode] ?? 'Unknown';
const current = {
temperature: `${cur.temperature_2m}${tempUnit}`,
condition: currentCondition,
humidity: `${cur.relative_humidity_2m}%`,
windSpeed: `${cur.windspeed_10m} ${speedUnit}`
};
const forecast = daily.time.map((date, i) => ({
date,
high: `${daily.temperature_2m_max[i]}${tempUnit}`,
low: `${daily.temperature_2m_min[i]}${tempUnit}`,
condition: WMO_DESCRIPTIONS[daily.weathercode[i]] ?? 'Unknown'
}));
const summary =
`Currently ${currentCondition.toLowerCase()} in ${resolvedCity}: ` +
`${cur.temperature_2m}${tempUnit}, wind ${cur.windspeed_10m} ${speedUnit}.`;
logger.info('Weather fetched successfully', { summary });
return { city: resolvedCity, summary, current, forecast };
}
};
Step 2: Verify the Skill
openclaw daemon restart
openclaw skills list | grep weather
# ✓ weather v2.0.0 — Fetches current conditions and a 3-day forecast...
openclaw skills call weather --params '{"city": "Tokyo", "units": "metric"}'
Expected output:
{
"city": "Tokyo, Japan",
"summary": "Currently partly cloudy in Tokyo, Japan: 18°C, wind 12 km/h.",
"current": {
"temperature": "18°C",
"condition": "Partly cloudy",
"humidity": "62%",
"windSpeed": "12 km/h"
},
"forecast": [
{ "date": "2026-04-08", "high": "20°C", "low": "14°C", "condition": "Partly cloudy" },
{ "date": "2026-04-09", "high": "22°C", "low": "15°C", "condition": "Mainly clear" },
{ "date": "2026-04-10", "high": "19°C", "low": "13°C", "condition": "Moderate rain" }
]
}
Step 3: Wire the Skill into a Node Graph
{
"$schema": "https://openclaw.ai/schemas/node-graph/v1.json",
"name": "weather-briefing",
"displayName": "Weather Briefing",
"description": "Fetches weather for the user's preferred city and formats a Slack-ready message.",
"version": "1.0.0",
"triggerType": "cron",
"nodes": [
{
"id": "prefs",
"skillName": "user-preferences",
"label": "Load Preferences",
"position": { "x": 100, "y": 300 },
"staticInputs": { "action": "get" }
},
{
"id": "weather",
"skillName": "weather",
"label": "Fetch Weather",
"position": { "x": 400, "y": 300 },
"inputMapping": {
"city": "prefs.output.preferences.city"
},
"staticInputs": { "units": "metric" }
},
{
"id": "notify",
"skillName": "slack-notify",
"label": "Post to Slack",
"position": { "x": 700, "y": 300 },
"inputMapping": {
"message": "weather.output.summary"
},
"staticInputs": {
"channel": "#morning-briefing"
}
}
],
"edges": [
{ "from": "prefs", "to": "weather" },
{ "from": "weather", "to": "notify" }
]
}
Save this to ~/.openclaw/nodes/weather-briefing.json and verify it loads:
openclaw graph list
# ✓ weather-briefing v1.0.0 — cron trigger
openclaw graph run weather-briefing --dry-run
# [DRY RUN] Execution plan:
# 1. prefs (user-preferences) — static inputs: { action: "get" }
# 2. weather (weather) — mapped: city ← prefs.preferences.city
# 3. notify (slack-notify) — mapped: message ← weather.summary
# All skill names resolved. All input mappings validated. Ready to run.
Combining Skills with Cron
Skills and Node graphs become truly autonomous when triggered by the Cron module. OpenClaw’s built-in scheduler runs inside the daemon process — no external cron daemon, no crontab entries, no third-party services required.
Defining a Cron Trigger
Add the schedule to openclaw.config.json under the cron.jobs array:
{
"cron": {
"jobs": [
{
"name": "morning-weather",
"schedule": "0 7 * * 1-5",
"graph": "weather-briefing",
"enabled": true,
"timezone": "Asia/Seoul",
"inputs": {
"source": "cron"
}
},
{
"name": "weekly-github-digest",
"schedule": "0 9 * * 1",
"graph": "github-weekly",
"enabled": true,
"timezone": "Asia/Seoul"
}
]
}
}
The schedule field uses standard five-field cron syntax: minute hour day-of-month month day-of-week. The timezone field is a TZ database name — all schedules fire according to the specified timezone, regardless of the server’s system timezone. This is critical for teams running OpenClaw on cloud VMs where the system timezone is typically UTC.
Cron Management Commands
# List all configured cron jobs with next fire time
openclaw cron list
# Manually trigger a cron job immediately (ignores schedule)
openclaw cron trigger morning-weather
# Pause a job without removing it from config
openclaw cron pause morning-weather
# Resume a paused job
openclaw cron resume morning-weather
# Show execution history for a job (last 20 runs)
openclaw cron history morning-weather --limit 20
Advanced Pattern: Conditional Cron with Session State
A common advanced pattern is a Cron job that checks session state before deciding what to do — effectively a scheduled agent that adapts to context. The following Skill demonstrates this pattern. It reads the last execution time from session state and skips redundant actions if it has already run recently:
// ~/.openclaw/skills/idempotent-notify.js
// Posts a Slack message only if the same message has not been posted within the last hour.
// Useful for cron jobs that may fire more than once due to retry logic.
/** @type {import('openclaw').SkillDefinition} */
module.exports = {
name: 'idempotent-notify',
displayName: 'Idempotent Slack Notify',
description:
'Posts a message to Slack only if an identical message was not already posted within the deduplication window. ' +
'Use instead of slack-notify when the graph may be retried or triggered multiple times.',
version: '1.0.0',
parameters: {
type: 'object',
required: ['channel', 'message', 'deduplicationWindowMinutes'],
properties: {
channel: {
type: 'string',
description: 'Slack channel name including #, e.g. "#alerts"'
},
message: {
type: 'string',
description: 'The message text to post.'
},
deduplicationWindowMinutes: {
type: 'number',
minimum: 1,
maximum: 1440,
description: 'How many minutes to suppress duplicate messages after a successful post.'
}
}
},
returns: {
type: 'object',
properties: {
posted: { type: 'boolean', description: 'True if the message was posted, false if suppressed.' },
reason: { type: 'string' }
}
},
async execute({ channel, message, deduplicationWindowMinutes }, context) {
const { session, logger } = context;
const dedupeKey = `slack_dedup_${channel}_${Buffer.from(message).toString('base64').slice(0, 32)}`;
const lastPosted = await session.get(dedupeKey);
if (lastPosted) {
const minutesAgo = (Date.now() - lastPosted) / 60_000;
if (minutesAgo < deduplicationWindowMinutes) {
logger.info(`Suppressing duplicate message. Last posted ${minutesAgo.toFixed(1)} minutes ago.`);
return { posted: false, reason: `Duplicate suppressed — last posted ${minutesAgo.toFixed(0)}m ago` };
}
}
// In a real implementation, this is where you would call the Slack API.
// For the purposes of this example, we log the would-be call.
logger.info(`[Slack → ${channel}] ${message}`);
await session.set(dedupeKey, Date.now());
return { posted: true, reason: 'Message posted successfully' };
}
};
This pattern — using session state as a key-value store for idempotency control — applies to any Skill where duplicate execution must be prevented. It is especially valuable in Cron-driven graphs where the scheduler’s “at-least-once” delivery guarantee means a job may fire twice across a daemon restart.
For developers building more complex agentic behavior — where the agent itself decides which skills to invoke rather than following a fixed Node graph — the article on LangChain Agents and Tools covers the same conceptual pattern (tool registration, parameter schemas, result routing) in a Python + LangChain context and is worth reading alongside this guide.
For a broader look at how open-source agent frameworks handle custom tool registration compared to OpenClaw’s approach, the AutoGPT Forge: Building Custom Agents guide offers a useful reference point — AutoGPT Forge uses a similar plugin model but with a more opinionated project structure that trades flexibility for convention.
Frequently Asked Questions
Is there a skill marketplace for OpenClaw?
An official community registry is under development but not yet live as of OpenClaw v2026.4.5. Currently, community-contributed skills are shared through the openclaw/community-skills repository on GitHub, which organizes them by category (messaging, productivity, data, developer tools). To install a skill from this repository, clone the repo and copy the relevant .js or .ts file into your ~/.openclaw/skills/ directory, then restart the daemon.
The project roadmap lists a first-class registry — with one-command install, versioning, and cryptographic signing — for a 2026 release. Until then, treat community skills as unaudited code and review the source before running them, exactly as you would any downloaded script.
How do I debug a custom skill?
Three tools serve most debugging needs:
1. The skills call command is your first stop. It executes the skill in isolation, outside any graph, with a specified parameter set. Errors surface immediately with full stack traces:
openclaw skills call weather --params '{"city": "UnknownCity123", "units": "metric"}'
# Error: City not found: "UnknownCity123". Try a larger nearby city.
2. Skill-scoped logs are written by calls to context.logger.info(), context.logger.warn(), and context.logger.error() inside your execute function. View them live:
openclaw logs --skill weather --follow
3. Canvas data panels show the exact input values and output values (or error object) for every skill invocation inside a graph run. If a node fails, its card turns red and the data panel shows the thrown error with the stack trace. This is the fastest way to diagnose data-flow problems — you can see at a glance whether incorrect inputs are causing the failure, or whether the skill is receiving correct inputs but failing internally.
For TypeScript skills, ensure you have run the TypeScript compiler (tsc) before restarting the daemon. OpenClaw transpiles TypeScript skills at load time using esbuild, but syntax errors will prevent the skill from loading.
Can I share skills with other OpenClaw users?
Yes, and the community encourages it. Skills are self-contained .js or .ts files with no project-specific state, which makes them straightforward to share. The recommended approach:
- Write your skill in TypeScript for better documentation and type safety
- Add a
SKILL.mdfile alongside it explaining the purpose, required permissions, and any external API keys or services it depends on - Submit a pull request to
openclaw/community-skillsor publish as an npm package with the naming conventionopenclaw-skill-[name](e.g.,openclaw-skill-weather)
For npm-distributed skills, users install them with:
npm install -g openclaw-skill-weather
openclaw skills link openclaw-skill-weather
The skills link command creates a symlink from ~/.openclaw/skills/ to the npm package’s skill file, so updates via npm update -g take effect after a daemon restart without any manual file management.
What performance limits should I know about?
OpenClaw imposes several limits to prevent runaway skills from degrading the daemon:
| Limit | Default | Override in config |
|---|---|---|
| Skill execution timeout | 30 seconds | skills.timeoutMs |
| Max concurrent skills | 5 | skills.maxConcurrency |
| Max output payload size | 512 KB | skills.maxOutputBytes |
| Graph node count | 50 | graphs.maxNodes |
| Cron job concurrency | 3 | cron.maxConcurrentJobs |
| Session state size | 10 MB | sessions.maxStateBytes |
Skills that exceed their timeout receive an AbortSignal cancellation (the signal object in SkillContext resolves). Well-written skills check signal.aborted at each async boundary — as demonstrated in the weather skill example — and return gracefully rather than being hard-terminated. Hard termination still works but leaves no time for cleanup operations like releasing database connections or writing partial results to session state.
For skills that legitimately need more than 30 seconds (for example, a skill that performs a long video transcription), increase skills.timeoutMs in openclaw.config.json rather than removing the limit entirely:
{
"skills": {
"timeoutMs": 120000
}
}
Keeping the limit nonzero ensures that network hangs and infinite loops in third-party APIs cannot freeze the daemon indefinitely.
Next Steps
You now have a complete picture of OpenClaw’s extension architecture — from individual Skills through Node graph composition, Canvas visualization, Session state management, and Cron scheduling. The next logical directions depend on what you want to build:
- Add more Skills — Start with the skills in the
openclaw/community-skillsrepository to avoid reinventing common integrations (GitHub, Notion, Google Calendar, RSS parsing). Each one is a working template you can fork and adapt. - Build complex graphs — The Node graph format supports conditional branching via
conditionMapping(documented in the OpenClaw wiki under “Node Graph Advanced Features”) — useful for graphs that take different paths based on skill output values. - Explore Canvas automations — Canvas supports saved “layouts” that you can share with teammates via a JSON export. If you are running OpenClaw in a team setting with a shared host, Canvas layouts are the fastest way to onboard others to your workflow designs.
- Monitor production graphs — Use
openclaw cron historyandopenclaw logstogether to build a clear picture of which graphs are succeeding, which are failing, and what the failure patterns are. Most production issues trace back to external API rate limits or changed response schemas — both are visible in the skill-level logs. - Extend into LangChain — If you find yourself needing more sophisticated multi-step reasoning than OpenClaw’s Node graphs provide, LangChain Agents and Tools describes how to build agentic workflows where the LLM itself chooses which tools to invoke and in what order — a complementary pattern to OpenClaw’s more deterministic graph execution model.