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
2 changes: 2 additions & 0 deletions README.ja-JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,13 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています

### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスクは特定 Agent(`agentId`)へ紐付けでき、必要に応じて Telegram などの対象チャンネルへ結果を配信し、任意の `to` ルートも指定できます。

### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。
ClawX はドキュメント処理スキル(`pdf`、`xlsx`、`docx`、`pptx`)もフル内容で同梱し、起動時に管理スキルディレクトリ(既定 `~/.openclaw/skills`)へ自動配備し、初回インストール時に既定で有効化します。追加の同梱スキル(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)も既定で有効化されますが、必要な API キーが未設定の場合は OpenClaw が実行時に設定エラーを表示します。
Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、workspace、追加スキルディレクトリ)から検出されたスキルを表示でき、各スキルの実際のパスを確認して実フォルダを直接開けます。
Skills UI はポリシースコープ管理に対応し、`グローバル` 基準と `エージェント` ごとの上書きを編集できます。選択中エージェント向けの有効スキルバッジも表示されます。エージェント上書きの実行時適用は OpenClaw 側の対応に依存します。

主な検索スキルで必要な環境変数:
- `BRAVE_SEARCH_API_KEY`: `brave-web-search` 用
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,13 @@ Configure and monitor multiple AI channels simultaneously. Each channel operates

### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
Cron tasks can now be bound to a specific Agent (`agentId`) and optionally announce results to a target channel (for example Telegram) with an optional `to` recipient route.

### 🧩 Extensible Skill System
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.
ClawX also pre-bundles full document-processing skills (`pdf`, `xlsx`, `docx`, `pptx`), deploys them automatically to the managed skills directory (default `~/.openclaw/skills`) on startup, and enables them by default on first install. Additional bundled skills (`find-skills`, `self-improving-agent`, `tavily-search`, `brave-web-search`, `bocha-skill`) are also enabled by default; if required API keys are missing, OpenClaw will surface configuration errors in runtime.
The Skills page can display skills discovered from multiple OpenClaw sources (managed dir, workspace, and extra skill dirs), and now shows each skill's actual location so you can open the real folder directly.
Skills now support policy scope management in UI: `Global` baseline plus per-`Agent` overrides, with effective-skill badges for the selected agent. Runtime enforcement of agent overrides depends on OpenClaw support.

Environment variables for bundled search skills:
- `BRAVE_SEARCH_API_KEY` for `brave-web-search`
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们

### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
定时任务现在支持绑定到指定 Agent(`agentId`),并可选将结果投递到目标频道(例如 Telegram),同时支持可选 `to` 目标路由。

### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。
ClawX 还会内置预装完整的文档处理技能(`pdf`、`xlsx`、`docx`、`pptx`),在启动时自动部署到托管技能目录(默认 `~/.openclaw/skills`),并在首次安装时默认启用。额外预装技能(`find-skills`、`self-improving-agent`、`tavily-search`、`brave-web-search`、`bocha-skill`)也会默认启用;若缺少必需的 API Key,OpenClaw 会在运行时给出配置错误提示。
Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、workspace、额外技能目录),并显示每个技能的实际路径,便于直接打开真实安装位置。
Skills 页面现在支持策略作用域管理:可编辑 `全局` 基线和按 `Agent` 的覆盖策略,并为所选 Agent 显示生效技能标记。Agent 覆盖策略是否在运行时生效仍取决于 OpenClaw 支持。

重点搜索技能所需环境变量:
- `BRAVE_SEARCH_API_KEY`:用于 `brave-web-search`
Expand Down
113 changes: 113 additions & 0 deletions docs/feature-request-agent-scoped-skills-and-cron.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Feature Request: Agent-Scoped Skills + Agent-Bound Cron Jobs

## Background

Current behavior in ClawX/OpenClaw integration is effectively global for skills and implicit for cron agent targeting:

- Skills enable/disable uses `skills.update` with only `skillKey` + `enabled`.
- Cron jobs created from ClawX use `payload.kind = agentTurn` and `sessionTarget = isolated`, but no explicit `agentId` is configured in UI/API.

For one-agent setups this is acceptable, but for multi-agent setups (e.g. 7-10 skills split by intent/purpose/goal) this causes:

- skill pollution (all agents see same skill set),
- weak role boundaries,
- less predictable automation behavior.

## Proposal

Introduce two-level skill scope and explicit cron agent binding:

1. Global Skills (shared baseline)
- Available to all agents by default.
- Good for universal utilities (e.g. docs parsing).

2. Agent Skills (agent override layer)
- Per-agent allow/deny list on top of global.
- Supports role-specific skill sets (research/coding/review/ops).

3. Agent-Bound Cron
- Each cron job explicitly stores `agentId`.
- Runtime executes job in that agent context.

## Suggested Data Model

```ts
type SkillPolicy = {
globalEnabled: string[]; // skill keys
agentOverrides: Record<string, { // agentId
enabled?: string[]; // explicit add
disabled?: string[]; // explicit remove
}>;
};

type CronJobExtension = {
agentId: string; // default "main"
};
```

Effective skills for an agent:

`effective(agent) = (globalEnabled - disabled(agent)) U enabled(agent)`

## UI/UX Requirements

1. Skills page:
- Scope switch: `Global` / `Agent`.
- When `Agent` selected, choose target agent and edit overrides.
- Show "effective" badge for current selected agent.

2. Agents page:
- Optional summary card: enabled skill count + overridden skills.

3. Cron page:
- Add `Run as Agent` selector in create/edit dialog.
- List view shows bound agent badge.

## API Requirements (Host API / Main process)

1. Skills
- `GET /api/skills/policy`
- `PUT /api/skills/policy/global`
- `PUT /api/skills/policy/agents/:agentId`
- Keep existing `skills.update` for backward compatibility.

2. Cron
- Extend create/update payload with optional `agentId`.
- Persist and return `agentId` in `/api/cron/jobs`.

## Backward Compatibility

- Existing installations default to:
- all currently enabled skills -> `globalEnabled`
- no per-agent overrides.
- Existing cron jobs default `agentId = "main"`.
- If backend/runtime doesn’t support agent-bound execution yet:
- preserve field in ClawX metadata and surface warning,
- do not silently drop agent binding in UI.

## Acceptance Criteria

1. A skill can be globally enabled but disabled for a specific agent.
2. A skill can be globally disabled but enabled for a specific agent.
3. Cron job can be created/edited with `agentId`.
4. Cron run history/session key resolves to that agent context consistently.
5. Existing users upgrade without behavior regression.

## Why this matters

- Better separation of intents/purposes/goals per agent.
- Lower accidental tool invocation risk.
- Better reproducibility for scheduled workflows.
- Foundation for policy controls (team/workspace governance).

## Implementation Notes

Likely needs cross-repo coordination:

- ClawX UI + Host API changes (this repo).
- OpenClaw gateway/runtime support for agent-scoped skill resolution and cron agent execution.

If runtime support is not available, ship in phases:

