Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/resources/(resources)/openclaw.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
title: openclaw
description: A reference page for the openclaw resource
---

The openclaw resource installs [OpenClaw](https://docs.openclaw.ai/) — a self-hosted gateway that connects chat channels (Discord, Slack, Telegram, WhatsApp, Signal, iMessage, Matrix, Teams, and more) to AI agents — and manages its configuration. It handles installation via the official installer script and gives you declarative control over the gateway, agents, models, channels, tools, and every other section of the OpenClaw config.

## Parameters

- **settings**: *(object, optional)* Top-level keys to merge into `~/.openclaw/openclaw.json`. On apply, the declared keys are written; on destroy, only the declared keys are removed. Common sections include:
- `gateway` — `mode` (`"local"` | `"remote"`), `port` (default `18789`), `bind` (`"loopback"` | `"lan"` | `"tailnet"` | `"auto"` | `"custom"`), `auth`, `tls`, `controlUi`
- `agents` — `defaults.{workspace,model,thinking,heartbeat,memory,media,skills}`, `list[]` for per-agent overrides
- `models` — `pricing.enabled`, `mode` (`"merge"` | `"replace"`), `providers` (custom/local model providers such as Ollama or LM Studio)
- `channels` — per-provider sections under `channels.<provider>` (e.g. `discord`, `slack`, `telegram`, `whatsapp`, `signal`, `imessage`, `matrix`, `teams`). A channel starts automatically once its config section exists (unless `enabled: false`). Common fields: `dmPolicy`, `groupPolicy`, `allowFrom`, `mediaMaxMb`, `historyLimit`, plus provider-specific credentials (`token`, `botToken`, `appToken`, etc.)
- `tools` — `policy.{allow,deny}` lists controlling which tools (`exec`, `read`, `write`, `browser`, `web_search`, `cron`, etc.) agents can call
- `skills` — `allowBundled`, `load.extraDirs`, `install.nodeManager`
- `plugins` — `enabled`, `allow`/`deny`, `entries.*`
- `mcp` — `servers`, `sessionIdleTtlMs`
- `browser`, `logging`, `cron`, `hooks`, `ui`, `env`, `secrets`, `auth`, `discovery`, `acp` — see the [configuration reference](https://docs.openclaw.ai/gateway/configuration-reference) for the full list of fields

## Example usage

### Install OpenClaw with gateway and agent defaults

```json title="codify.jsonc"
[
{
"type": "openclaw",
"settings": {
"gateway": { "port": 18789, "bind": "loopback" },
"agents": { "defaults": { "model": "anthropic/claude-sonnet-4-6" } }
}
}
]
```

### OpenClaw with a Telegram channel and restricted tools

```json title="codify.jsonc"
[
{
"type": "openclaw",
"settings": {
"channels": {
"telegram": {
"botToken": "<Replace me here!>",
"dmPolicy": "allowlist",
"allowFrom": ["123456789"]
}
},
"tools": {
"policy": { "allow": ["exec", "read", "write", "web_search"] }
}
}
}
]
```

## Notes

- OpenClaw is installed via the official installer (`curl -fsSL https://openclaw.ai/install.sh | bash`) on both macOS and Linux.
- The configuration file lives at `~/.openclaw/openclaw.json` and uses JSON5 (Codify reads/writes it as plain JSON, so any comments in a hand-edited file will not be preserved).
- The `settings` parameter merges only the declared top-level keys. Existing sections not in your Codify config are left untouched.
- After applying settings changes, Codify runs `openclaw gateway restart` so the running gateway picks up the new configuration.
- On destroy, the declared `settings` keys are removed and the OpenClaw binary, config, and state directory (`~/.openclaw`) are removed.
- Model provider authentication (API keys) and full guided onboarding are not managed by this resource — configure credentials under `settings.models` / `settings.auth`, or run `openclaw onboard` manually for an interactive setup.
- See the [OpenClaw configuration reference](https://docs.openclaw.ai/gateway/configuration-reference) for the complete list of configuration sections and fields.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Pnpm } from './resources/javascript/pnpm/pnpm.js';
import { MacosSettingsResource } from './resources/macos/macos-settings/macos-settings-resource.js';
import { MacportsResource } from './resources/macports/macports.js';
import { ClaudeCodeResource } from './resources/claude-code/claude-code.js';
import { OpenClawResource } from './resources/openclaw/openclaw.js';
import { ClaudeCodeProjectResource } from './resources/claude-code/claude-code-project.js';
import { OllamaResource } from './resources/ollama/ollama.js';
import { PgcliResource } from './resources/pgcli/pgcli.js';
Expand Down Expand Up @@ -127,6 +128,7 @@ runPlugin(Plugin.create(
new SyncthingDeviceResource(),
new SyncthingFolderResource(),
new RbenvResource(),
new OpenClawResource(),
],
{ minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION }
))
128 changes: 128 additions & 0 deletions src/resources/openclaw/openclaw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
CreatePlan,
DestroyPlan,
ExampleConfig,
Resource,
ResourceSettings,
SpawnStatus,
getPty,
z,
} from '@codifycli/plugin-core';
import { OS } from '@codifycli/schemas';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { OpenClawSettingsParameter } from './settings-parameter.js';

const schema = z
.object({
settings: z
.record(z.string(), z.unknown())
.optional()
.describe(
'Top-level keys to merge into ~/.openclaw/openclaw.json. Supports gateway, agents, ' +
'models, channels, tools, skills, plugins, mcp, browser, cron, and all other ' +
'OpenClaw configuration sections.'
),
})
.meta({ $comment: 'https://codifycli.com/docs/resources/openclaw/openclaw' })
.describe('OpenClaw installation and gateway configuration management');

export type OpenClawConfig = z.infer<typeof schema>;

const defaultConfig: Partial<OpenClawConfig> = {
settings: {},
};

const exampleBasic: ExampleConfig = {
title: 'Install OpenClaw with gateway and agent defaults',
description:
'Install OpenClaw and configure the local gateway port/bind address along with the ' +
'default agent model.',
configs: [
{
type: 'openclaw',
settings: {
gateway: { port: 18789, bind: 'loopback' },
agents: { defaults: { model: 'anthropic/claude-sonnet-4-6' } },
},
},
],
};

const exampleWithChannels: ExampleConfig = {
title: 'OpenClaw with a Telegram channel and restricted tools',
description:
'Install OpenClaw, connect a Telegram bot channel restricted to an allowlist, and limit ' +
'the agent tool policy to a safe subset.',
configs: [
{
type: 'openclaw',
settings: {
channels: {
telegram: {
botToken: '<Replace me here!>',
dmPolicy: 'allowlist',
allowFrom: ['123456789'],
},
},
tools: {
policy: { allow: ['exec', 'read', 'write', 'web_search'] },
},
},
},
],
};

export class OpenClawResource extends Resource<OpenClawConfig> {
getSettings(): ResourceSettings<OpenClawConfig> {
return {
id: 'openclaw',
defaultConfig,
exampleConfigs: {
example1: exampleBasic,
example2: exampleWithChannels,
},
operatingSystems: [OS.Darwin, OS.Linux],
schema,
parameterSettings: {
settings: { type: 'stateful', definition: new OpenClawSettingsParameter(), order: 1 },
},
};
}

async refresh(_parameters: Partial<OpenClawConfig>): Promise<Partial<OpenClawConfig> | null> {
const $ = getPty();

const { status } = await $.spawnSafe('which openclaw');
if (status !== SpawnStatus.SUCCESS) {
return null;
}

return {};
}

async create(_plan: CreatePlan<OpenClawConfig>): Promise<void> {
const $ = getPty();

await $.spawn(
'bash -c "curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard --no-prompt"',
{ interactive: true },
);

// Ensure PATH is updated so subsequent lifecycle methods can call `openclaw`
const localBin = path.join(os.homedir(), '.local', 'bin');
process.env['PATH'] = `${localBin}:${process.env['PATH'] ?? ''}`;
}

async destroy(_plan: DestroyPlan<OpenClawConfig>): Promise<void> {
const $ = getPty();

await $.spawnSafe('openclaw gateway stop', { interactive: true });
await $.spawnSafe('npm uninstall -g openclaw', { interactive: true });
await $.spawnSafe('rm -f ~/.local/bin/openclaw', { interactive: true });

await fs.rm(path.join(os.homedir(), '.openclaw'), { recursive: true, force: true });
}
}
75 changes: 75 additions & 0 deletions src/resources/openclaw/settings-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ParameterSetting, Plan, StatefulParameter, getPty } from '@codifycli/plugin-core';
import { StringIndexedObject } from '@codifycli/schemas';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

