How to Build an OpenClaw Plugin: Step by Step Tutorial
TL;DR: OpenClaw plugins extend your agent with custom tools, channels, and LLM providers without modifying core code. This tutorial covers the plugin architecture, development environment setup, building a working plugin from scratch, testing locally, and the advanced patterns used in production plugins.
Why Plugins Instead of Custom Tools
You can add custom tools to OpenClaw by dropping files directly into your workspace's tools/ directory. That works for tools specific to one workspace. But if you want to share a tool across multiple workspaces, publish it for the community, or bundle complex functionality with its own dependencies and configuration, that is what the plugin system is for. If you are new to agents and tools, read what an AI agent is before diving into the plugin architecture — it frames why the tool registration pattern exists.
A plugin is a self contained TypeScript package that registers tools, channels, or LLM providers with the OpenClaw runtime. It has its own package.json, its own dependencies, and its own test suite. You install it into a workspace, configure it in AGENTS.md, and the runtime calls your register() function at boot.
This tutorial builds a practical plugin from scratch: a GitHub Issues tool that lets your agent search open issues, create new ones, and add comments. By the end you will have a working, tested plugin and understand the patterns needed to build more complex ones. Building these plugins is also the foundation of our AI agent development work for clients who need production agents beyond what OpenClaw prototypes.
Before starting, make sure you are familiar with the OpenClaw workspace file system. Understanding how tools and plugins fit into the workspace architecture makes the plugin API much easier to follow.
Prerequisites
- Node.js 20 or later
- pnpm (or npm if you prefer)
- OpenClaw installed and a working workspace
- A GitHub personal access token with
reposcope
Plugin Architecture
Every OpenClaw plugin has the same shape. The runtime calls one function: register(). That function receives a context object and returns a plugin definition.
import { OpenClawPlugin, PluginContext, PluginDefinition } from '@openclaw/sdk'
const plugin: OpenClawPlugin = {
name: 'my-plugin',
version: '1.0.0',
async register(ctx: PluginContext): Promise<PluginDefinition> {
return {
tools: [], // Tool definitions
channels: [], // Channel integrations
providers: [], // LLM provider integrations
}
}
}
export default plugin
The PluginContext contains:
ctx.env— environment variables from the workspace .env filectx.workspace— the current workspace path and configctx.logger— structured logger for plugin outputctx.memory— access to workspace memory (read/write long term memory)
The PluginDefinition return value tells the runtime what your plugin contributes. A plugin that only adds tools returns an empty channels array and an empty providers array. The runtime skips registration for anything empty.
Setting Up the Development Environment
Create a new directory for your plugin:
mkdir openclaw-plugin-github-issues
cd openclaw-plugin-github-issues
pnpm init
Install the OpenClaw SDK and dependencies:
pnpm add @openclaw/sdk
pnpm add -D typescript @types/node vitest tsx
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Update package.json:
{
"name": "openclaw-plugin-github-issues",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"test": "vitest run"
}
}
Create the src/ directory:
mkdir src
touch src/index.ts
touch src/tools/search-issues.ts
touch src/tools/create-issue.ts
touch src/tools/add-comment.ts
touch src/tools/index.ts
Building the Plugin: Step by Step
Step 1: Define the Tool Input Schemas
OpenClaw tools use JSON Schema for parameter validation. The runtime validates tool inputs against the schema before calling your handler, so you never receive malformed data.
Create src/tools/search-issues.ts:
import { ToolDefinition, ToolHandler } from '@openclaw/sdk'
interface SearchIssuesInput {
repo: string // Format: "owner/repo"
query: string // Search query string
state?: 'open' | 'closed' | 'all'
limit?: number
}
export const searchIssuesHandler: ToolHandler<SearchIssuesInput> = async (input, ctx) => {
const { repo, query, state = 'open', limit = 10 } = input
const token = ctx.env.GITHUB_TOKEN
if (!token) {
throw new Error('GITHUB_TOKEN is not configured in workspace .env')
}
const params = new URLSearchParams({
q: `${query} repo:${repo} is:issue is:${state}`,
per_page: String(Math.min(limit, 30)),
})
const response = await fetch(`https://api.github.com/search/issues?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
const data = await response.json() as { items: Array<{
number: number
title: string
state: string
html_url: string
body: string | null
created_at: string
comments: number
}> }
return {
total_found: data.items.length,
issues: data.items.map(issue => ({
number: issue.number,
title: issue.title,
state: issue.state,
url: issue.html_url,
preview: issue.body?.slice(0, 200) ?? '(no description)',
created_at: issue.created_at,
comment_count: issue.comments,
})),
}
}
export const searchIssuesTool: ToolDefinition = {
name: 'github_search_issues',
description: 'Search GitHub issues in a repository by keyword or phrase. Returns matching issues with their number, title, state, and a preview of the description.',
inputSchema: {
type: 'object',
required: ['repo', 'query'],
properties: {
repo: {
type: 'string',
description: 'Repository in owner/repo format, e.g. "Houseofmvps/buildradar"',
},
query: {
type: 'string',
description: 'Search query, e.g. "login not working" or "performance"',
},
state: {
type: 'string',
enum: ['open', 'closed', 'all'],
description: 'Filter by issue state. Defaults to open.',
},
limit: {
type: 'number',
description: 'Maximum number of results to return. Defaults to 10, max 30.',
},
},
},
handler: searchIssuesHandler,
}
Create src/tools/create-issue.ts:
import { ToolDefinition, ToolHandler } from '@openclaw/sdk'
interface CreateIssueInput {
repo: string
title: string
body: string
labels?: string[]
}
export const createIssueHandler: ToolHandler<CreateIssueInput> = async (input, ctx) => {
const { repo, title, body, labels = [] } = input
const token = ctx.env.GITHUB_TOKEN
const [owner, repoName] = repo.split('/')
const response = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, body, labels }),
})
if (!response.ok) {
const error = await response.json() as { message: string }
throw new Error(`GitHub API error: ${error.message}`)
}
const issue = await response.json() as { number: number; html_url: string; title: string }
return {
number: issue.number,
title: issue.title,
url: issue.html_url,
message: `Issue #${issue.number} created successfully`,
}
}
export const createIssueTool: ToolDefinition = {
name: 'github_create_issue',
description: 'Create a new GitHub issue in a repository with a title, description, and optional labels.',
inputSchema: {
type: 'object',
required: ['repo', 'title', 'body'],
properties: {
repo: { type: 'string', description: 'Repository in owner/repo format' },
title: { type: 'string', description: 'Issue title' },
body: { type: 'string', description: 'Issue description in markdown' },
labels: {
type: 'array',
items: { type: 'string' },
description: 'Optional labels to apply to the issue',
},
},
},
handler: createIssueHandler,
}
Create src/tools/index.ts to export all tools:
export { searchIssuesTool } from './search-issues.js'
export { createIssueTool } from './create-issue.js'
Step 2: Write the Plugin Entry Point
Create src/index.ts:
import { OpenClawPlugin, PluginContext, PluginDefinition } from '@openclaw/sdk'
import { searchIssuesTool, createIssueTool } from './tools/index.js'
const plugin: OpenClawPlugin = {
name: 'openclaw-plugin-github-issues',
version: '1.0.0',
description: 'Search and manage GitHub issues from your OpenClaw agent',
async register(ctx: PluginContext): Promise<PluginDefinition> {
// Validate required configuration at registration time
if (!ctx.env.GITHUB_TOKEN) {
ctx.logger.warn('GITHUB_TOKEN not found in workspace .env — github_* tools will fail at runtime')
}
ctx.logger.info(`GitHub Issues plugin loaded for workspace: ${ctx.workspace.name}`)
return {
tools: [searchIssuesTool, createIssueTool],
channels: [],
providers: [],
}
},
}
export default plugin
Step 3: Wire the Plugin Into Your Workspace
Build the plugin:
pnpm build
In your workspace's AGENTS.md:
plugins:
- name: github-issues
path: /path/to/openclaw-plugin-github-issues/dist/index.js
# Or after publishing to npm:
# package: openclaw-plugin-github-issues
Add the token to your workspace .env:
GITHUB_TOKEN=ghp_your_token_here
Testing Plugins Locally
Good plugin tests have two layers: unit tests for the handler logic (fast, no network), and integration tests against the real API (slower, run before publishing).
Create src/tools/search-issues.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { searchIssuesHandler } from './search-issues.js'
// Mock fetch globally
const mockFetch = vi.fn()
global.fetch = mockFetch
const mockCtx = {
env: { GITHUB_TOKEN: 'test-token' },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}
describe('searchIssuesHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns formatted issues on success', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [
{
number: 42,
title: 'Login button not working',
state: 'open',
html_url: 'https://github.com/owner/repo/issues/42',
body: 'Steps to reproduce: click the login button',
created_at: '2026-04-01T00:00:00Z',
comments: 3,
},
],
}),
})
const result = await searchIssuesHandler(
{ repo: 'owner/repo', query: 'login' },
mockCtx as any
)
expect(result.total_found).toBe(1)
expect(result.issues[0].number).toBe(42)
expect(result.issues[0].title).toBe('Login button not working')
})
it('throws if GITHUB_TOKEN is missing', async () => {
const ctxWithoutToken = { ...mockCtx, env: {} }
await expect(
searchIssuesHandler({ repo: 'owner/repo', query: 'test' }, ctxWithoutToken as any)
).rejects.toThrow('GITHUB_TOKEN is not configured')
})
it('throws on non-200 API response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: 'Forbidden',
})
await expect(
searchIssuesHandler({ repo: 'owner/repo', query: 'test' }, mockCtx as any)
).rejects.toThrow('GitHub API error: 403')
})
})
Run the tests:
pnpm test
Publishing to the Community
Once your plugin is tested and working, publishing to npm makes it installable with a single command by anyone running OpenClaw. Use the AI Agent ROI Calculator to estimate how much time a custom plugin saves your team before deciding whether to publish it as a community package or keep it internal.
Update package.json with appropriate metadata:
{
"name": "openclaw-plugin-github-issues",
"version": "1.0.0",
"description": "OpenClaw plugin for searching and managing GitHub issues",
"keywords": ["openclaw", "openclaw-plugin", "github", "ai-agents"],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist/", "README.md"]
}
The openclaw-plugin keyword is how the community plugin registry discovers plugins. Always include it.
pnpm build
npm publish --access public
After publishing, users install it with:
pnpm add openclaw-plugin-github-issues
And reference it in AGENTS.md:
plugins:
- package: openclaw-plugin-github-issues
Advanced Patterns
Registering a Custom Channel
If you want your agent available on a platform OpenClaw does not support out of the box (Linear, Notion, a custom webhook endpoint), register a channel in your plugin:
import { ChannelDefinition } from '@openclaw/sdk'
const linearChannel: ChannelDefinition = {
name: 'linear',
description: 'Linear issue tracker integration',
async connect(config, messageHandler) {
// Set up your webhook listener or polling here
// Call messageHandler whenever a message arrives
const server = setupWebhookListener(config.webhookSecret)
server.on('message', (msg) => {
messageHandler({
text: msg.content,
sender: msg.userId,
channel: 'linear',
metadata: { issueId: msg.issueId },
})
})
return {
send: async (response) => {
await postLinearComment(response.issueId, response.text)
},
disconnect: async () => {
server.close()
},
}
},
}
Registering an LLM Provider
If you use a model not in OpenClaw's default provider list, or you run a local model behind an OpenAI compatible API, register a custom provider:
import { LLMProviderDefinition } from '@openclaw/sdk'
const localLlamaProvider: LLMProviderDefinition = {
name: 'local-llama',
models: ['llama-3.1-8b', 'llama-3.1-70b'],
async complete(messages, options, ctx) {
const response = await fetch(`${ctx.env.OLLAMA_BASE_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model,
messages,
stream: false,
}),
})
const data = await response.json()
return { content: data.message.content }
},
}
Accessing Workspace Memory from a Plugin
Plugins can read and write long term memory, which enables tools that learn from past interactions:
export const rememberPreferenceHandler: ToolHandler = async (input, ctx) => {
// Write to long term memory
await ctx.memory.store({
key: `preference_${input.key}`,
value: input.value,
tags: ['user_preference'],
})
// Read from long term memory
const existing = await ctx.memory.search({
query: 'user preferences',
tags: ['user_preference'],
limit: 10,
})
return { stored: true, total_preferences: existing.length }
}
What to Build Next
The GitHub issues plugin demonstrates the full plugin lifecycle. From here the patterns scale directly: any HTTP API, any webhook based platform, any local service can become an OpenClaw tool using the same structure.
Look at the plugins we have already built at HouseofMVPs for more real world examples. For integrating your agent into additional AI development workflows, see building AI agents with OpenClaw and the MCP protocol guide for how tools interact with the broader AI tooling ecosystem.
Build With an AI-Native Agency
Free: 14-Day AI MVP Checklist
The exact checklist we use to ship production-ready MVPs in 2 weeks. Enter your email to download.
OpenClaw Plugin Starter Template
A ready to clone plugin starter with TypeScript config, test setup, and a working example tool already wired up.
Frequently Asked Questions
Frequently Asked Questions
Free Estimate in 2 Minutes
Already know your scope? Book a Fixed-Price Scope Review