1. ClawX-side model + UI + persistence + warnings.
2. Runtime wiring once OpenClaw supports agent-scoped resolution.
81 changes: 70 additions & 11 deletions electron/api/routes/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface GatewayCronJob {
id: string;
name: string;
description?: string;
agentId?: string | null;
enabled: boolean;
createdAtMs: number;
updatedAtMs: number;
Expand Down Expand Up @@ -55,6 +56,56 @@ interface CronSessionFallbackMessage {
isError?: boolean;
}

interface CronDeliveryTargetInput {
channelType?: unknown;
to?: unknown;
}

function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

function buildDeliveryFromTarget(target: unknown): { mode: 'none' } | { mode: 'announce'; channel: string; to?: string } {
if (!target || typeof target !== 'object') {
return { mode: 'none' };
}
const raw = target as CronDeliveryTargetInput;
const channel = normalizeOptionalString(raw.channelType);
if (!channel) {
return { mode: 'none' };
}
const to = normalizeOptionalString(raw.to);
return to
? { mode: 'announce', channel, to }
: { mode: 'announce', channel };
}

function normalizeCronPatch(input: Record<string, unknown>): Record<string, unknown> {
const patch: Record<string, unknown> = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if (Object.prototype.hasOwnProperty.call(patch, 'target')) {
patch.delivery = buildDeliveryFromTarget(patch.target);
delete patch.target;
}
if (Object.prototype.hasOwnProperty.call(patch, 'agentId')) {
if (patch.agentId === null) {
// Keep null to clear job-level agent binding.
} else {
const normalized = normalizeOptionalString(patch.agentId);
patch.agentId = normalized ?? null;
}
}
return patch;
}

function parseCronSessionKey(sessionKey: string): CronSessionKeyParts | null {
if (!sessionKey.startsWith('agent:')) return null;
const parts = sessionKey.split(':');
Expand Down Expand Up @@ -265,7 +316,12 @@ function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const channelType = job.delivery?.channel;
const target = channelType
? { channelType, channelId: channelType, channelName: channelType }
? {
channelType,
channelId: channelType,
channelName: channelType,
...(job.delivery?.to ? { to: job.delivery.to } : {}),
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
Expand All @@ -284,6 +340,7 @@ function transformCronJob(job: GatewayCronJob) {
name: job.name,
message,
schedule: job.schedule,
...(job.agentId ? { agentId: job.agentId } : {}),
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
Expand Down Expand Up @@ -378,15 +435,24 @@ export async function handleCronRoutes(

if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
const input = await parseJsonBody<{
name: string;
message: string;
schedule: string;
enabled?: boolean;
agentId?: string | null;
target?: CronDeliveryTargetInput | null;
}>(req);
const agentId = normalizeOptionalString(input.agentId);
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
...(agentId ? { agentId } : {}),
delivery: buildDeliveryFromTarget(input.target),
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
Expand All @@ -399,14 +465,7 @@ export async function handleCronRoutes(
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
const patch = normalizeCronPatch(input);
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
Expand Down
57 changes: 57 additions & 0 deletions electron/api/routes/skills.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
import {
computeEffectiveSkills,
readSkillPolicy,
updateSkillPolicyAgentOverride,
updateSkillPolicyGlobal,
} from '../../utils/skill-policy';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';

Expand All @@ -9,6 +15,57 @@ export async function handleSkillRoutes(
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/skills/policy' && req.method === 'GET') {
try {
const policy = await readSkillPolicy();
const agentId = (url.searchParams.get('agentId') || '').trim();
sendJson(res, 200, {
success: true,
policy,
...(agentId ? { effective: computeEffectiveSkills(policy, agentId) } : {}),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}

if (url.pathname === '/api/skills/policy/global' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ enabledSkillKeys?: unknown }>(req);
const enabledSkillKeys = Array.isArray(body.enabledSkillKeys)
? body.enabledSkillKeys.filter((v): v is string => typeof v === 'string')
: [];
const policy = await updateSkillPolicyGlobal(enabledSkillKeys);
sendJson(res, 200, { success: true, policy });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}

if (url.pathname.startsWith('/api/skills/policy/agents/') && req.method === 'PUT') {
try {
const agentId = decodeURIComponent(url.pathname.slice('/api/skills/policy/agents/'.length)).trim();
if (!agentId) {
sendJson(res, 400, { success: false, error: 'agentId is required' });
return true;
}
const body = await parseJsonBody<{ enabled?: unknown; disabled?: unknown }>(req);
const enabled = Array.isArray(body.enabled) ? body.enabled.filter((v): v is string => typeof v === 'string') : [];
const disabled = Array.isArray(body.disabled) ? body.disabled.filter((v): v is string => typeof v === 'string') : [];
const policy = await updateSkillPolicyAgentOverride(agentId, { enabled, disabled });
sendJson(res, 200, {
success: true,
policy,
effective: computeEffectiveSkills(policy, agentId),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}

if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
sendJson(res, 200, await getAllSkillConfigs());
return true;
Expand Down
Loading