type Settings = Record<string, unknown>;

export const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), '.openclaw', 'openclaw.json');

export class OpenClawSettingsParameter extends StatefulParameter<StringIndexedObject, Settings> {
getSettings(): ParameterSetting {
return { type: 'object' };
}

override async refresh(_desired: Settings | null): Promise<Settings | null> {
try {
const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8');
return JSON.parse(content) as Settings;
} catch {
return null;
}
}

async add(valueToAdd: Settings, plan: Plan<StringIndexedObject>): Promise<void> {
await this.mergeIntoFile(valueToAdd);
await this.restartGateway();
}

async modify(newValue: Settings, previousValue: Settings, plan: Plan<StringIndexedObject>): Promise<void> {
let existing: Settings = {};
try {
existing = JSON.parse(await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8'));
} catch { /* file may not exist */ }

for (const key of Object.keys(previousValue)) {
if (!(key in newValue)) {
delete existing[key];
}
}

Object.assign(existing, newValue);

await fs.mkdir(path.dirname(OPENCLAW_CONFIG_PATH), { recursive: true });
await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(existing, null, 2));
await this.restartGateway();
}

async remove(valueToRemove: Settings, plan: Plan<StringIndexedObject>): Promise<void> {
try {
const existing = JSON.parse(await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8')) as Settings;
for (const key of Object.keys(valueToRemove)) {
delete existing[key];
}
await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(existing, null, 2));
} catch { /* nothing to do if file doesn't exist */ }

await this.restartGateway();
}

private async mergeIntoFile(settings: Settings): Promise<void> {
let existing: Settings = {};
try {
existing = JSON.parse(await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8'));
} catch { /* file may not exist yet */ }

await fs.mkdir(path.dirname(OPENCLAW_CONFIG_PATH), { recursive: true });
await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify({ ...existing, ...settings }, null, 2));
}

private async restartGateway(): Promise<void> {
const $ = getPty();
await $.spawnSafe('openclaw gateway restart', { interactive: true });
}
}
75 changes: 75 additions & 0 deletions test/openclaw/openclaw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { PluginTester, testSpawn } from '@codifycli/plugin-test';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';

const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), '.openclaw', 'openclaw.json');

describe('openclaw resource integration tests', async () => {
const pluginPath = path.resolve('./src/index.ts');

it('Can install openclaw', { timeout: 300_000 }, async () => {
await PluginTester.fullTest(
pluginPath,
[{ type: 'openclaw' }],
{
skipUninstall: true,
validateApply: async () => {
const { data } = await testSpawn('which openclaw');
expect(data.trim().length).toBeGreaterThan(0);
},
},
);
});

it('Can manage settings', { timeout: 300_000 }, async () => {
const initialSettings = {
gateway: { port: 18789, bind: 'loopback' },
logging: { level: 'debug' },
};

const modifiedSettings = {
gateway: { port: 18790, bind: 'loopback' },
logging: { level: 'debug' },
};

await PluginTester.fullTest(
pluginPath,
[{ type: 'openclaw', settings: initialSettings }],
{
validateApply: async () => {
const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(content);
expect(parsed.gateway.port).toBe(18789);
expect(parsed.logging.level).toBe('debug');
},
testModify: {
modifiedConfigs: [{ type: 'openclaw', settings: modifiedSettings }],
validateModify: async () => {
const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(content);
expect(parsed.gateway.port).toBe(18790);
},
},
validateDestroy: async () => {
try {
const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(content);
expect(parsed.gateway).toBeUndefined();
} catch {
// file removed entirely is also acceptable
}
},
},
);
});

afterAll(async () => {
// Best-effort cleanup in case tests left openclaw installed
await testSpawn('openclaw gateway stop');
await testSpawn('npm uninstall -g openclaw');
await testSpawn('rm -f ~/.local/bin/openclaw');
await testSpawn('rm -rf ~/.openclaw');
}, 60_000);
});