OpenClawPluginsDeveloper ToolsTypeScriptTutorial

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.

HouseofMVPs··8 min read

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 repo scope

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 file
  • ctx.workspace — the current workspace path and config
  • ctx.logger — structured logger for plugin output
  • ctx.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

Security-First Architecture
Production-Ready in 14 Days
Fixed Scope & Price
AI-Optimized Engineering
Start Your Build

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

50+ products shipped$10M+ funding raised2-week delivery

Already know your scope? Book a Fixed-Price Scope Review

Get Your Fixed-Price MVP Estimate