Building Plugins¶
This guide walks through building a new OpenClaw Enterprise plugin from scratch, including the entry point, tools, services, tests, and the paired SKILL.md.
Prerequisites¶
- Node.js >= 22
- pnpm >= 9.15
- Familiarity with TypeScript strict mode and ESM
- Understanding of the Plugin Architecture
Plugin Entry Point¶
Every plugin exports an activate function in src/plugin.ts. This is the only required export. The runtime calls it once during initialization and passes the OpenClawPluginAPI object.
// plugins/connector-example/src/plugin.ts
import type { OpenClawPluginAPI } from './openclaw-types.js';
import type { GatewayMethods } from '@openclaw-enterprise/shared/connector-base.js';
export function activate(api: OpenClawPluginAPI): void {
// Register tools
api.registerTool({
name: 'example_read',
description: 'Read data from the example service.',
parameters: {
type: 'object',
properties: {
id: { type: 'string', description: 'Item identifier' },
},
required: ['id'],
},
execute: async (params) => {
const { id, _gateway, _tenantId, _userId } = params as {
id: string;
_gateway: GatewayMethods;
_tenantId: string;
_userId: string;
};
// Implementation here
},
});
// Register services
api.registerService({
name: 'example-poller',
start: async () => { /* start polling */ },
stop: async () => { /* stop polling */ },
healthCheck: async () => ({ status: 'healthy' }),
});
}
Important: The
_gateway,_tenantId, and_userIdfields are injected at runtime by the OpenClaw gateway on a per-request basis. During registration, you define the tool shape; the execution context is bound when the tool is actually invoked.
Example: Building a Connector Plugin¶
This section builds a complete connector plugin that integrates with a hypothetical "Acme Tasks" API.
Step 1: Create the plugin directory¶
plugins/connector-acme/
src/
plugin.ts
openclaw-types.ts
tools/
read.ts
services/
poller.ts
tests/
acme.test.ts
SKILL.md
package.json
tsconfig.json
Step 2: Set up package.json¶
{
"name": "@openclaw-enterprise/connector-acme",
"version": "0.1.0",
"type": "module",
"main": "dist/plugin.js",
"types": "dist/plugin.d.ts",
"scripts": {
"build": "tsc"
},
"dependencies": {
"@openclaw-enterprise/shared": "workspace:*"
}
}
Step 3: Set up tsconfig.json¶
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
Step 4: Implement the tool using ConnectorBase¶
The ConnectorBase abstract class provides the standard pipeline for all connector operations: policy check, fetch, extract, classify, audit. Use it by extending the class and implementing the fetch and extract functions.
// plugins/connector-acme/src/tools/read.ts
import { ConnectorBase } from '@openclaw-enterprise/shared/connector-base.js';
import type { GatewayMethods } from '@openclaw-enterprise/shared/connector-base.js';
import type { ConnectorReadResult } from '@openclaw-enterprise/shared/types.js';
interface AcmeTask {
id: string;
title: string;
description: string;
assignee: string;
status: string;
updatedAt: string;
}
interface AcmeApiResponse {
tasks: AcmeTask[];
}
export class AcmeReadTools extends ConnectorBase {
private readonly apiToken: string;
constructor(
gateway: GatewayMethods,
tenantId: string,
userId: string,
apiToken: string,
) {
// ConnectorType must be one of the defined types, but for a custom
// connector you would extend the ConnectorType union in shared/types.ts
super('acme' as any, gateway, tenantId, userId);
this.apiToken = apiToken;
}
async listTasks(params: { projectId: string }): Promise<ConnectorReadResult> {
return this.executeRead<AcmeApiResponse>(
'acme_list_tasks',
params,
// fetchRaw: get raw data from the external API
async () => {
const response = await fetch(
`https://api.acme.dev/v1/projects/${params.projectId}/tasks`,
{ headers: { Authorization: `Bearer ${this.apiToken}` } },
);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json() as Promise<AcmeApiResponse>;
},
// extract: convert raw data to ConnectorReadResult (raw is discarded after this)
(raw) => ({
items: raw.tasks.map((task) => ({
id: `acme-${task.id}`,
source: 'acme',
sourceId: task.id,
title: task.title,
summary: task.description.slice(0, 200),
classification: 'internal', // Will be reclassified by policy.classify
url: `https://acme.dev/tasks/${task.id}`,
metadata: { assignee: task.assignee, status: task.status },
timestamp: task.updatedAt,
})),
connectorStatus: 'ok',
}),
);
}
}
The ConnectorBase Pipeline¶
The executeRead<T>() method runs a five-step pipeline on every read operation:
- Policy evaluation -- Calls
policy.evaluatevia GatewayMethod. If denied, returns immediately with an error status and zero items. - Fetch raw data -- Calls your
fetchRawfunction to retrieve data from the external API. - Extract structured data -- Calls your
extractfunction to convert raw API response intoConnectorReadResult. The raw data is discarded after this point (never persisted). - Classify each item -- Calls
policy.classifyvia GatewayMethod for each item. Items may be reclassified above the connector default. - Audit log -- Calls
audit.logvia GatewayMethod to record the data access.
The executeWrite() method follows a similar pattern: policy check, perform write, audit log.
Important: Raw user data (email bodies, message content, document text) is processed in the extract step and then discarded. Only structured extractions (titles, summaries, metadata) are persisted. This is a constitutional requirement.
Step 5: Wire up the plugin entry point¶
// plugins/connector-acme/src/plugin.ts
import type { OpenClawPluginAPI } from './openclaw-types.js';
import type { GatewayMethods } from '@openclaw-enterprise/shared/connector-base.js';
import { AcmeReadTools } from './tools/read.js';
export function activate(api: OpenClawPluginAPI): void {
api.registerTool({
name: 'acme_list_tasks',
description: 'List tasks from an Acme project. Returns structured data (title, description summary, assignee, status).',
parameters: {
type: 'object',
properties: {
projectId: { type: 'string', description: 'The Acme project ID' },
},
required: ['projectId'],
},
execute: async (params) => {
const { projectId, _gateway, _tenantId, _userId, _apiToken } = params as {
projectId: string;
_gateway: GatewayMethods;
_tenantId: string;
_userId: string;
_apiToken: string;
};
const tools = new AcmeReadTools(_gateway, _tenantId, _userId, _apiToken);
return tools.listTasks({ projectId });
},
});
api.registerService({
name: 'acme-poller',
start: async () => { /* Start polling for task updates */ },
stop: async () => { /* Stop polling */ },
healthCheck: async () => ({ status: 'healthy' }),
});
}
Step 6: Using GatewayMethods for Policy and Audit¶
If you are not using ConnectorBase (for example, in a non-connector plugin), you call GatewayMethods directly:
// Evaluate policy before performing an action
const policyResult = await gateway['policy.evaluate']({
tenantId,
userId,
action: 'my_custom_action',
context: {
dataClassification: 'internal',
targetSystem: 'acme',
additional: { projectId: 'proj-1' },
},
});
if (policyResult.decision === 'deny') {
throw new PolicyDeniedError('my_custom_action', policyResult.policyApplied, policyResult.reason);
}
// Log the action to the audit trail
await gateway['audit.log']({
tenantId,
userId,
actionType: 'tool_invocation',
actionDetail: { tool: 'my_custom_action' },
dataClassification: 'internal',
policyApplied: policyResult.policyApplied,
policyResult: policyResult.decision,
policyReason: policyResult.reason,
outcome: 'success',
});
Warning: Every plugin MUST handle the policy engine being unreachable. The default behavior is fail closed -- deny all actions. Use the
PolicyEngineUnreachableErrorerror class for this case.
Creating the Paired SKILL.md¶
Every plugin needs a SKILL.md that teaches the agent how to use the plugin's tools. The skill file is placed at the root of the plugin directory.
# Acme Tasks Skill
You have access to the Acme Tasks integration.
## Available Tools
### acme_list_tasks
Lists tasks from an Acme project. Use this when the user asks about their
Acme tasks, project status, or task assignments.
**When to use:**
- User asks "What are my tasks in Acme?"
- User asks about project status
- User asks about task assignments
**Parameters:**
- projectId (required): The Acme project ID
**Important:**
- Results are filtered by policy. Some tasks may be excluded based on
data classification.
- If access is denied, inform the user that their policy does not allow
Acme access and suggest contacting their admin.
Adding Tests with Vitest¶
Tests go in plugins/{name}/tests/ and use the .test.ts extension. See the Testing Guide for full details.
// plugins/connector-acme/tests/acme.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AcmeReadTools } from '../src/tools/read.js';
import type { GatewayMethods } from '@openclaw-enterprise/shared/connector-base.js';
function createMockGateway(): GatewayMethods {
return {
'policy.evaluate': vi.fn().mockResolvedValue({
decision: 'allow',
policyApplied: 'test-policy',
reason: 'Allowed by test',
constraints: {},
}),
'policy.classify': vi.fn().mockResolvedValue({
classification: 'internal',
assignedBy: 'connector_default',
originalLevel: null,
confidence: 1.0,
}),
'audit.log': vi.fn().mockResolvedValue({ id: 'audit-001' }),
};
}
describe('AcmeReadTools', () => {
let gateway: GatewayMethods;
beforeEach(() => {
gateway = createMockGateway();
vi.restoreAllMocks();
});
it('evaluates policy before reading tasks', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tasks: [] }),
}) as unknown as typeof fetch;
const tools = new AcmeReadTools(gateway, 'tenant-1', 'user-1', 'token');
await tools.listTasks({ projectId: 'proj-1' });
expect(gateway['policy.evaluate']).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: 'tenant-1',
userId: 'user-1',
action: 'acme_list_tasks',
}),
);
});
it('returns empty result when policy denies', async () => {
gateway = {
...createMockGateway(),
'policy.evaluate': vi.fn().mockResolvedValue({
decision: 'deny',
policyApplied: 'restrict-acme',
reason: 'Not authorized',
constraints: {},
}),
};
const tools = new AcmeReadTools(gateway, 'tenant-1', 'user-1', 'token');
const result = await tools.listTasks({ projectId: 'proj-1' });
expect(result.items).toHaveLength(0);
expect(result.connectorStatus).toBe('error');
});
});
TypeScript and ESM Requirements¶
All plugins must follow these TypeScript and module conventions:
- Strict mode: TypeScript strict mode is enabled via
tsconfig.base.json. Noanytypes in production code. - ESM modules: The project uses
"type": "module"inpackage.json. All imports must use.jsextensions:
// Correct
import { PolicyDeniedError } from '@openclaw-enterprise/shared/errors.js';
import type { PolicyScope } from '@openclaw-enterprise/shared/types.js';
import { AcmeReadTools } from './tools/read.js';
// Incorrect -- will fail at runtime
import { PolicyDeniedError } from '@openclaw-enterprise/shared/errors';
import { AcmeReadTools } from './tools/read';
verbatimModuleSyntax: Enabled in tsconfig. Useimport typefor type-only imports:
- Target: ES2024 with Node16 module resolution.
Registering Health Checks¶
Every plugin must provide a health check. For plugins using ConnectorBase, the base class provides a healthCheck() method that reports disabled status. For other plugins, implement health checks via the service registration:
import type { HealthCheckResult } from '@openclaw-enterprise/shared/health.js';
api.registerService({
name: 'my-service',
start: async () => { /* ... */ },
stop: async () => { /* ... */ },
healthCheck: async (): Promise<HealthCheckResult> => {
try {
// Check connectivity, dependencies, etc.
return { status: 'healthy' };
} catch (error) {
return {
status: 'unhealthy',
detail: error instanceof Error ? error.message : 'Unknown error',
};
}
},
});
Health check results are aggregated by the gateway into GET /api/v1/status. See the Shared Library Reference for aggregateHealth and safeHealthCheck utilities.
Error Handling¶
Use the shared error classes from @openclaw-enterprise/shared/errors.js. These provide consistent error codes and messages across all plugins.
| Error Class | Code | When to Use |
|---|---|---|
PolicyEngineUnreachableError |
POLICY_ENGINE_UNREACHABLE |
Cannot connect to OPA sidecar |
PolicyDeniedError |
POLICY_DENIED |
Policy explicitly denies an action |
PolicyApprovalRequiredError |
POLICY_APPROVAL_REQUIRED |
Policy requires human approval |
PolicyHierarchyViolationError |
POLICY_HIERARCHY_VIOLATION |
Child scope tries to expand beyond parent |
ClassificationViolationError |
CLASSIFICATION_VIOLATION |
Data exceeds allowed classification |
ConnectorUnavailableError |
CONNECTOR_UNAVAILABLE |
External API is unreachable |
OAuthRevocationError |
OAUTH_REVOKED |
OAuth token has been revoked |
ExchangeRoundLimitError |
EXCHANGE_ROUND_LIMIT |
Agent exchange exceeded max rounds |
CommitmentRequiresHumanError |
COMMITMENT_REQUIRES_HUMAN |
Agent exchange requires human approval for commitment |
CrossEnterpriseBlockedError |
CROSS_ENTERPRISE_BLOCKED |
Cross-enterprise exchange blocked by policy |
AuditWriteError |
AUDIT_WRITE_FAILED |
Failed to write to audit log |
import {
PolicyDeniedError,
PolicyEngineUnreachableError,
ConnectorUnavailableError,
} from '@openclaw-enterprise/shared/errors.js';
try {
const result = await gateway['policy.evaluate'](request);
if (result.decision === 'deny') {
throw new PolicyDeniedError(action, result.policyApplied, result.reason);
}
} catch (error) {
if (error instanceof PolicyDeniedError) {
// Known denial -- return gracefully
} else {
// Policy engine unreachable -- fail closed
throw new PolicyEngineUnreachableError(
error instanceof Error ? error.message : undefined,
);
}
}
Checklist for a New Plugin¶
-
src/plugin.tswithactivate(api)function - Tools registered with
api.registerTool() - Policy evaluation before every action (via
ConnectorBaseor direct GatewayMethod call) - Audit logging for all state-changing operations
- Health check via
api.registerService()withhealthCheckcallback -
SKILL.mdteaching the agent when and how to use the tools - Tests in
tests/with >80% coverage -
package.jsonwith@openclaw-enterprise/shareddependency -
tsconfig.jsonextending../../tsconfig.base.json - ESM imports with
.jsextensions - No
anytypes in production code - Error handling using shared error classes
- Fail-closed behavior when policy engine is unreachable