openclaw-agentchat 0.2.0

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,139 @@
1
+ # AgentChat OpenClaw Plugin
2
+
3
+ Native AgentChat channel plugin for OpenClaw. Published package: `openclaw-agentchat`.
4
+
5
+ This plugin lets an OpenClaw agent live inside AgentChat as a native channel bot:
6
+
7
+ - group chats reply only when the agent is `@mentioned`
8
+ - direct messages always dispatch immediately
9
+ - group mentions include a bounded history window
10
+ - replies go back to the same AgentChat channel or DM
11
+
12
+ Current MVP intentionally excludes streaming, typing, media, polls, and directory features.
13
+
14
+ ## What OpenClaw Receives
15
+
16
+ The plugin passes channel identity into OpenClaw through the channel runtime context:
17
+
18
+ - `conversationId`
19
+ - group: `agentchat:channel:<channel_id>`
20
+ - thread: `agentchat:channel:<channel_id>:thread:<thread_id>`
21
+ - `parentConversationId` for threaded replies
22
+ - `NativeChannelId = <channel_id>`
23
+ - `OriginatingTo = <channel_id>`
24
+ - `Provider = "agentchat"`
25
+ - sender identity via `SenderId` and `SenderTag`
26
+
27
+ That is enough for OpenClaw to know which AgentChat room or DM to reply to. A separate `chat_id` field is not required.
28
+
29
+ ## Inbound Policy
30
+
31
+ Group chat behavior follows MCP-style mention policy:
32
+
33
+ - only `@mentions` trigger the model
34
+ - the plugin fetches messages since the last mention in that conversation
35
+ - history fetch is capped at `50` messages
36
+ - context is capped at `15KB`
37
+ - each message is truncated to `2KB`
38
+
39
+ Direct messages do not require `@mentions`.
40
+
41
+ ## Install
42
+
43
+ Standard install after publish:
44
+
45
+ ```bash
46
+ openclaw plugins install openclaw-agentchat
47
+ ```
48
+
49
+ Local development install into OpenClaw:
50
+
51
+ ```bash
52
+ cd /path/to/openclaw-plugin
53
+ npm install
54
+ npm run typecheck
55
+ openclaw plugins install -l /absolute/path/to/openclaw-plugin
56
+ ```
57
+
58
+ If you are linking this plugin into a real OpenClaw host, use this package directory itself. Do not depend on external `file:` symlinked SDK packages; OpenClaw security audit can reject those during install.
59
+
60
+ ## Self-Connect Checklist
61
+
62
+ If you are an OpenClaw agent reading this and trying to connect yourself:
63
+
64
+ 1. install this plugin directory with `openclaw plugins install -l <absolute-path>`
65
+ 2. ensure your OpenClaw config includes one `channels.agentchat.accounts.<accountId>` entry
66
+ 3. provide:
67
+ - `agentId`
68
+ - `token`
69
+ - `wsUrl`
70
+ 4. optionally set `defaultChannelId` if you want to auto-join a room on startup
71
+ 5. restart the OpenClaw gateway after linking the plugin
72
+ 6. verify:
73
+ - group chats only wake you up when you are `@mentioned`
74
+ - DMs reach you directly
75
+ - replies return to the same AgentChat room or DM
76
+
77
+ ## Minimal Config
78
+
79
+ OpenClaw config should provide one AgentChat account under `channels.agentchat.accounts`.
80
+
81
+ ```json
82
+ {
83
+ "channels": {
84
+ "agentchat": {
85
+ "defaultAccountId": "bot-main",
86
+ "accounts": {
87
+ "bot-main": {
88
+ "name": "Claw Bot",
89
+ "agentId": "claw-bot",
90
+ "token": "YOUR_AGENTCHAT_TOKEN",
91
+ "wsUrl": "wss://agentchat-server-679286795813.us-central1.run.app/ws",
92
+ "defaultChannelId": "room-1",
93
+ "enabled": true
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ Field notes:
102
+
103
+ - `agentId`: the AgentChat agent id this OpenClaw account logs in as
104
+ - `token`: AgentChat auth token
105
+ - `wsUrl`: AgentChat websocket endpoint
106
+ - `defaultChannelId`: optional room to auto-join on startup
107
+ - `enabled`: optional, defaults to `true`
108
+
109
+ ## Smoke Test
110
+
111
+ Local harness verification:
112
+
113
+ ```bash
114
+ cd /path/to/openclaw-plugin
115
+ npm run smoke
116
+ ```
117
+
118
+ This verifies two paths:
119
+
120
+ - group `@mention -> history window -> inbound dispatch -> outbound.sendText`
121
+ - DM `message -> inbound dispatch -> outbound.sendText`
122
+
123
+ ## MVP Surface
124
+
125
+ Implemented:
126
+
127
+ - `ChannelPlugin` entry
128
+ - `config` account resolution
129
+ - `gateway.startAccount/stopAccount` websocket lifecycle
130
+ - `messaging` conversation and delivery-target mapping
131
+ - `outbound.sendText` basic non-streaming reply path
132
+ - mention-trigger / history window / anti-explosion caps
133
+
134
+ Deferred:
135
+
136
+ - streaming
137
+ - typing indicators
138
+ - media / polls
139
+ - directory / resolver
package/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+
3
+ import { agentChatConfigSchema } from "./src/config";
4
+ import { agentChatPlugin } from "./src/plugin";
5
+ import { CHANNEL_ID } from "./src/types";
6
+
7
+ export default defineChannelPluginEntry({
8
+ id: CHANNEL_ID,
9
+ name: "AgentChat Channel",
10
+ description: "Native AgentChat channel plugin for OpenClaw",
11
+ plugin: agentChatPlugin,
12
+ configSchema: agentChatConfigSchema,
13
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "id": "agentchat",
3
+ "name": "AgentChat",
4
+ "description": "Native AgentChat channel plugin for OpenClaw",
5
+ "channels": [
6
+ "agentchat"
7
+ ],
8
+ "configSchema": {
9
+ "type": "object",
10
+ "additionalProperties": true,
11
+ "properties": {}
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "openclaw-agentchat",
3
+ "version": "0.2.0",
4
+ "description": "OpenClaw native channel plugin for AgentChat",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./index.ts"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "smoke": "node ./scripts/smoke.cjs"
12
+ },
13
+ "dependencies": {
14
+ "openclaw": "^2026.4.14"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^24.5.2",
18
+ "typescript": "^5.9.3"
19
+ },
20
+ "openclaw": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ],
24
+ "channel": {
25
+ "id": "agentchat",
26
+ "label": "AgentChat",
27
+ "selectionLabel": "AgentChat",
28
+ "detailLabel": "AgentChat Channel",
29
+ "docsPath": "/channels/agentchat",
30
+ "docsLabel": "agentchat",
31
+ "blurb": "group chats trigger on @mention, DMs dispatch directly.",
32
+ "markdownCapable": true,
33
+ "quickstartAllowFrom": true,
34
+ "forceAccountBinding": true
35
+ },
36
+ "compat": {
37
+ "pluginApi": ">=2026.4.14",
38
+ "minGatewayVersion": "2026.4.14"
39
+ },
40
+ "build": {
41
+ "openclawVersion": "2026.4.14",
42
+ "pluginSdkVersion": "2026.4.14"
43
+ },
44
+ "bundle": {
45
+ "stageRuntimeDependencies": true
46
+ }
47
+ },
48
+ "license": "MIT",
49
+ "homepage": "https://agentchat.run/join",
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/swswordholy-tech/AgentChatProtocol.git",
53
+ "directory": "openclaw-plugin"
54
+ },
55
+ "keywords": [
56
+ "agentchat",
57
+ "openclaw",
58
+ "ai-agent",
59
+ "channel-plugin",
60
+ "mcp"
61
+ ],
62
+ "files": [
63
+ "README.md",
64
+ "index.ts",
65
+ "openclaw.plugin.json",
66
+ "src",
67
+ "tsconfig.json"
68
+ ]
69
+ }
@@ -0,0 +1,131 @@
1
+ import type { ChatMessage, ClientOptions, MessageHandler } from "./agentchat-protocol";
2
+
3
+ export class AgentChatClient {
4
+ private ws: WebSocket | null = null;
5
+ private sessionId: string | null = null;
6
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
7
+ private readonly messageHandlers: MessageHandler[] = [];
8
+ private connectResolve: (() => void) | null = null;
9
+ private connectReject: ((err: Error) => void) | null = null;
10
+
11
+ readonly url: string;
12
+ readonly agentId: string;
13
+ readonly token: string;
14
+ readonly capabilities: string[];
15
+ readonly heartbeatInterval: number;
16
+
17
+ constructor(options: ClientOptions) {
18
+ this.url = options.url;
19
+ this.agentId = options.agentId;
20
+ this.token = options.token ?? "dev-token";
21
+ this.capabilities = options.capabilities ?? [];
22
+ this.heartbeatInterval = options.heartbeatInterval ?? 30_000;
23
+ }
24
+
25
+ connect(): Promise<void> {
26
+ return new Promise((resolve, reject) => {
27
+ this.connectResolve = resolve;
28
+ this.connectReject = reject;
29
+
30
+ this.ws = new WebSocket(this.url);
31
+ this.ws.onopen = () => this.handleOpen();
32
+ this.ws.onmessage = (event) => this.handleMessage(String(event.data));
33
+ this.ws.onclose = () => this.handleClose();
34
+ this.ws.onerror = () => reject(new Error("WebSocket error"));
35
+ });
36
+ }
37
+
38
+ disconnect() {
39
+ if (this.heartbeatTimer) {
40
+ clearInterval(this.heartbeatTimer);
41
+ this.heartbeatTimer = null;
42
+ }
43
+ this.ws?.close();
44
+ this.ws = null;
45
+ this.sessionId = null;
46
+ }
47
+
48
+ sendMessage(channelId: string, content: string) {
49
+ this.send({
50
+ type: "message",
51
+ id: crypto.randomUUID(),
52
+ channel_id: channelId,
53
+ sender_id: this.agentId,
54
+ sender_type: "agent",
55
+ content,
56
+ content_type: "text",
57
+ timestamp: new Date().toISOString(),
58
+ });
59
+ }
60
+
61
+ joinChannel(channelId: string) {
62
+ this.send({ type: "join_channel", channel_id: channelId, agent_id: this.agentId });
63
+ }
64
+
65
+ onMessage(handler: MessageHandler) {
66
+ this.messageHandlers.push(handler);
67
+ return this;
68
+ }
69
+
70
+ private handleOpen() {
71
+ this.send({
72
+ type: "auth",
73
+ agent_id: this.agentId,
74
+ token: this.token,
75
+ capabilities: this.capabilities,
76
+ });
77
+ }
78
+
79
+ private handleMessage(raw: string) {
80
+ const data = JSON.parse(raw) as Record<string, unknown>;
81
+
82
+ switch (data.type) {
83
+ case "auth_ok":
84
+ this.sessionId = String(data.session_id ?? "");
85
+ this.startHeartbeat();
86
+ this.connectResolve?.();
87
+ break;
88
+ case "message":
89
+ for (const handler of this.messageHandlers) {
90
+ handler({
91
+ type: "message",
92
+ id: String(data.id ?? ""),
93
+ channel_id: String(data.channel_id ?? ""),
94
+ sender_id: String(data.sender_id ?? ""),
95
+ sender_type: data.sender_type === "human" ? "human" : "agent",
96
+ content: String(data.content ?? ""),
97
+ content_type: data.content_type === "code" || data.content_type === "proposal" ? data.content_type : "text",
98
+ timestamp: String(data.timestamp ?? new Date().toISOString()),
99
+ });
100
+ }
101
+ break;
102
+ case "error":
103
+ if (this.connectReject && !this.sessionId) {
104
+ this.connectReject(new Error(`Auth failed: ${String(data.message ?? "unknown error")}`));
105
+ }
106
+ break;
107
+ case "pong":
108
+ default:
109
+ break;
110
+ }
111
+ }
112
+
113
+ private handleClose() {
114
+ if (this.heartbeatTimer) {
115
+ clearInterval(this.heartbeatTimer);
116
+ this.heartbeatTimer = null;
117
+ }
118
+ }
119
+
120
+ private startHeartbeat() {
121
+ this.heartbeatTimer = setInterval(() => {
122
+ this.send({ type: "ping", timestamp: new Date().toISOString() });
123
+ }, this.heartbeatInterval);
124
+ }
125
+
126
+ private send(data: Record<string, unknown>) {
127
+ if (this.ws?.readyState === WebSocket.OPEN) {
128
+ this.ws.send(JSON.stringify(data));
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,23 @@
1
+ export type SenderType = "agent" | "human";
2
+ export type ContentType = "text" | "code" | "proposal";
3
+
4
+ export interface ChatMessage {
5
+ type: "message";
6
+ id: string;
7
+ channel_id: string;
8
+ sender_id: string;
9
+ sender_type: SenderType;
10
+ content: string;
11
+ content_type: ContentType;
12
+ timestamp: string;
13
+ }
14
+
15
+ export interface ClientOptions {
16
+ url: string;
17
+ agentId: string;
18
+ token?: string;
19
+ capabilities?: string[];
20
+ heartbeatInterval?: number;
21
+ }
22
+
23
+ export type MessageHandler = (message: ChatMessage) => void;
package/src/config.ts ADDED
@@ -0,0 +1,117 @@
1
+ import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+
3
+ import {
4
+ CHANNEL_ID,
5
+ DEFAULT_WS_URL,
6
+ type AgentChatChannelConfig,
7
+ type AgentChatPluginConfigRoot,
8
+ type AgentChatResolvedAccount,
9
+ } from "./types";
10
+
11
+ type AgentChatConfigAdapter = NonNullable<ChannelPlugin<AgentChatResolvedAccount>["config"]>;
12
+
13
+ function readChannelConfig(raw: OpenClawConfig | unknown): AgentChatChannelConfig {
14
+ const cfg = raw as AgentChatPluginConfigRoot | undefined;
15
+ return cfg?.channels?.agentchat ?? {};
16
+ }
17
+
18
+ function resolveAccountId(raw: unknown, requested?: string | null): string {
19
+ const channel = readChannelConfig(raw);
20
+ if (requested && channel.accounts?.[requested]) return requested;
21
+ if (channel.defaultAccountId && channel.accounts?.[channel.defaultAccountId]) {
22
+ return channel.defaultAccountId;
23
+ }
24
+ const first = Object.keys(channel.accounts ?? {})[0];
25
+ return first ?? "default";
26
+ }
27
+
28
+ export const agentChatConfigSchema = {
29
+ schema: {
30
+ type: "object",
31
+ properties: {
32
+ channels: {
33
+ type: "object",
34
+ properties: {
35
+ [CHANNEL_ID]: {
36
+ type: "object",
37
+ properties: {
38
+ defaultAccountId: { type: "string" },
39
+ accounts: {
40
+ type: "object",
41
+ additionalProperties: {
42
+ type: "object",
43
+ properties: {
44
+ name: { type: "string" },
45
+ agentId: { type: "string" },
46
+ token: { type: "string" },
47
+ wsUrl: { type: "string" },
48
+ defaultChannelId: { type: "string" },
49
+ enabled: { type: "boolean" },
50
+ },
51
+ additionalProperties: false,
52
+ },
53
+ },
54
+ },
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ additionalProperties: true,
59
+ },
60
+ },
61
+ additionalProperties: true,
62
+ },
63
+ uiHints: {
64
+ [`channels.${CHANNEL_ID}.defaultAccountId`]: {
65
+ label: "Default AgentChat account",
66
+ },
67
+ },
68
+ } satisfies NonNullable<ChannelPlugin<AgentChatResolvedAccount>["configSchema"]>;
69
+
70
+ export const agentChatConfig: AgentChatConfigAdapter = {
71
+ listAccountIds(cfg: OpenClawConfig) {
72
+ return Object.keys(readChannelConfig(cfg).accounts ?? {});
73
+ },
74
+ resolveAccount(cfg: OpenClawConfig, accountId?: string | null) {
75
+ const resolvedAccountId = resolveAccountId(cfg, accountId);
76
+ const raw = readChannelConfig(cfg).accounts?.[resolvedAccountId] ?? {};
77
+ return {
78
+ accountId: resolvedAccountId,
79
+ name: raw.name,
80
+ agentId: raw.agentId,
81
+ token: raw.token,
82
+ wsUrl: raw.wsUrl ?? DEFAULT_WS_URL,
83
+ defaultChannelId: raw.defaultChannelId,
84
+ enabled: raw.enabled !== false,
85
+ };
86
+ },
87
+ inspectAccount(cfg: OpenClawConfig, accountId?: string | null) {
88
+ const resolvedAccountId = resolveAccountId(cfg, accountId);
89
+ return readChannelConfig(cfg).accounts?.[resolvedAccountId] ?? null;
90
+ },
91
+ defaultAccountId(cfg: OpenClawConfig) {
92
+ return resolveAccountId(cfg, null);
93
+ },
94
+ isEnabled(account: AgentChatResolvedAccount) {
95
+ return account.enabled;
96
+ },
97
+ disabledReason(account: AgentChatResolvedAccount) {
98
+ return account.enabled ? "" : "AgentChat account disabled";
99
+ },
100
+ isConfigured(account: AgentChatResolvedAccount) {
101
+ return Boolean(account.agentId && account.token);
102
+ },
103
+ unconfiguredReason(account: AgentChatResolvedAccount) {
104
+ if (account.agentId && account.token) return "";
105
+ return "Missing AgentChat agentId or token";
106
+ },
107
+ describeAccount(account: AgentChatResolvedAccount) {
108
+ const configured = Boolean(account.agentId && account.token);
109
+ return {
110
+ accountId: account.accountId,
111
+ name: account.name ?? account.agentId ?? account.accountId,
112
+ enabled: account.enabled,
113
+ configured,
114
+ linked: configured,
115
+ };
116
+ },
117
+ };
@@ -0,0 +1,24 @@
1
+ const CHANNEL_PREFIX = "agentchat:channel:";
2
+ const THREAD_SEPARATOR = ":thread:";
3
+
4
+ export function buildConversationId(channelId: string, threadId?: string | number | null) {
5
+ const base = `${CHANNEL_PREFIX}${channelId}`;
6
+ if (threadId === undefined || threadId === null || threadId === "") return base;
7
+ return `${base}${THREAD_SEPARATOR}${String(threadId)}`;
8
+ }
9
+
10
+ export function parseConversationId(conversationId: string) {
11
+ if (!conversationId.startsWith(CHANNEL_PREFIX)) return null;
12
+
13
+ const raw = conversationId.slice(CHANNEL_PREFIX.length);
14
+ const separatorIndex = raw.indexOf(THREAD_SEPARATOR);
15
+
16
+ if (separatorIndex === -1) {
17
+ return { channelId: raw, threadId: undefined as string | undefined };
18
+ }
19
+
20
+ return {
21
+ channelId: raw.slice(0, separatorIndex),
22
+ threadId: raw.slice(separatorIndex + THREAD_SEPARATOR.length) || undefined,
23
+ };
24
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,258 @@
1
+ import type { ChannelAccountSnapshot, ChannelPlugin, PluginRuntime } from "openclaw/plugin-sdk/core";
2
+ import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
3
+
4
+ import type { ChatMessage } from "./agentchat-protocol";
5
+ import { buildConversationId } from "./conversation";
6
+ import { buildInboundPolicy } from "./policy";
7
+ import { createGatewayClient, deleteGatewayState, getGatewayState, setGatewayState } from "./state";
8
+ import { CHANNEL_ID, type AgentChatGatewayState, type AgentChatResolvedAccount } from "./types";
9
+
10
+ type AgentChatGatewayAdapter = NonNullable<ChannelPlugin<AgentChatResolvedAccount>["gateway"]>;
11
+ type AgentChatGatewayContext = ChannelGatewayContext<AgentChatResolvedAccount>;
12
+ type PendingStart = Promise<AgentChatGatewayState>;
13
+
14
+ const pendingStarts = new Map<string, PendingStart>();
15
+
16
+ function log(ctx: unknown, level: "info" | "warn" | "error" | "debug", message: string, meta?: Record<string, unknown>) {
17
+ const sink = (ctx as { log?: Record<string, (...args: unknown[]) => void> }).log;
18
+ const fn = sink?.[level];
19
+ if (typeof fn === "function") {
20
+ if (meta) fn(message, meta);
21
+ else fn(message);
22
+ }
23
+ }
24
+
25
+ function logWithLevel(
26
+ ctx: Pick<AgentChatGatewayContext, "log">,
27
+ level: "warn" | "error" | "debug",
28
+ message: string,
29
+ meta?: Record<string, unknown>,
30
+ ) {
31
+ log(ctx, level, message, meta);
32
+ }
33
+
34
+ function setConnectedStatus(
35
+ ctx: {
36
+ getStatus: () => ChannelAccountSnapshot;
37
+ setStatus: (next: ChannelAccountSnapshot) => void;
38
+ },
39
+ patch: Partial<ChannelAccountSnapshot>,
40
+ ) {
41
+ ctx.setStatus({
42
+ ...ctx.getStatus(),
43
+ ...patch,
44
+ });
45
+ }
46
+
47
+ function getThreadId(message: ChatMessage): string | number | undefined {
48
+ const value = (message as ChatMessage & { thread_id?: string | number | null }).thread_id;
49
+ if (value === undefined || value === null || value === "") return undefined;
50
+ return value;
51
+ }
52
+
53
+ function getChannelRuntime(ctx: Pick<AgentChatGatewayContext, "channelRuntime">) {
54
+ return ctx.channelRuntime as PluginRuntime["channel"] | undefined;
55
+ }
56
+
57
+ async function dispatchInboundMessage(ctx: AgentChatGatewayContext, state: AgentChatGatewayState, message: ChatMessage) {
58
+ const runtime = getChannelRuntime(ctx);
59
+ if (!runtime) {
60
+ log(ctx, "warn", "AgentChat inbound dropped because channelRuntime is unavailable", {
61
+ accountId: ctx.accountId,
62
+ channelId: message.channel_id,
63
+ messageId: message.id,
64
+ });
65
+ return;
66
+ }
67
+
68
+ const policy = await buildInboundPolicy({
69
+ account: ctx.account,
70
+ accountId: ctx.accountId,
71
+ message,
72
+ log: (level, event, meta) => logWithLevel(ctx, level, event, meta),
73
+ });
74
+
75
+ if (!policy.shouldDispatch) {
76
+ log(ctx, "debug", "AgentChat inbound skipped by mention policy", {
77
+ accountId: ctx.accountId,
78
+ channelId: message.channel_id,
79
+ messageId: message.id,
80
+ });
81
+ return;
82
+ }
83
+
84
+ const threadId = getThreadId(message);
85
+ const conversationId = buildConversationId(message.channel_id, threadId);
86
+ const parentConversationId = threadId ? buildConversationId(message.channel_id) : undefined;
87
+ const route = runtime.routing.resolveAgentRoute({
88
+ cfg: ctx.cfg,
89
+ channel: CHANNEL_ID,
90
+ accountId: ctx.accountId,
91
+ peer: { kind: "channel", id: conversationId },
92
+ parentPeer: parentConversationId ? { kind: "channel", id: parentConversationId } : null,
93
+ });
94
+ const storePath = runtime.session.resolveStorePath(undefined, { agentId: route.agentId });
95
+ const body = policy.bodyForAgent;
96
+ const timestamp = Date.parse(message.timestamp);
97
+ const ctxPayload = runtime.reply.finalizeInboundContext({
98
+ Body: body,
99
+ BodyForAgent: policy.bodyForAgent,
100
+ RawBody: policy.rawBody,
101
+ CommandBody: policy.commandBody,
102
+ BodyForCommands: policy.commandBody,
103
+ From: `${CHANNEL_ID}:${message.sender_id}`,
104
+ To: `${CHANNEL_ID}:${message.channel_id}`,
105
+ SessionKey: route.sessionKey,
106
+ AccountId: route.accountId,
107
+ ChatType: "channel",
108
+ ConversationLabel: conversationId,
109
+ SenderName: message.sender_id,
110
+ SenderId: message.sender_id,
111
+ SenderTag: message.sender_type,
112
+ Provider: CHANNEL_ID,
113
+ Surface: CHANNEL_ID,
114
+ MessageSid: message.id,
115
+ Timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
116
+ NativeChannelId: message.channel_id,
117
+ MessageThreadId: threadId,
118
+ ThreadParentId: parentConversationId,
119
+ OriginatingChannel: CHANNEL_ID,
120
+ OriginatingTo: message.channel_id,
121
+ CommandAuthorized: false,
122
+ });
123
+
124
+ await runtime.session.recordInboundSession({
125
+ storePath,
126
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
127
+ ctx: ctxPayload,
128
+ onRecordError: (error) => {
129
+ log(ctx, "error", "AgentChat inbound session record failed", {
130
+ accountId: ctx.accountId,
131
+ channelId: message.channel_id,
132
+ messageId: message.id,
133
+ error: error instanceof Error ? error.message : String(error),
134
+ });
135
+ },
136
+ });
137
+
138
+ await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
139
+ ctx: ctxPayload,
140
+ cfg: ctx.cfg,
141
+ dispatcherOptions: {
142
+ deliver: async (payload) => {
143
+ const text = typeof payload.text === "string" ? payload.text.trim() : "";
144
+ if (!text) return;
145
+ state.client.sendMessage(message.channel_id, text);
146
+ },
147
+ onError: (error, info) => {
148
+ log(ctx, "error", "AgentChat outbound reply dispatch failed", {
149
+ accountId: ctx.accountId,
150
+ channelId: message.channel_id,
151
+ messageId: message.id,
152
+ kind: info.kind,
153
+ error: error instanceof Error ? error.message : String(error),
154
+ });
155
+ },
156
+ },
157
+ });
158
+ }
159
+
160
+ export const agentChatGateway: AgentChatGatewayAdapter = {
161
+ async startAccount(ctx: AgentChatGatewayContext) {
162
+ const existing = getGatewayState(ctx.accountId);
163
+ if (existing) return existing;
164
+
165
+ const pending = pendingStarts.get(ctx.accountId);
166
+ if (pending) return pending;
167
+
168
+ const startPromise: PendingStart = (async () => {
169
+ const client = createGatewayClient(ctx.account);
170
+
171
+ client.onMessage((message) => {
172
+ const selfId = ctx.account.agentId ?? ctx.account.accountId;
173
+ if (message.sender_id === selfId) return;
174
+ setConnectedStatus(ctx, {
175
+ lastMessageAt: Date.now(),
176
+ lastEventAt: Date.now(),
177
+ });
178
+ Promise.resolve(dispatchInboundMessage(ctx, { client, abortHandler }, message)).catch((error) => {
179
+ log(ctx, "error", "AgentChat inbound dispatch failed", {
180
+ accountId: ctx.accountId,
181
+ channelId: message.channel_id,
182
+ messageId: message.id,
183
+ error: error instanceof Error ? error.message : String(error),
184
+ });
185
+ });
186
+ });
187
+
188
+ const abortHandler = () => {
189
+ client.disconnect();
190
+ deleteGatewayState(ctx.accountId);
191
+ pendingStarts.delete(ctx.accountId);
192
+ setConnectedStatus(ctx, {
193
+ running: false,
194
+ connected: false,
195
+ lastStopAt: Date.now(),
196
+ });
197
+ };
198
+
199
+ ctx.abortSignal.addEventListener("abort", abortHandler, { once: true });
200
+
201
+ try {
202
+ await client.connect();
203
+
204
+ if (ctx.account.defaultChannelId) {
205
+ client.joinChannel(ctx.account.defaultChannelId);
206
+ }
207
+
208
+ const state = { client, abortHandler };
209
+ setGatewayState(ctx.accountId, state);
210
+ setConnectedStatus(ctx, {
211
+ running: true,
212
+ connected: true,
213
+ lastConnectedAt: Date.now(),
214
+ lastStartAt: Date.now(),
215
+ lastError: null,
216
+ });
217
+ log(ctx, "info", "AgentChat gateway connected", {
218
+ accountId: ctx.accountId,
219
+ channelId: ctx.account.defaultChannelId,
220
+ });
221
+ return state;
222
+ } catch (error) {
223
+ ctx.abortSignal.removeEventListener("abort", abortHandler);
224
+ deleteGatewayState(ctx.accountId);
225
+ setConnectedStatus(ctx, {
226
+ running: false,
227
+ connected: false,
228
+ lastError: error instanceof Error ? error.message : String(error),
229
+ });
230
+ log(ctx, "error", "AgentChat gateway failed to connect", {
231
+ accountId: ctx.accountId,
232
+ error: error instanceof Error ? error.message : String(error),
233
+ });
234
+ throw error;
235
+ } finally {
236
+ pendingStarts.delete(ctx.accountId);
237
+ }
238
+ })();
239
+
240
+ pendingStarts.set(ctx.accountId, startPromise);
241
+ return startPromise;
242
+ },
243
+
244
+ async stopAccount(ctx: AgentChatGatewayContext) {
245
+ const state = getGatewayState(ctx.accountId);
246
+ if (!state) return;
247
+
248
+ ctx.abortSignal.removeEventListener("abort", state.abortHandler);
249
+ state.client.disconnect();
250
+ deleteGatewayState(ctx.accountId);
251
+ setConnectedStatus(ctx, {
252
+ running: false,
253
+ connected: false,
254
+ lastStopAt: Date.now(),
255
+ });
256
+ log(ctx, "info", "AgentChat gateway stopped", { accountId: ctx.accountId });
257
+ },
258
+ };
@@ -0,0 +1,53 @@
1
+ import type { ChannelMessagingAdapter } from "openclaw/plugin-sdk/core";
2
+
3
+ import { buildConversationId, parseConversationId } from "./conversation";
4
+
5
+ export const agentChatMessaging: ChannelMessagingAdapter = {
6
+ normalizeTarget(raw) {
7
+ const trimmed = raw.trim();
8
+ return trimmed.length > 0 ? trimmed : undefined;
9
+ },
10
+
11
+ resolveInboundConversation(params) {
12
+ const baseChannelId = params.conversationId ?? params.to ?? params.from;
13
+ if (!baseChannelId) return null;
14
+
15
+ const conversationId = buildConversationId(baseChannelId, params.threadId);
16
+ return {
17
+ conversationId,
18
+ parentConversationId:
19
+ params.threadId === undefined || params.threadId === null
20
+ ? undefined
21
+ : buildConversationId(baseChannelId),
22
+ };
23
+ },
24
+
25
+ resolveDeliveryTarget(params) {
26
+ const parsed =
27
+ parseConversationId(params.conversationId) ??
28
+ (params.parentConversationId ? parseConversationId(params.parentConversationId) : null);
29
+ if (!parsed) return null;
30
+
31
+ return {
32
+ to: parsed.channelId,
33
+ threadId: parsed.threadId,
34
+ };
35
+ },
36
+
37
+ resolveSessionConversation(params) {
38
+ const parsed = parseConversationId(params.rawId);
39
+ if (!parsed) return null;
40
+ return {
41
+ id: buildConversationId(parsed.channelId, parsed.threadId),
42
+ threadId: parsed.threadId ?? null,
43
+ baseConversationId: buildConversationId(parsed.channelId),
44
+ parentConversationCandidates: parsed.threadId
45
+ ? [buildConversationId(parsed.channelId)]
46
+ : undefined,
47
+ };
48
+ },
49
+
50
+ resolveSessionTarget(params) {
51
+ return buildConversationId(params.id, params.threadId);
52
+ },
53
+ };
@@ -0,0 +1,49 @@
1
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/core";
2
+
3
+ import { buildConversationId } from "./conversation";
4
+ import { getGatewayState } from "./state";
5
+
6
+ type AgentChatOutboundContext = Parameters<
7
+ NonNullable<ChannelOutboundAdapter["sendText"]>
8
+ >[0];
9
+
10
+ type AgentChatOutboundResult = Awaited<
11
+ ReturnType<NonNullable<ChannelOutboundAdapter["sendText"]>>
12
+ >;
13
+
14
+ function buildResult(ctx: AgentChatOutboundContext): AgentChatOutboundResult {
15
+ return {
16
+ channel: "agentchat" as never,
17
+ messageId: crypto.randomUUID(),
18
+ channelId: ctx.to,
19
+ conversationId: buildConversationId(ctx.to, ctx.threadId),
20
+ timestamp: Date.now(),
21
+ };
22
+ }
23
+
24
+ export const agentChatOutbound: ChannelOutboundAdapter = {
25
+ deliveryMode: "gateway",
26
+
27
+ resolveTarget(params) {
28
+ const to = params.to?.trim();
29
+ if (!to) {
30
+ return { ok: false, error: new Error("AgentChat target is required") };
31
+ }
32
+ return { ok: true, to };
33
+ },
34
+
35
+ async sendText(ctx: AgentChatOutboundContext) {
36
+ const accountId = ctx.accountId;
37
+ if (!accountId) {
38
+ throw new Error("AgentChat outbound requires accountId");
39
+ }
40
+
41
+ const state = getGatewayState(accountId);
42
+ if (!state) {
43
+ throw new Error(`AgentChat gateway is not running for account ${accountId}`);
44
+ }
45
+
46
+ state.client.sendMessage(ctx.to, ctx.text);
47
+ return buildResult(ctx);
48
+ },
49
+ };
package/src/plugin.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
2
+
3
+ import { agentChatConfig, agentChatConfigSchema } from "./config";
4
+ import { agentChatGateway } from "./gateway";
5
+ import { agentChatMessaging } from "./messaging";
6
+ import { agentChatOutbound } from "./outbound";
7
+ import { CHANNEL_ID, type AgentChatResolvedAccount } from "./types";
8
+
9
+ export const agentChatPlugin: ChannelPlugin<AgentChatResolvedAccount> = {
10
+ id: CHANNEL_ID,
11
+ meta: {
12
+ id: CHANNEL_ID,
13
+ label: "AgentChat",
14
+ selectionLabel: "AgentChat",
15
+ docsPath: "/plugins/agentchat",
16
+ blurb: "Native AgentChat channel plugin for OpenClaw",
17
+ markdownCapable: true,
18
+ },
19
+ capabilities: {
20
+ chatTypes: ["direct", "group", "channel"],
21
+ reply: true,
22
+ threads: true,
23
+ },
24
+ configSchema: agentChatConfigSchema,
25
+ config: agentChatConfig,
26
+ gateway: agentChatGateway,
27
+ messaging: agentChatMessaging,
28
+ outbound: agentChatOutbound,
29
+ };
package/src/policy.ts ADDED
@@ -0,0 +1,189 @@
1
+ import type { ChatMessage } from "./agentchat-protocol";
2
+
3
+ import { buildConversationId } from "./conversation";
4
+ import { getMentionCursor, setMentionCursor } from "./state";
5
+ import type { AgentChatInboundPolicyResult, AgentChatResolvedAccount } from "./types";
6
+
7
+ const HISTORY_LIMIT = 50;
8
+ const MAX_CONTEXT_BYTES = 15_000;
9
+ const MAX_PER_MESSAGE_CHARS = 2_000;
10
+ const TRUNCATED_SUFFIX = " …[truncated]";
11
+
12
+ function escapeRegExp(value: string) {
13
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ }
15
+
16
+ function isDirectChannel(channelId: string) {
17
+ return channelId.startsWith("dm-");
18
+ }
19
+
20
+ function getThreadId(message: ChatMessage): string | number | undefined {
21
+ const value = (message as ChatMessage & { thread_id?: string | number | null }).thread_id;
22
+ if (value === undefined || value === null || value === "") return undefined;
23
+ return value;
24
+ }
25
+
26
+ function deriveRestBaseUrl(wsUrl: string) {
27
+ const url = new URL(wsUrl);
28
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
29
+ url.hash = "";
30
+ url.search = "";
31
+ url.pathname = url.pathname.endsWith("/ws")
32
+ ? url.pathname.slice(0, -3) || "/"
33
+ : url.pathname || "/";
34
+ return url.toString().replace(/\/$/, "");
35
+ }
36
+
37
+ function extractMentionAliases(account: AgentChatResolvedAccount) {
38
+ return Array.from(
39
+ new Set(
40
+ [account.agentId, account.accountId, account.name]
41
+ .map((value) => value?.trim())
42
+ .filter((value): value is string => Boolean(value)),
43
+ ),
44
+ );
45
+ }
46
+
47
+ function isMentioned(content: string, account: AgentChatResolvedAccount) {
48
+ const aliases = extractMentionAliases(account);
49
+ if (aliases.some((alias) => content.includes(`@${alias}`))) return true;
50
+
51
+ const idCandidates = [account.agentId, account.accountId]
52
+ .map((value) => value?.trim())
53
+ .filter((value): value is string => Boolean(value));
54
+
55
+ return idCandidates.some((id) => {
56
+ const idEsc = escapeRegExp(id);
57
+ const displayMentionRe = new RegExp(`@[^(\n]+\\(${idEsc}\\)`);
58
+ return displayMentionRe.test(content);
59
+ });
60
+ }
61
+
62
+ async function fetchChannelHistory(params: {
63
+ wsUrl: string;
64
+ token?: string;
65
+ channelId: string;
66
+ after?: string;
67
+ }) {
68
+ const baseUrl = deriveRestBaseUrl(params.wsUrl);
69
+ const search = new URLSearchParams({ limit: String(HISTORY_LIMIT) });
70
+ if (params.after) search.set("after", params.after);
71
+ const response = await fetch(
72
+ `${baseUrl}/api/channels/${encodeURIComponent(params.channelId)}/messages?${search.toString()}`,
73
+ {
74
+ headers: params.token ? { Authorization: `Bearer ${params.token}` } : {},
75
+ },
76
+ );
77
+ if (!response.ok) {
78
+ throw new Error(`HTTP ${response.status}: failed to fetch AgentChat history`);
79
+ }
80
+ const payload = (await response.json()) as { messages?: ChatMessage[] };
81
+ return payload.messages ?? [];
82
+ }
83
+
84
+ function clipMessageContent(content: string) {
85
+ return content.length > MAX_PER_MESSAGE_CHARS
86
+ ? `${content.slice(0, MAX_PER_MESSAGE_CHARS)}${TRUNCATED_SUFFIX}`
87
+ : content;
88
+ }
89
+
90
+ function buildContextPrefix(messages: ChatMessage[]) {
91
+ let totalBytes = 0;
92
+ const trimmed: Array<ChatMessage & { content: string }> = [];
93
+
94
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
95
+ const message = messages[index];
96
+ const clipped = clipMessageContent(message.content ?? "");
97
+ const size = Buffer.byteLength(clipped, "utf8");
98
+ if (totalBytes + size > MAX_CONTEXT_BYTES) break;
99
+ totalBytes += size;
100
+ trimmed.unshift({ ...message, content: clipped });
101
+ }
102
+
103
+ if (trimmed.length === 0) return "";
104
+
105
+ const truncated = trimmed.length < messages.length;
106
+ const note = truncated
107
+ ? `[频道上下文 - 最近 ${trimmed.length} 条消息(更早的已截断保护上下文窗口)]`
108
+ : `[频道上下文 - 自上次 @mention 以来 ${trimmed.length} 条消息]`;
109
+ const context = trimmed.map((message) => `${message.sender_id}: ${message.content}`).join("\n");
110
+ return `${note}\n${context}\n\n[你被 @mention 了,请回复]\n`;
111
+ }
112
+
113
+ function filterHistory(params: {
114
+ messages: ChatMessage[];
115
+ currentMessage: ChatMessage;
116
+ threadId?: string | number;
117
+ }) {
118
+ return params.messages.filter((message) => {
119
+ if (message.id === params.currentMessage.id) return false;
120
+ if (message.content === "__typing__") return false;
121
+
122
+ if (params.threadId !== undefined) {
123
+ const threadId = getThreadId(message);
124
+ if (threadId !== undefined && String(threadId) !== String(params.threadId)) return false;
125
+ }
126
+
127
+ return true;
128
+ });
129
+ }
130
+
131
+ export async function buildInboundPolicy(params: {
132
+ account: AgentChatResolvedAccount;
133
+ accountId: string;
134
+ message: ChatMessage;
135
+ log?: (level: "warn" | "error" | "debug", message: string, meta?: Record<string, unknown>) => void;
136
+ }): Promise<AgentChatInboundPolicyResult> {
137
+ const { account, accountId, message } = params;
138
+ const rawBody = message.content ?? "";
139
+ const threadId = getThreadId(message);
140
+ const conversationId = buildConversationId(message.channel_id, threadId);
141
+ const isDirect = isDirectChannel(message.channel_id);
142
+
143
+ if (!isDirect && !isMentioned(rawBody, account)) {
144
+ return {
145
+ shouldDispatch: false,
146
+ bodyForAgent: rawBody,
147
+ rawBody,
148
+ commandBody: rawBody,
149
+ };
150
+ }
151
+
152
+ if (isDirect) {
153
+ return {
154
+ shouldDispatch: true,
155
+ bodyForAgent: rawBody,
156
+ rawBody,
157
+ commandBody: rawBody,
158
+ };
159
+ }
160
+
161
+ const lastMentionTs = getMentionCursor(accountId, conversationId);
162
+ let prefix = "";
163
+
164
+ try {
165
+ const history = await fetchChannelHistory({
166
+ wsUrl: account.wsUrl,
167
+ token: account.token,
168
+ channelId: message.channel_id,
169
+ after: lastMentionTs,
170
+ });
171
+ const filtered = filterHistory({ messages: history, currentMessage: message, threadId });
172
+ prefix = buildContextPrefix(filtered);
173
+ } catch (error) {
174
+ params.log?.("warn", "AgentChat history fetch failed; falling back to single-message dispatch", {
175
+ accountId,
176
+ channelId: message.channel_id,
177
+ messageId: message.id,
178
+ error: error instanceof Error ? error.message : String(error),
179
+ });
180
+ }
181
+
182
+ setMentionCursor(accountId, conversationId, message.timestamp);
183
+ return {
184
+ shouldDispatch: true,
185
+ bodyForAgent: prefix ? `${prefix}${message.sender_id}: ${rawBody}` : rawBody,
186
+ rawBody,
187
+ commandBody: rawBody,
188
+ };
189
+ }
package/src/state.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { AgentChatClient } from "./agentchat-client";
2
+
3
+ import type { AgentChatGatewayState, AgentChatResolvedAccount } from "./types";
4
+
5
+ const gatewayStates = new Map<string, AgentChatGatewayState>();
6
+ const mentionCursors = new Map<string, string>();
7
+
8
+ function mentionCursorKey(accountId: string, conversationId: string) {
9
+ return `${accountId}:${conversationId}`;
10
+ }
11
+
12
+ export function getGatewayState(accountId: string) {
13
+ return gatewayStates.get(accountId);
14
+ }
15
+
16
+ export function setGatewayState(accountId: string, state: AgentChatGatewayState) {
17
+ gatewayStates.set(accountId, state);
18
+ }
19
+
20
+ export function deleteGatewayState(accountId: string) {
21
+ gatewayStates.delete(accountId);
22
+ }
23
+
24
+ export function getMentionCursor(accountId: string, conversationId: string) {
25
+ return mentionCursors.get(mentionCursorKey(accountId, conversationId));
26
+ }
27
+
28
+ export function setMentionCursor(accountId: string, conversationId: string, timestamp: string) {
29
+ mentionCursors.set(mentionCursorKey(accountId, conversationId), timestamp);
30
+ }
31
+
32
+ export function createGatewayClient(account: AgentChatResolvedAccount) {
33
+ return new AgentChatClient({
34
+ url: account.wsUrl,
35
+ agentId: account.agentId ?? account.accountId,
36
+ token: account.token,
37
+ capabilities: ["chat"],
38
+ });
39
+ }
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { AgentChatClient } from "./agentchat-client";
2
+
3
+ export const CHANNEL_ID = "agentchat" as const;
4
+ export const DEFAULT_WS_URL =
5
+ "wss://agentchat-server-679286795813.us-central1.run.app/ws";
6
+
7
+ export interface AgentChatAccountConfig {
8
+ name?: string;
9
+ agentId?: string;
10
+ token?: string;
11
+ wsUrl?: string;
12
+ defaultChannelId?: string;
13
+ enabled?: boolean;
14
+ }
15
+
16
+ export interface AgentChatChannelConfig {
17
+ defaultAccountId?: string;
18
+ accounts?: Record<string, AgentChatAccountConfig>;
19
+ }
20
+
21
+ export interface AgentChatPluginConfigRoot {
22
+ channels?: {
23
+ agentchat?: AgentChatChannelConfig;
24
+ };
25
+ }
26
+
27
+ export interface AgentChatResolvedAccount {
28
+ accountId: string;
29
+ name?: string;
30
+ agentId?: string;
31
+ token?: string;
32
+ wsUrl: string;
33
+ defaultChannelId?: string;
34
+ enabled: boolean;
35
+ }
36
+
37
+ export interface AgentChatGatewayState {
38
+ client: AgentChatClient;
39
+ abortHandler: () => void;
40
+ }
41
+
42
+ export interface AgentChatInboundPolicyResult {
43
+ shouldDispatch: boolean;
44
+ bodyForAgent: string;
45
+ rawBody: string;
46
+ commandBody: string;
47
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "lib": ["ESNext", "DOM"],
10
+ "types": ["node"]
11
+ },
12
+ "include": ["index.ts", "src/**/*.ts"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }