@qiov/tsmai-api-openclaw 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,306 @@
1
+ # tsmai-api-openclaw
2
+
3
+ OpenClaw 的 HTTP API 频道插件,通过 REST API 暴露 OpenClaw 上的 Agent 能力。
4
+
5
+ ## 功能
6
+
7
+ - 通过标准 HTTP 接口调用 OpenClaw 上任意已注册的 Agent
8
+ - 支持同步调用(`/api/chat`)和 SSE 流式调用(`/api/chat/stream`)
9
+ - 支持多轮对话(通过 `conversationId` 维持上下文)
10
+ - 支持指定目标 Agent 或使用默认 main Agent
11
+ - 可选 API Key 认证
12
+ - 跨域(CORS)支持
13
+
14
+ ## 目录结构
15
+
16
+ ```
17
+ tsmai-api-openclaw/
18
+ ├── index.ts # 插件入口,注册 channel
19
+ ├── package.json # 包配置 & OpenClaw 插件声明
20
+ ├── openclaw.plugin.json # OpenClaw 插件元数据
21
+ ├── README.md
22
+ └── src/
23
+ ├── channel.ts # ChannelPlugin 定义(配置、状态、网关启动)
24
+ ├── config-schema.ts # Zod 配置 schema
25
+ ├── http-server.ts # HTTP 服务核心(路由、同步/流式处理)
26
+ ├── onboarding.ts # 配置向导(openclaw onboard 时交互式配置)
27
+ └── runtime.ts # PluginRuntime 管理
28
+ ```
29
+
30
+ ## 安装
31
+
32
+ ```bash
33
+ openclaw plugins install @qiov/tsmai-api-openclaw
34
+ ```
35
+
36
+ 安装后插件会自动注册到 `openclaw.json`,只需添加 channel 配置即可:
37
+
38
+ ```bash
39
+ # 编辑 ~/.openclaw/openclaw.json,在 channels 中添加:
40
+ # "api": { "enabled": true, "port": 3100, "host": "0.0.0.0" }
41
+
42
+ # 重启 gateway
43
+ openclaw gateway restart
44
+ ```
45
+
46
+ ## 配置
47
+
48
+ 在 `~/.openclaw/openclaw.json` 中配置:
49
+
50
+ ```json
51
+ {
52
+ "channels": {
53
+ "api": {
54
+ "enabled": true,
55
+ "port": 3100,
56
+ "host": "0.0.0.0",
57
+ "apiKey": ""
58
+ }
59
+ },
60
+ "plugins": {
61
+ "entries": {
62
+ "tsmai-api-openclaw": {
63
+ "enabled": true
64
+ }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ | 配置项 | 类型 | 默认值 | 说明 |
71
+ |--------|------|--------|------|
72
+ | `enabled` | boolean | `true` | 是否启用 |
73
+ | `port` | number | `3100` | HTTP 服务监听端口 |
74
+ | `host` | string | `0.0.0.0` | HTTP 服务监听地址 |
75
+ | `apiKey` | string | `""` | API Key(为空则不启用认证) |
76
+
77
+ 也支持通过环境变量配置:`API_OPENCLAW_PORT`、`API_OPENCLAW_HOST`、`API_OPENCLAW_API_KEY`。
78
+
79
+ ## API 接口
80
+
81
+ ### 健康检查
82
+
83
+ ```
84
+ GET /health
85
+ ```
86
+
87
+ **响应**:
88
+
89
+ ```json
90
+ { "ok": true, "status": "running" }
91
+ ```
92
+
93
+ ---
94
+
95
+ ### 同步调用
96
+
97
+ ```
98
+ POST /api/chat
99
+ Content-Type: application/json
100
+ ```
101
+
102
+ 等待 Agent 处理完成后返回完整回复,适用于简单快速的查询场景。
103
+
104
+ **请求体**:
105
+
106
+ ```json
107
+ {
108
+ "prompt": "分析一下最近的服务器告警",
109
+ "agent": "ops-lead",
110
+ "conversationId": "session-123"
111
+ }
112
+ ```
113
+
114
+ | 字段 | 必填 | 类型 | 说明 |
115
+ |------|------|------|------|
116
+ | `prompt` | 是 | string | 用户提示词 |
117
+ | `agent` | 否 | string | 目标 Agent ID,不传则使用 main agent |
118
+ | `conversationId` | 否 | string | 会话 ID,用于多轮对话,不传自动生成 |
119
+
120
+ **成功响应**:
121
+
122
+ ```json
123
+ {
124
+ "ok": true,
125
+ "text": "根据分析,最近的告警主要集中在...",
126
+ "conversationId": "session-123"
127
+ }
128
+ ```
129
+
130
+ **错误响应**:
131
+
132
+ ```json
133
+ {
134
+ "ok": false,
135
+ "error": "Missing required field: prompt"
136
+ }
137
+ ```
138
+
139
+ **curl 示例**:
140
+
141
+ ```bash
142
+ # 使用默认 main agent
143
+ curl -X POST http://localhost:3100/api/chat \
144
+ -H 'Content-Type: application/json' \
145
+ -d '{"prompt": "你好"}'
146
+
147
+ # 指定 agent
148
+ curl -X POST http://localhost:3100/api/chat \
149
+ -H 'Content-Type: application/json' \
150
+ -d '{"prompt": "分析告警", "agent": "ops-lead"}'
151
+
152
+ # 多轮对话
153
+ curl -X POST http://localhost:3100/api/chat \
154
+ -H 'Content-Type: application/json' \
155
+ -d '{"prompt": "继续分析", "agent": "ops-lead", "conversationId": "session-123"}'
156
+ ```
157
+
158
+ ---
159
+
160
+ ### SSE 流式调用
161
+
162
+ ```
163
+ POST /api/chat/stream
164
+ Content-Type: application/json
165
+ ```
166
+
167
+ 以 Server-Sent Events (SSE) 格式实时推送 Agent 处理进度,适用于复杂任务、长时间处理的场景。
168
+
169
+ **请求体**:与 `/api/chat` 完全一致。
170
+
171
+ **响应**:`Content-Type: text/event-stream`
172
+
173
+ #### SSE 事件类型
174
+
175
+ | 事件 | 触发时机 | data 字段 |
176
+ |------|---------|----------|
177
+ | `start` | 请求被接受,开始处理 | `{ "conversationId": "...", "agent": "..." }` |
178
+ | `chunk` | AI 生成了一段增量文本 | `{ "text": "增量文本", "index": 0 }` |
179
+ | `tool` | 工具/技能调用完成 | `{ "name": "tool-name", "status": "done" }` |
180
+ | `error` | 处理出错 | `{ "error": "错误信息" }` |
181
+ | `done` | 处理完成 | `{ "text": "完整文本", "conversationId": "..." }` |
182
+
183
+ **响应示例**:
184
+
185
+ ```
186
+ event: start
187
+ data: {"conversationId":"session-123","agent":"ops-lead"}
188
+
189
+ event: chunk
190
+ data: {"text":"我是","index":0}
191
+
192
+ event: chunk
193
+ data: {"text":" IT 运维","index":1}
194
+
195
+ event: chunk
196
+ data: {"text":"团队的运维主管","index":2}
197
+
198
+ event: done
199
+ data: {"text":"我是 IT 运维团队的运维主管...","conversationId":"session-123"}
200
+ ```
201
+
202
+ **curl 示例**:
203
+
204
+ ```bash
205
+ curl -N -X POST http://localhost:3100/api/chat/stream \
206
+ -H 'Content-Type: application/json' \
207
+ -d '{"prompt": "用3段话介绍你自己", "agent": "ops-lead"}'
208
+ ```
209
+
210
+ **JavaScript 客户端示例**:
211
+
212
+ ```javascript
213
+ async function chatStream(prompt, agent) {
214
+ const resp = await fetch('http://localhost:3100/api/chat/stream', {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ prompt, agent }),
218
+ });
219
+
220
+ const reader = resp.body.getReader();
221
+ const decoder = new TextDecoder();
222
+ let buffer = '';
223
+
224
+ while (true) {
225
+ const { done, value } = await reader.read();
226
+ if (done) break;
227
+
228
+ buffer += decoder.decode(value, { stream: true });
229
+ const lines = buffer.split('\n');
230
+ buffer = lines.pop() || '';
231
+
232
+ let eventType = '';
233
+ for (const line of lines) {
234
+ if (line.startsWith('event: ')) {
235
+ eventType = line.slice(7);
236
+ } else if (line.startsWith('data: ')) {
237
+ const data = JSON.parse(line.slice(6));
238
+ switch (eventType) {
239
+ case 'start':
240
+ console.log('Started:', data.agent);
241
+ break;
242
+ case 'chunk':
243
+ process.stdout.write(data.text);
244
+ break;
245
+ case 'done':
246
+ console.log('\n\nDone. Full text:', data.text.length, 'chars');
247
+ break;
248
+ case 'error':
249
+ console.error('Error:', data.error);
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ chatStream('介绍你自己', 'ops-lead');
258
+ ```
259
+
260
+ ---
261
+
262
+ ### 认证
263
+
264
+ 如果配置了 `apiKey`,所有 API 请求需要携带认证信息,支持两种方式:
265
+
266
+ ```bash
267
+ # 方式一:X-API-Key header
268
+ curl -H 'X-API-Key: your-api-key' ...
269
+
270
+ # 方式二:Bearer token
271
+ curl -H 'Authorization: Bearer your-api-key' ...
272
+ ```
273
+
274
+ 未认证请求返回:
275
+
276
+ ```json
277
+ { "ok": false, "error": "Unauthorized: invalid API key" }
278
+ ```
279
+
280
+ ## Agent 路由
281
+
282
+ - **指定 `agent` 参数**:直接路由到对应的 Agent(需要在 `openclaw.json` 的 `agents.list` 中已注册)
283
+ - **不指定 `agent`**:使用 `main` Agent(OpenClaw 默认 Agent)
284
+
285
+ 每个 Agent 可以独立配置不同的模型,例如:
286
+
287
+ ```json
288
+ {
289
+ "agents": {
290
+ "list": [
291
+ { "id": "ops-lead", "model": "tione/kimi-k2.5" },
292
+ { "id": "ops-l1", "model": "minimax/MiniMax-M2.5" }
293
+ ]
294
+ }
295
+ }
296
+ ```
297
+
298
+ ## 错误码
299
+
300
+ | HTTP 状态码 | 说明 |
301
+ |-------------|------|
302
+ | 200 | 成功 |
303
+ | 400 | 请求参数错误(JSON 格式错误、缺少 prompt) |
304
+ | 401 | 认证失败(API Key 无效) |
305
+ | 404 | 路由不存在 |
306
+ | 500 | 服务端内部错误 |
package/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { apiPlugin } from "./src/channel.js";
4
+ import { setApiRuntime } from "./src/runtime.js";
5
+
6
+ const plugin = {
7
+ id: "tsmai-api-openclaw",
8
+ name: "HTTP API",
9
+ description: "HTTP API channel plugin — exposes OpenClaw agent capabilities via REST API",
10
+ configSchema: emptyPluginConfigSchema(),
11
+ register(api: OpenClawPluginApi) {
12
+ setApiRuntime(api.runtime);
13
+ api.registerChannel({ plugin: apiPlugin });
14
+ },
15
+ };
16
+
17
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "tsmai-api-openclaw",
3
+ "channels": ["api"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@qiov/tsmai-api-openclaw",
3
+ "version": "0.0.1",
4
+ "description": "HTTP API channel plugin for OpenClaw — exposes agent capabilities via REST API (sync + SSE streaming)",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "src",
9
+ "openclaw.plugin.json",
10
+ "README.md"
11
+ ],
12
+ "keywords": [
13
+ "openclaw",
14
+ "openclaw-plugin",
15
+ "channel",
16
+ "http-api",
17
+ "sse",
18
+ "agent"
19
+ ],
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "zod": "^3.22.4"
23
+ },
24
+ "devDependencies": {},
25
+ "openclaw": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ],
29
+ "channel": {
30
+ "id": "api",
31
+ "label": "HTTP API",
32
+ "selectionLabel": "HTTP API Channel",
33
+ "docsPath": "/channels/api",
34
+ "blurb": "HTTP API channel — exposes OpenClaw agent capabilities via REST API.",
35
+ "order": 999
36
+ }
37
+ }
38
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,212 @@
1
+ import {
2
+ type ChannelPlugin,
3
+ type ClawdbotConfig,
4
+ DEFAULT_ACCOUNT_ID,
5
+ } from "openclaw/plugin-sdk";
6
+ import { apiOnboardingAdapter } from "./onboarding.js";
7
+
8
+ export type ApiChannelConfig = {
9
+ enabled?: boolean;
10
+ port?: number;
11
+ host?: string;
12
+ apiKey?: string;
13
+ };
14
+
15
+ export type ResolvedApiAccount = {
16
+ accountId: string;
17
+ name: string;
18
+ enabled: boolean;
19
+ configured: boolean;
20
+ port: number;
21
+ host: string;
22
+ apiKey: string;
23
+ };
24
+
25
+ const DEFAULT_PORT = 3100;
26
+ const DEFAULT_HOST = "0.0.0.0";
27
+
28
+ function resolveCredentials(channelCfg?: ApiChannelConfig): {
29
+ port: number;
30
+ host: string;
31
+ apiKey: string;
32
+ } | null {
33
+ const port = channelCfg?.port ?? (process.env.API_OPENCLAW_PORT ? parseInt(process.env.API_OPENCLAW_PORT, 10) : DEFAULT_PORT);
34
+ const host = channelCfg?.host?.trim() || process.env.API_OPENCLAW_HOST || DEFAULT_HOST;
35
+ const apiKey = channelCfg?.apiKey?.trim() || process.env.API_OPENCLAW_API_KEY || "";
36
+
37
+ return { port, host, apiKey };
38
+ }
39
+
40
+ function resolveAccount(cfg: ClawdbotConfig, accountId?: string): ResolvedApiAccount {
41
+ const channelCfg = cfg.channels?.["api"] as ApiChannelConfig | undefined;
42
+ const enabled = channelCfg?.enabled !== false;
43
+ const creds = resolveCredentials(channelCfg);
44
+
45
+ return {
46
+ accountId: accountId?.trim() || DEFAULT_ACCOUNT_ID,
47
+ name: "HTTP API",
48
+ enabled,
49
+ configured: Boolean(creds),
50
+ port: creds?.port ?? DEFAULT_PORT,
51
+ host: creds?.host ?? DEFAULT_HOST,
52
+ apiKey: creds?.apiKey ?? "",
53
+ };
54
+ }
55
+
56
+ export const apiPlugin: ChannelPlugin<ResolvedApiAccount> = {
57
+ id: "api",
58
+ meta: {
59
+ id: "api",
60
+ label: "HTTP API",
61
+ selectionLabel: "HTTP API Channel",
62
+ docsPath: "/channels/api",
63
+ blurb: "HTTP API channel — exposes OpenClaw agent capabilities via REST API.",
64
+ order: 999,
65
+ },
66
+ onboarding: apiOnboardingAdapter,
67
+ capabilities: {
68
+ chatTypes: ["direct"],
69
+ polls: false,
70
+ reactions: false,
71
+ threads: false,
72
+ media: false,
73
+ blockStreaming: false,
74
+ },
75
+ reload: { configPrefixes: ["channels.api"] },
76
+ configSchema: {
77
+ schema: {
78
+ type: "object",
79
+ additionalProperties: false,
80
+ properties: {
81
+ enabled: { type: "boolean" },
82
+ port: { type: "number" },
83
+ host: { type: "string" },
84
+ apiKey: { type: "string" },
85
+ },
86
+ },
87
+ },
88
+ config: {
89
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
90
+ resolveAccount: (cfg) => resolveAccount(cfg),
91
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
92
+ setAccountEnabled: ({ cfg, enabled }) => ({
93
+ ...cfg,
94
+ channels: {
95
+ ...cfg.channels,
96
+ api: {
97
+ ...cfg.channels?.["api"],
98
+ enabled,
99
+ },
100
+ },
101
+ }),
102
+ deleteAccount: ({ cfg }) => {
103
+ const next = { ...cfg } as ClawdbotConfig;
104
+ const nextChannels = { ...cfg.channels };
105
+ delete (nextChannels as Record<string, unknown>)["api"];
106
+ if (Object.keys(nextChannels).length > 0) {
107
+ next.channels = nextChannels;
108
+ } else {
109
+ delete next.channels;
110
+ }
111
+ return next;
112
+ },
113
+ isConfigured: (_account, _cfg) => true, // HTTP API is always configurable
114
+ describeAccount: (account) => ({
115
+ accountId: account.accountId,
116
+ name: account.name,
117
+ enabled: account.enabled,
118
+ configured: account.configured,
119
+ port: account.port,
120
+ host: account.host,
121
+ }),
122
+ resolveAllowFrom: () => [],
123
+ formatAllowFrom: ({ allowFrom }) => allowFrom,
124
+ },
125
+ setup: {
126
+ resolveAccountId: () => DEFAULT_ACCOUNT_ID,
127
+ applyAccountConfig: ({ cfg }) => ({
128
+ ...cfg,
129
+ channels: {
130
+ ...cfg.channels,
131
+ api: {
132
+ ...cfg.channels?.["api"],
133
+ enabled: true,
134
+ },
135
+ },
136
+ }),
137
+ },
138
+ status: {
139
+ defaultRuntime: {
140
+ accountId: DEFAULT_ACCOUNT_ID,
141
+ running: false,
142
+ lastStartAt: null,
143
+ lastStopAt: null,
144
+ lastError: null,
145
+ },
146
+ collectStatusIssues: () => [],
147
+ buildChannelSummary: ({ snapshot }) => ({
148
+ configured: snapshot.configured ?? false,
149
+ port: snapshot.port ?? null,
150
+ host: snapshot.host ?? null,
151
+ running: snapshot.running ?? false,
152
+ lastStartAt: snapshot.lastStartAt ?? null,
153
+ lastStopAt: snapshot.lastStopAt ?? null,
154
+ lastError: snapshot.lastError ?? null,
155
+ lastEventAt: snapshot.lastEventAt ?? null,
156
+ lastInboundAt: snapshot.lastInboundAt ?? null,
157
+ }),
158
+ probeAccount: async ({ cfg }) => {
159
+ const account = resolveAccount(cfg);
160
+ const start = Date.now();
161
+ return {
162
+ ok: account.configured && account.enabled,
163
+ elapsedMs: Date.now() - start,
164
+ };
165
+ },
166
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
167
+ accountId: account.accountId,
168
+ name: account.name,
169
+ enabled: account.enabled,
170
+ configured: account.configured,
171
+ port: account.port,
172
+ host: account.host,
173
+ running: runtime?.running ?? false,
174
+ lastStartAt: runtime?.lastStartAt ?? null,
175
+ lastStopAt: runtime?.lastStopAt ?? null,
176
+ lastError: runtime?.lastError ?? null,
177
+ lastEventAt: runtime?.lastEventAt ?? null,
178
+ lastInboundAt: runtime?.lastInboundAt ?? null,
179
+ probe,
180
+ }),
181
+ },
182
+ outbound: {
183
+ deliveryMode: "direct",
184
+ sendText: async (_ctx) => {
185
+ // HTTP API is request-response, outbound is handled within the request lifecycle
186
+ return { ok: true };
187
+ },
188
+ sendMedia: async (_ctx) => {
189
+ return { ok: true };
190
+ },
191
+ },
192
+ gateway: {
193
+ startAccount: async (ctx) => {
194
+ const account = ctx.account;
195
+ ctx.setStatus({ accountId: account.accountId, port: account.port, host: account.host, running: false });
196
+ ctx.log?.info(`[api] Starting HTTP API server on ${account.host}:${account.port}`);
197
+
198
+ const { startHttpServer } = await import("./http-server.js");
199
+ return startHttpServer({
200
+ port: account.port,
201
+ host: account.host,
202
+ apiKey: account.apiKey,
203
+ abortSignal: ctx.abortSignal,
204
+ log: ctx.log,
205
+ cfg: ctx.cfg,
206
+ onStatusChange: (status) => {
207
+ ctx.setStatus({ ...status });
208
+ },
209
+ });
210
+ },
211
+ },
212
+ };
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+
3
+ export const ApiConfigSchema = z.object({
4
+ enabled: z.boolean().optional(),
5
+ port: z.number().optional(),
6
+ host: z.string().optional(),
7
+ apiKey: z.string().optional(),
8
+ });
9
+
10
+ export type ApiConfig = z.infer<typeof ApiConfigSchema>;
@@ -0,0 +1,456 @@
1
+ /**
2
+ * HTTP Server for the API channel plugin.
3
+ *
4
+ * Exposes two REST endpoints:
5
+ *
6
+ * 1. POST /api/chat — Synchronous (returns full reply)
7
+ *
8
+ * Request body (JSON):
9
+ * { "prompt": string, "agent": string?, "conversationId": string? }
10
+ *
11
+ * Response body (JSON):
12
+ * { "ok": true, "text": "...", "conversationId": "..." }
13
+ *
14
+ * 2. POST /api/chat/stream — SSE stream (real-time progress)
15
+ *
16
+ * Request body: same as /api/chat
17
+ *
18
+ * Response: text/event-stream with events:
19
+ * event: start — { conversationId, agent }
20
+ * event: chunk — { text, index }
21
+ * event: tool — { name, status }
22
+ * event: error — { error }
23
+ * event: done — { text, conversationId }
24
+ */
25
+
26
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
27
+ import type { PluginLogger, ClawdbotConfig } from "openclaw/plugin-sdk";
28
+ import { getApiRuntime } from "./runtime.js";
29
+
30
+ export type HttpServerParams = {
31
+ port: number;
32
+ host: string;
33
+ apiKey?: string;
34
+ abortSignal?: AbortSignal;
35
+ log?: PluginLogger;
36
+ cfg?: ClawdbotConfig;
37
+ onStatusChange?: (status: { running: boolean; port?: number; lastEventAt?: number; lastInboundAt?: number }) => void;
38
+ };
39
+
40
+ function generateId(): string {
41
+ return `api-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
42
+ }
43
+
44
+ /**
45
+ * Read the full request body as a string.
46
+ */
47
+ function readBody(req: IncomingMessage): Promise<string> {
48
+ return new Promise((resolve, reject) => {
49
+ const chunks: Buffer[] = [];
50
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
51
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
52
+ req.on("error", reject);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Send a JSON response.
58
+ */
59
+ function sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
60
+ const data = JSON.stringify(body);
61
+ res.writeHead(statusCode, {
62
+ "Content-Type": "application/json; charset=utf-8",
63
+ "Content-Length": Buffer.byteLength(data),
64
+ });
65
+ res.end(data);
66
+ }
67
+
68
+ /**
69
+ * Strip OpenClaw internal protocol markers that may leak into plugin text.
70
+ */
71
+ function stripProtocolTags(t: string): string {
72
+ return t.replace(/<\/?(?:final|block|tool|media)[^>]*>/gi, "").trimEnd();
73
+ }
74
+
75
+ /**
76
+ * Resolve agent route — shared by sync and stream endpoints.
77
+ */
78
+ function resolveRoute(
79
+ agentId: string | undefined,
80
+ conversationId: string,
81
+ log?: PluginLogger,
82
+ ): { agentId: string; sessionKey: string; accountId: string; mainSessionKey: string } {
83
+ if (agentId) {
84
+ const normalizedAgentId = agentId.toLowerCase();
85
+ const normalizedConvId = conversationId.toLowerCase();
86
+ const sessionKey = `agent:${normalizedAgentId}:direct:${normalizedConvId}`;
87
+ const mainSessionKey = `agent:${normalizedAgentId}:main`;
88
+ log?.info(`[api] Direct agent route: agentId=${normalizedAgentId}, sessionKey=${sessionKey}`);
89
+ return { agentId: normalizedAgentId, sessionKey, mainSessionKey, accountId: "default" };
90
+ }
91
+ const normalizedConvId = conversationId.toLowerCase();
92
+ const sessionKey = `agent:main:direct:${normalizedConvId}`;
93
+ const mainSessionKey = "agent:main:main";
94
+ log?.info(`[api] Default main agent route: sessionKey=${sessionKey}`);
95
+ return { agentId: "main", sessionKey, mainSessionKey, accountId: "default" };
96
+ }
97
+
98
+ /**
99
+ * Build inbound context — shared by sync and stream endpoints.
100
+ */
101
+ function buildInboundContext(
102
+ prompt: string,
103
+ route: { agentId: string; sessionKey: string; accountId: string },
104
+ cfg: ClawdbotConfig | undefined,
105
+ ) {
106
+ const runtime = getApiRuntime();
107
+ const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(cfg ?? {});
108
+
109
+ const formattedBody = runtime.channel.reply.formatInboundEnvelope({
110
+ channel: "API",
111
+ from: "api-user",
112
+ timestamp: Date.now(),
113
+ body: prompt,
114
+ chatType: "direct",
115
+ sender: { id: "api-user", name: "API User" },
116
+ envelope: envelopeOptions,
117
+ });
118
+
119
+ const messageId = generateId();
120
+
121
+ return runtime.channel.reply.finalizeInboundContext({
122
+ Body: formattedBody,
123
+ RawBody: prompt,
124
+ CommandBody: prompt,
125
+ From: "api:api-user",
126
+ To: "api:bot",
127
+ SessionKey: route.sessionKey,
128
+ AccountId: route.accountId,
129
+ ChatType: "direct",
130
+ SenderId: "api-user",
131
+ SenderName: "API User",
132
+ Provider: "api",
133
+ Surface: "api-http",
134
+ MessageSid: messageId,
135
+ MessageSidFull: messageId,
136
+ Timestamp: Date.now(),
137
+ CommandAuthorized: true,
138
+ OriginatingChannel: "api",
139
+ OriginatingTo: "api:bot",
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Process user prompt through OpenClaw's agent pipeline and collect the full reply.
145
+ */
146
+ async function processPrompt(
147
+ prompt: string,
148
+ agentId: string | undefined,
149
+ conversationId: string,
150
+ cfg: ClawdbotConfig | undefined,
151
+ log?: PluginLogger,
152
+ ): Promise<{ text: string; conversationId: string }> {
153
+ const runtime = getApiRuntime();
154
+ const route = resolveRoute(agentId, conversationId, log);
155
+ const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg ?? {}, route.agentId);
156
+ const ctx = buildInboundContext(prompt, route, cfg);
157
+
158
+ let fullText = "";
159
+
160
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
161
+ ctx,
162
+ cfg: cfg ?? {},
163
+ replyOptions: {
164
+ disableBlockStreaming: true,
165
+ onPartialReply: async (payload: { text?: string }) => {
166
+ const rawText = payload.text || "";
167
+ if (rawText) {
168
+ fullText = stripProtocolTags(rawText);
169
+ }
170
+ },
171
+ },
172
+ dispatcherOptions: {
173
+ responsePrefix: messagesConfig.responsePrefix,
174
+ deliver: async (payload: { text?: string }, info?: { kind?: string }) => {
175
+ const rawText = payload.text || "";
176
+ const kind = info?.kind;
177
+ const text = stripProtocolTags(rawText);
178
+
179
+ if (kind === "tool") return;
180
+ if (kind === "block" && text) {
181
+ fullText = fullText ? fullText + "\n" + text : text;
182
+ return;
183
+ }
184
+ if (kind === "final" || kind === undefined) {
185
+ if (text) fullText = text;
186
+ }
187
+ },
188
+ onError: (err: Error) => {
189
+ log?.error(`[api] Reply error: ${err.message}`);
190
+ },
191
+ },
192
+ });
193
+
194
+ return { text: fullText, conversationId };
195
+ }
196
+
197
+ /**
198
+ * Send a single SSE event to the response stream.
199
+ */
200
+ function sendSSE(res: ServerResponse, event: string, data: unknown): void {
201
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
202
+ }
203
+
204
+ /**
205
+ * Process user prompt with SSE streaming — pushes events in real-time.
206
+ */
207
+ async function processPromptStream(
208
+ res: ServerResponse,
209
+ prompt: string,
210
+ agentId: string | undefined,
211
+ conversationId: string,
212
+ cfg: ClawdbotConfig | undefined,
213
+ log?: PluginLogger,
214
+ ): Promise<void> {
215
+ const runtime = getApiRuntime();
216
+ const route = resolveRoute(agentId, conversationId, log);
217
+ const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg ?? {}, route.agentId);
218
+ const ctx = buildInboundContext(prompt, route, cfg);
219
+
220
+ // Send start event
221
+ sendSSE(res, "start", { conversationId, agent: route.agentId });
222
+
223
+ let chunkIndex = 0;
224
+ let fullText = "";
225
+ let streamedCleanText = "";
226
+
227
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
228
+ ctx,
229
+ cfg: cfg ?? {},
230
+ replyOptions: {
231
+ disableBlockStreaming: false, // Enable streaming for SSE
232
+ onPartialReply: async (payload: { text?: string }) => {
233
+ const rawText = payload.text || "";
234
+ if (!rawText) return;
235
+
236
+ const cleanText = stripProtocolTags(rawText);
237
+ if (!cleanText) return;
238
+
239
+ // Compute delta (incremental text)
240
+ let delta = cleanText;
241
+ if (cleanText.startsWith(streamedCleanText)) {
242
+ delta = cleanText.slice(streamedCleanText.length);
243
+ }
244
+ if (!delta) return;
245
+
246
+ streamedCleanText = cleanText;
247
+ fullText = cleanText;
248
+
249
+ sendSSE(res, "chunk", { text: delta, index: chunkIndex });
250
+ chunkIndex++;
251
+ },
252
+ },
253
+ dispatcherOptions: {
254
+ responsePrefix: messagesConfig.responsePrefix,
255
+ deliver: async (payload: { text?: string }, info?: { kind?: string }) => {
256
+ const rawText = payload.text || "";
257
+ const kind = info?.kind;
258
+ const text = stripProtocolTags(rawText);
259
+
260
+ if (kind === "tool") {
261
+ // Extract tool name from payload if available
262
+ const toolName = (payload as any).toolName || (payload as any).name || "tool";
263
+ sendSSE(res, "tool", { name: toolName, status: "done" });
264
+ return;
265
+ }
266
+
267
+ if (kind === "block" && text) {
268
+ // Skip if already streamed via onPartialReply
269
+ if (streamedCleanText && streamedCleanText.includes(text.trim())) return;
270
+
271
+ sendSSE(res, "chunk", { text, index: chunkIndex });
272
+ chunkIndex++;
273
+ fullText = fullText ? fullText + "\n" + text : text;
274
+ streamedCleanText = fullText;
275
+ return;
276
+ }
277
+
278
+ if (kind === "final" || kind === undefined) {
279
+ if (text) fullText = text;
280
+ }
281
+ },
282
+ onError: (err: Error) => {
283
+ log?.error(`[api/stream] Reply error: ${err.message}`);
284
+ sendSSE(res, "error", { error: err.message });
285
+ },
286
+ },
287
+ });
288
+
289
+ // Send done event with full text
290
+ const finalText = fullText || streamedCleanText;
291
+ sendSSE(res, "done", { text: finalText, conversationId });
292
+ }
293
+
294
+ /**
295
+ * Start the HTTP server for the API channel.
296
+ */
297
+ export async function startHttpServer(params: HttpServerParams): Promise<void> {
298
+ const { port, host, apiKey, abortSignal, log, cfg, onStatusChange } = params;
299
+
300
+ return new Promise((resolve, reject) => {
301
+ const server = createServer(async (req, res) => {
302
+ // CORS headers
303
+ res.setHeader("Access-Control-Allow-Origin", "*");
304
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
305
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key");
306
+
307
+ // Handle preflight
308
+ if (req.method === "OPTIONS") {
309
+ res.writeHead(204);
310
+ res.end();
311
+ return;
312
+ }
313
+
314
+ // Health check
315
+ if (req.method === "GET" && req.url === "/health") {
316
+ sendJson(res, 200, { ok: true, status: "running" });
317
+ return;
318
+ }
319
+
320
+ // Route: POST /api/chat/stream (SSE) — must check before /api/chat
321
+ if (req.method === "POST" && req.url?.startsWith("/api/chat/stream")) {
322
+ // API key auth
323
+ if (apiKey) {
324
+ const reqKey =
325
+ req.headers["x-api-key"] as string ||
326
+ req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
327
+ if (reqKey !== apiKey) {
328
+ sendJson(res, 401, { ok: false, error: "Unauthorized: invalid API key" });
329
+ return;
330
+ }
331
+ }
332
+
333
+ try {
334
+ const body = await readBody(req);
335
+ let parsed: { prompt?: string; agent?: string; conversationId?: string };
336
+
337
+ try {
338
+ parsed = JSON.parse(body);
339
+ } catch {
340
+ sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
341
+ return;
342
+ }
343
+
344
+ const prompt = parsed.prompt?.trim();
345
+ if (!prompt) {
346
+ sendJson(res, 400, { ok: false, error: "Missing required field: prompt" });
347
+ return;
348
+ }
349
+
350
+ const agentId = parsed.agent?.trim() || undefined;
351
+ const conversationId = parsed.conversationId?.trim() || generateId();
352
+
353
+ log?.info(`[api/stream] Received request: prompt="${prompt.slice(0, 80)}...", agent=${agentId || "default"}`);
354
+ onStatusChange?.({ running: true, port, lastEventAt: Date.now(), lastInboundAt: Date.now() });
355
+
356
+ // Set SSE headers
357
+ res.writeHead(200, {
358
+ "Content-Type": "text/event-stream; charset=utf-8",
359
+ "Cache-Control": "no-cache",
360
+ "Connection": "keep-alive",
361
+ "Access-Control-Allow-Origin": "*",
362
+ });
363
+
364
+ await processPromptStream(res, prompt, agentId, conversationId, cfg, log);
365
+ } catch (err) {
366
+ log?.error(`[api/stream] Request processing error: ${err}`);
367
+ // If headers already sent, push error event; otherwise send JSON error
368
+ if (res.headersSent) {
369
+ sendSSE(res, "error", { error: `Internal error: ${(err as Error).message}` });
370
+ } else {
371
+ sendJson(res, 500, { ok: false, error: `Internal error: ${(err as Error).message}` });
372
+ }
373
+ } finally {
374
+ if (!res.writableEnded) res.end();
375
+ }
376
+ return;
377
+ }
378
+
379
+ // Route: POST /api/chat (sync)
380
+ if (req.method !== "POST" || !req.url?.startsWith("/api/chat")) {
381
+ sendJson(res, 404, { ok: false, error: "Not found. Use POST /api/chat or /api/chat/stream" });
382
+ return;
383
+ }
384
+
385
+ // API key auth (if configured)
386
+ if (apiKey) {
387
+ const reqKey =
388
+ req.headers["x-api-key"] as string ||
389
+ req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
390
+ if (reqKey !== apiKey) {
391
+ sendJson(res, 401, { ok: false, error: "Unauthorized: invalid API key" });
392
+ return;
393
+ }
394
+ }
395
+
396
+ try {
397
+ const body = await readBody(req);
398
+ let parsed: { prompt?: string; agent?: string; conversationId?: string };
399
+
400
+ try {
401
+ parsed = JSON.parse(body);
402
+ } catch {
403
+ sendJson(res, 400, { ok: false, error: "Invalid JSON body" });
404
+ return;
405
+ }
406
+
407
+ const prompt = parsed.prompt?.trim();
408
+ if (!prompt) {
409
+ sendJson(res, 400, { ok: false, error: "Missing required field: prompt" });
410
+ return;
411
+ }
412
+
413
+ const agentId = parsed.agent?.trim() || undefined;
414
+ const conversationId = parsed.conversationId?.trim() || generateId();
415
+
416
+ log?.info(`[api] Received request: prompt="${prompt.slice(0, 80)}...", agent=${agentId || "default"}`);
417
+ onStatusChange?.({ running: true, port, lastEventAt: Date.now(), lastInboundAt: Date.now() });
418
+
419
+ const result = await processPrompt(prompt, agentId, conversationId, cfg, log);
420
+
421
+ sendJson(res, 200, {
422
+ ok: true,
423
+ text: result.text,
424
+ conversationId: result.conversationId,
425
+ });
426
+ } catch (err) {
427
+ log?.error(`[api] Request processing error: ${err}`);
428
+ sendJson(res, 500, {
429
+ ok: false,
430
+ error: `Internal error: ${(err as Error).message}`,
431
+ });
432
+ }
433
+ });
434
+
435
+ // Handle abort signal
436
+ if (abortSignal) {
437
+ const abortHandler = () => {
438
+ server.close();
439
+ onStatusChange?.({ running: false });
440
+ resolve();
441
+ };
442
+ abortSignal.addEventListener("abort", abortHandler);
443
+ }
444
+
445
+ server.on("error", (err) => {
446
+ log?.error(`[api] HTTP server error: ${err}`);
447
+ onStatusChange?.({ running: false });
448
+ reject(err);
449
+ });
450
+
451
+ server.listen(port, host, () => {
452
+ log?.info(`[api] HTTP server listening on http://${host}:${port}`);
453
+ onStatusChange?.({ running: true, port, lastEventAt: Date.now() });
454
+ });
455
+ });
456
+ }
@@ -0,0 +1,138 @@
1
+ import type {
2
+ ChannelOnboardingAdapter,
3
+ OpenClawConfig,
4
+ WizardPrompter,
5
+ } from "openclaw/plugin-sdk";
6
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
7
+
8
+ const channel = "api" as const;
9
+
10
+ type ApiChannelConfig = {
11
+ enabled?: boolean;
12
+ port?: number;
13
+ host?: string;
14
+ apiKey?: string;
15
+ };
16
+
17
+ function getChannelConfig(cfg: OpenClawConfig): ApiChannelConfig | undefined {
18
+ return cfg.channels?.["api"] as ApiChannelConfig | undefined;
19
+ }
20
+
21
+ function isConfigured(channelCfg?: ApiChannelConfig): boolean {
22
+ // API channel is always "configured" as long as it has a port
23
+ const port = channelCfg?.port ?? (process.env.API_OPENCLAW_PORT ? parseInt(process.env.API_OPENCLAW_PORT, 10) : 3100);
24
+ return Boolean(port);
25
+ }
26
+
27
+ function updateConfig(
28
+ cfg: OpenClawConfig,
29
+ updates: { port?: number; host?: string; apiKey?: string; enabled?: boolean },
30
+ ): OpenClawConfig {
31
+ return {
32
+ ...cfg,
33
+ channels: {
34
+ ...cfg.channels,
35
+ api: {
36
+ ...cfg.channels?.["api"],
37
+ ...updates,
38
+ enabled: updates.enabled ?? true,
39
+ },
40
+ },
41
+ };
42
+ }
43
+
44
+ async function noteSetup(prompter: WizardPrompter): Promise<void> {
45
+ await prompter.note(
46
+ [
47
+ "HTTP API channel exposes OpenClaw agent capabilities via a REST endpoint.",
48
+ "You can configure the port, host, and an optional API key for authentication.",
49
+ "",
50
+ "Once running, send POST requests to /api/chat with:",
51
+ ' { "prompt": "your question", "agent": "optional-agent-id" }',
52
+ ].join("\n"),
53
+ "HTTP API setup",
54
+ );
55
+ }
56
+
57
+ export const apiOnboardingAdapter: ChannelOnboardingAdapter = {
58
+ channel,
59
+ getStatus: async ({ cfg }) => {
60
+ const channelCfg = getChannelConfig(cfg);
61
+ const configured = isConfigured(channelCfg);
62
+
63
+ return {
64
+ channel,
65
+ configured,
66
+ statusLines: [`HTTP API: ${configured ? "configured" : "needs configuration"}`],
67
+ selectionHint: configured ? "configured" : "requires configuration",
68
+ quickstartScore: configured ? 1 : 10,
69
+ };
70
+ },
71
+ configure: async ({ cfg, prompter }) => {
72
+ let next = cfg;
73
+ const accountId = DEFAULT_ACCOUNT_ID;
74
+
75
+ await noteSetup(prompter);
76
+
77
+ const channelCfg = getChannelConfig(next);
78
+
79
+ // Check for env vars
80
+ const envPort = process.env.API_OPENCLAW_PORT ? parseInt(process.env.API_OPENCLAW_PORT, 10) : undefined;
81
+ const envHost = process.env.API_OPENCLAW_HOST?.trim();
82
+ const envApiKey = process.env.API_OPENCLAW_API_KEY?.trim();
83
+
84
+ if (envPort) {
85
+ const useEnv = await prompter.confirm({
86
+ message: `API_OPENCLAW_PORT=${envPort} detected in env. Use environment variables?`,
87
+ initialValue: true,
88
+ });
89
+ if (useEnv) {
90
+ next = updateConfig(next, { enabled: true });
91
+ return { cfg: next, accountId };
92
+ }
93
+ }
94
+
95
+ // Prompt for port
96
+ const portInput = await prompter.text({
97
+ message: "HTTP server port",
98
+ placeholder: "3100",
99
+ initialValue: channelCfg?.port?.toString() || envPort?.toString() || "3100",
100
+ validate: (value) => {
101
+ const n = parseInt(String(value), 10);
102
+ return Number.isInteger(n) && n > 0 && n < 65536 ? undefined : "Must be a valid port (1-65535)";
103
+ },
104
+ });
105
+ const port = parseInt(String(portInput), 10);
106
+
107
+ // Prompt for host
108
+ const hostInput = await prompter.text({
109
+ message: "HTTP server host",
110
+ placeholder: "0.0.0.0",
111
+ initialValue: channelCfg?.host?.trim() || envHost || "0.0.0.0",
112
+ });
113
+ const host = String(hostInput).trim() || "0.0.0.0";
114
+
115
+ // Prompt for API key (optional)
116
+ const apiKeyInput = await prompter.text({
117
+ message: "API Key (optional, leave empty for no authentication)",
118
+ placeholder: "",
119
+ initialValue: channelCfg?.apiKey?.trim() || envApiKey || "",
120
+ });
121
+ const apiKey = String(apiKeyInput).trim();
122
+
123
+ next = updateConfig(next, { port, host, apiKey, enabled: true });
124
+ return { cfg: next, accountId };
125
+ },
126
+ disable: (cfg) => {
127
+ return {
128
+ ...cfg,
129
+ channels: {
130
+ ...cfg.channels,
131
+ api: {
132
+ ...cfg.channels?.["api"],
133
+ enabled: false,
134
+ },
135
+ },
136
+ };
137
+ },
138
+ };
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let apiRuntime: PluginRuntime | null = null;
4
+
5
+ export function setApiRuntime(runtime: PluginRuntime): void {
6
+ apiRuntime = runtime;
7
+ }
8
+
9
+ export function getApiRuntime(): PluginRuntime {
10
+ if (!apiRuntime) {
11
+ throw new Error("API runtime not initialized");
12
+ }
13
+ return apiRuntime;
14
+ }