@stagewhisper/stagewhisper 0.9.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,86 @@
1
+ # @stagewhisper/stagewhisper
2
+
3
+ OpenClaw channel plugin that turns StageWhisper live call moments into assistant tasks.
4
+
5
+ ## What it does
6
+
7
+ During a live StageWhisper call, the system detects actionable moments (follow-ups, research requests, scheduling needs) and packages them as structured tasks. This plugin receives those tasks via the StageWhisper relay and injects them into OpenClaw as channel messages, allowing your AI assistant to start working while the call is still happening.
8
+
9
+ ## Setup
10
+
11
+ ### 1. Install the plugin
12
+
13
+ ```bash
14
+ openclaw plugins install @stagewhisper/stagewhisper
15
+ ```
16
+
17
+ Or install from a local path during development:
18
+
19
+ ```bash
20
+ openclaw plugins install /path/to/integrations/openclaw-stagewhisper-channel
21
+ ```
22
+
23
+ ### 2. Generate a pairing code in StageWhisper
24
+
25
+ Open StageWhisper desktop → Settings → Assistant → Generate pairing code.
26
+
27
+ ### 3. Complete pairing
28
+
29
+ ```bash
30
+ openclaw stagewhisper pair --code <PAIRING_CODE> --api-url https://api.stagewhisper.io
31
+ ```
32
+
33
+ Follow the printed instructions to apply config, then restart:
34
+
35
+ ```bash
36
+ openclaw gateway restart
37
+ ```
38
+
39
+ ### 4. Verify the connection
40
+
41
+ ```bash
42
+ openclaw stagewhisper status
43
+ ```
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ cd integrations/openclaw-stagewhisper-channel
49
+ pnpm install
50
+ pnpm test
51
+ pnpm typecheck
52
+ ```
53
+
54
+ ### Local install for testing
55
+
56
+ ```bash
57
+ openclaw plugins install $(pwd)
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ The plugin reads from `channels.stagewhisper` or `plugins.entries.stagewhisper.config`:
63
+
64
+ | Key | Required | Description |
65
+ |-----|----------|-------------|
66
+ | `apiBaseUrl` | Yes | StageWhisper backend URL |
67
+ | `integrationId` | Yes | Integration ID from pairing |
68
+ | `relayToken` | Yes | Relay token from pairing |
69
+ | `label` | No | Display label (default: "StageWhisper") |
70
+
71
+ ## Architecture
72
+
73
+ ```
74
+ StageWhisper Desktop → StageWhisper Backend (relay) → This Plugin → OpenClaw Assistant
75
+ ← Reply path ←
76
+ ```
77
+
78
+ The plugin runs a background service that:
79
+ 1. Connects to the StageWhisper relay stream (SSE)
80
+ 2. Receives queued assistant tasks
81
+ 3. Injects them as channel messages into OpenClaw
82
+ 4. Posts assistant replies back to StageWhisper
83
+
84
+ ## License
85
+
86
+ MIT
package/api.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { stagewhisperPlugin, resolveAccount } from "./src/channel.js";
2
+ export { StageWhisperClient } from "./src/client.js";
3
+ export type { TaskPayload, PairCompleteResponse } from "./src/client.js";
4
+ export type { StageWhisperAccount } from "./src/channel.js";
package/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import { stagewhisperPlugin } from "./src/channel.js";
3
+ import { setRuntime } from "./src/runtime.js";
4
+ import { createRelayService } from "./src/service.js";
5
+
6
+ export default definePluginEntry({
7
+ id: "stagewhisper",
8
+ name: "StageWhisper",
9
+ description: "Turn live call moments into assistant tasks via StageWhisper",
10
+ register(api) {
11
+ api.registerChannel({ plugin: stagewhisperPlugin });
12
+
13
+ const service = createRelayService(api);
14
+
15
+ api.registerCli(({ program }) => {
16
+ const sw = program
17
+ .command("stagewhisper")
18
+ .description("StageWhisper integration");
19
+
20
+ sw.command("pair")
21
+ .description("Pair with StageWhisper using a pairing code from the desktop app")
22
+ .requiredOption("--code <code>", "Pairing code from Settings → Assistant")
23
+ .requiredOption("--api-url <url>", "StageWhisper backend URL (e.g. https://api.stagewhisper.io)")
24
+ .option("--label <label>", "Label for this OpenClaw host", "OpenClaw")
25
+ .action(
26
+ async (opts: { code: string; apiUrl: string; label?: string }) => {
27
+ const { StageWhisperClient } = await import("./src/client.js");
28
+ const client = new StageWhisperClient(opts.apiUrl, "", "");
29
+ try {
30
+ const result = await client.completePairing(
31
+ opts.code,
32
+ opts.label ?? "OpenClaw",
33
+ );
34
+
35
+ console.log(`\n✓ Paired with StageWhisper (${result.label})\n`);
36
+ console.log(" Apply the config:\n");
37
+ console.log(` openclaw config set plugins.entries.stagewhisper.config.apiBaseUrl ${JSON.stringify(opts.apiUrl)}`);
38
+ console.log(` openclaw config set plugins.entries.stagewhisper.config.integrationId ${JSON.stringify(result.integration_id)}`);
39
+ console.log(` openclaw config set plugins.entries.stagewhisper.config.relayToken ${JSON.stringify(result.relay_token)}`);
40
+ console.log(` openclaw config set plugins.entries.stagewhisper.config.label ${JSON.stringify(result.label)}`);
41
+ console.log("\n Then restart OpenClaw to activate the relay:\n");
42
+ console.log(" openclaw gateway restart\n");
43
+ } catch (err) {
44
+ console.error(`\n✗ Pairing failed: ${err}\n`);
45
+ process.exit(1);
46
+ }
47
+ },
48
+ );
49
+
50
+ sw.command("status")
51
+ .description("Show StageWhisper relay connection status")
52
+ .action(() => {
53
+ const relayState = service.getState();
54
+ const cfg = api.pluginConfig as Record<string, string> | undefined;
55
+ const configured = !!(cfg?.["integrationId"] && cfg?.["relayToken"]);
56
+
57
+ if (!configured) {
58
+ console.log("\nStageWhisper: not paired\n");
59
+ console.log(" Run: openclaw stagewhisper pair --code <CODE> --api-url <URL>\n");
60
+ console.log(" Get the pairing code from StageWhisper desktop: Settings → Assistant → Generate Pairing Code\n");
61
+ return;
62
+ }
63
+
64
+ console.log(`\nStageWhisper relay:`);
65
+ console.log(` Connected: ${relayState.connected}`);
66
+ console.log(` Last heartbeat: ${relayState.lastHeartbeat?.toISOString() ?? "never"}`);
67
+ console.log(` Reconnect attempts: ${relayState.reconnectAttempts}`);
68
+ console.log(` Backend: ${cfg?.["apiBaseUrl"] ?? "(unset)"}\n`);
69
+ });
70
+ });
71
+
72
+ if (api.registrationMode !== "full") return;
73
+
74
+ setRuntime(api.runtime);
75
+ api.registerService(service);
76
+ },
77
+ });
@@ -0,0 +1,49 @@
1
+ {
2
+ "id": "stagewhisper",
3
+ "name": "StageWhisper",
4
+ "description": "Turn live call moments into assistant tasks via StageWhisper",
5
+ "version": "0.9.0",
6
+ "channels": [
7
+ "stagewhisper"
8
+ ],
9
+ "skills": [
10
+ "skills/stagewhisper-assistant"
11
+ ],
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "apiBaseUrl": {
17
+ "type": "string",
18
+ "description": "StageWhisper backend URL (e.g. https://api.stagewhisper.io)",
19
+ "default": ""
20
+ },
21
+ "integrationId": {
22
+ "type": "string",
23
+ "description": "Integration ID returned after pairing",
24
+ "default": ""
25
+ },
26
+ "relayToken": {
27
+ "type": "string",
28
+ "description": "Relay token returned after pairing",
29
+ "default": ""
30
+ },
31
+ "label": {
32
+ "type": "string",
33
+ "description": "Human-readable label for this connection",
34
+ "default": "OpenClaw"
35
+ }
36
+ },
37
+ "required": []
38
+ },
39
+ "uiHints": {
40
+ "relayToken": {
41
+ "label": "Relay token",
42
+ "sensitive": true
43
+ },
44
+ "apiBaseUrl": {
45
+ "label": "StageWhisper URL",
46
+ "placeholder": "https://api.stagewhisper.io"
47
+ }
48
+ }
49
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@stagewhisper/stagewhisper",
3
+ "version": "0.9.0",
4
+ "type": "module",
5
+ "description": "OpenClaw channel plugin that connects StageWhisper live calls to your AI assistant",
6
+ "license": "MIT",
7
+ "files": [
8
+ "index.ts",
9
+ "setup-entry.ts",
10
+ "api.ts",
11
+ "src",
12
+ "openclaw.plugin.json",
13
+ "skills"
14
+ ],
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "openclaw": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ],
24
+ "setupEntry": "./setup-entry.ts",
25
+ "channel": {
26
+ "id": "stagewhisper",
27
+ "label": "StageWhisper",
28
+ "blurb": "Turn live call moments into assistant tasks"
29
+ }
30
+ },
31
+ "devDependencies": {
32
+ "openclaw": "2026.3.23",
33
+ "typescript": "^5.7.0",
34
+ "vitest": "^3.0.0"
35
+ },
36
+ "peerDependencies": {
37
+ "openclaw": ">=2026.3.0"
38
+ }
39
+ }
package/setup-entry.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
2
+ import { stagewhisperPlugin } from "./src/channel.js";
3
+
4
+ export default defineSetupPluginEntry(stagewhisperPlugin);
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: StageWhisper Assistant
3
+ description: Handle tasks that arrive from StageWhisper live calls
4
+ ---
5
+
6
+ You are receiving messages from the StageWhisper channel. Each message represents a task that was created during a live call because the speaker said or implied that follow-up work is needed.
7
+
8
+ ## What you know about StageWhisper tasks
9
+
10
+ Each task arrives with:
11
+ - A title summarizing what needs to happen
12
+ - The explicit ask from the call
13
+ - Transcript context showing what was said
14
+ - Signal and tone summaries when available
15
+ - The playbook label (sales, support, interview, etc.)
16
+
17
+ ## How to respond
18
+
19
+ - Start working immediately. The caller may still be on the call.
20
+ - Keep replies short and concrete. Summarize what you did and what the result is.
21
+ - If a task is ambiguous, do the safe version and note what you assumed.
22
+ - If a task requires access you don't have, say so clearly in one sentence rather than attempting workarounds.
23
+ - Call out blockers early. "I can't access that CRM" is better than silence.
24
+
25
+ ## Task types you may see
26
+
27
+ - **research**: Look up information (competitor pricing, candidate profiles, case references)
28
+ - **draft**: Write a follow-up email, summary, or note
29
+ - **schedule**: Create calendar holds or propose meeting times
30
+ - **lookup**: Find internal documents, tickets, or records
31
+ - **notify**: Send a message or update to a specific person or channel
32
+
33
+ ## What not to do
34
+
35
+ - Do not ask clarifying questions back through the channel. The caller is in a live meeting.
36
+ - Do not run shell commands or make system changes unless the task explicitly asks for it.
37
+ - Do not restate the entire task back. Just do the work and report the result.
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { StageWhisperClient } from "./client.js";
3
+
4
+ describe("StageWhisperClient", () => {
5
+ beforeEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ describe("completePairing", () => {
10
+ it("sends pairing code and returns credentials", async () => {
11
+ const mockResponse = {
12
+ integration_id: "int-123",
13
+ relay_token: "tok-abc",
14
+ label: "StageWhisper (openclaw)",
15
+ };
16
+
17
+ vi.stubGlobal(
18
+ "fetch",
19
+ vi.fn().mockResolvedValue({
20
+ ok: true,
21
+ json: () => Promise.resolve(mockResponse),
22
+ }),
23
+ );
24
+
25
+ const client = new StageWhisperClient("https://sw.test", "", "");
26
+ const result = await client.completePairing("ABC123", "MyHost");
27
+
28
+ expect(fetch).toHaveBeenCalledWith(
29
+ "https://sw.test/api/v1/openclaw/pair/complete",
30
+ expect.objectContaining({
31
+ method: "POST",
32
+ body: JSON.stringify({
33
+ pairing_code: "ABC123",
34
+ host_label: "MyHost",
35
+ }),
36
+ }),
37
+ );
38
+
39
+ expect(result.integration_id).toBe("int-123");
40
+ expect(result.relay_token).toBe("tok-abc");
41
+ });
42
+
43
+ it("throws on failure", async () => {
44
+ vi.stubGlobal(
45
+ "fetch",
46
+ vi.fn().mockResolvedValue({
47
+ ok: false,
48
+ status: 400,
49
+ text: () => Promise.resolve("Invalid code"),
50
+ }),
51
+ );
52
+
53
+ const client = new StageWhisperClient("https://sw.test", "", "");
54
+ await expect(client.completePairing("BAD", "Host")).rejects.toThrow(
55
+ "Pairing failed (400)",
56
+ );
57
+ });
58
+ });
59
+
60
+ describe("postReply", () => {
61
+ it("sends reply content to the correct endpoint", async () => {
62
+ vi.stubGlobal(
63
+ "fetch",
64
+ vi.fn().mockResolvedValue({
65
+ ok: true,
66
+ json: () => Promise.resolve({ ok: true }),
67
+ }),
68
+ );
69
+
70
+ const client = new StageWhisperClient(
71
+ "https://sw.test",
72
+ "int-1",
73
+ "tok-1",
74
+ );
75
+ await client.postReply("task-42", "Here is the answer", "msg-99");
76
+
77
+ expect(fetch).toHaveBeenCalledWith(
78
+ "https://sw.test/api/v1/openclaw/tasks/task-42/reply",
79
+ expect.objectContaining({
80
+ method: "POST",
81
+ headers: expect.objectContaining({
82
+ Authorization: "Bearer tok-1",
83
+ }),
84
+ body: JSON.stringify({
85
+ content: "Here is the answer",
86
+ remote_message_id: "msg-99",
87
+ }),
88
+ }),
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("updateTaskStatus", () => {
94
+ it("sends status update", async () => {
95
+ vi.stubGlobal(
96
+ "fetch",
97
+ vi.fn().mockResolvedValue({
98
+ ok: true,
99
+ json: () => Promise.resolve({ ok: true }),
100
+ }),
101
+ );
102
+
103
+ const client = new StageWhisperClient(
104
+ "https://sw.test",
105
+ "int-1",
106
+ "tok-1",
107
+ );
108
+ await client.updateTaskStatus("task-42", "running", "remote-77");
109
+
110
+ expect(fetch).toHaveBeenCalledWith(
111
+ "https://sw.test/api/v1/openclaw/tasks/task-42/status",
112
+ expect.objectContaining({
113
+ method: "POST",
114
+ body: JSON.stringify({
115
+ status: "running",
116
+ remote_task_id: "remote-77",
117
+ }),
118
+ }),
119
+ );
120
+ });
121
+ });
122
+
123
+ describe("heartbeat", () => {
124
+ it("sends heartbeat and returns response", async () => {
125
+ vi.stubGlobal(
126
+ "fetch",
127
+ vi.fn().mockResolvedValue({
128
+ ok: true,
129
+ json: () => Promise.resolve({ ok: true }),
130
+ }),
131
+ );
132
+
133
+ const client = new StageWhisperClient(
134
+ "https://sw.test",
135
+ "int-1",
136
+ "tok-1",
137
+ );
138
+ const result = await client.heartbeat();
139
+
140
+ expect(result.ok).toBe(true);
141
+ expect(fetch).toHaveBeenCalledWith(
142
+ "https://sw.test/api/v1/openclaw/integrations/int-1/heartbeat",
143
+ expect.objectContaining({
144
+ method: "POST",
145
+ headers: expect.objectContaining({
146
+ Authorization: "Bearer tok-1",
147
+ }),
148
+ }),
149
+ );
150
+ });
151
+ });
152
+
153
+ describe("streamUrl", () => {
154
+ it("builds correct stream URL", () => {
155
+ const client = new StageWhisperClient(
156
+ "https://sw.test/",
157
+ "int-1",
158
+ "tok-1",
159
+ );
160
+ expect(client.streamUrl()).toBe(
161
+ "https://sw.test/api/v1/openclaw/integrations/int-1/stream",
162
+ );
163
+ });
164
+ });
165
+ });
166
+
167
+ describe("config resolution", () => {
168
+ it("resolveAccount extracts config from channels.stagewhisper", async () => {
169
+ const { stagewhisperPlugin } = await import("./channel.js");
170
+
171
+ const cfg = {
172
+ channels: {
173
+ stagewhisper: {
174
+ apiBaseUrl: "https://sw.test",
175
+ integrationId: "int-1",
176
+ relayToken: "tok-1",
177
+ label: "My SW",
178
+ },
179
+ },
180
+ } as unknown as import("openclaw/plugin-sdk/core").OpenClawConfig;
181
+
182
+ const base = stagewhisperPlugin.base;
183
+ if (base?.setup?.resolveAccount) {
184
+ const account = base.setup.resolveAccount(cfg);
185
+ expect(account.apiBaseUrl).toBe("https://sw.test");
186
+ expect(account.integrationId).toBe("int-1");
187
+ expect(account.relayToken).toBe("tok-1");
188
+ expect(account.label).toBe("My SW");
189
+ }
190
+ });
191
+
192
+ it("resolveAccount throws when config is missing", async () => {
193
+ const { stagewhisperPlugin } = await import("./channel.js");
194
+
195
+ const cfg = { channels: {} } as unknown as import("openclaw/plugin-sdk/core").OpenClawConfig;
196
+ const base = stagewhisperPlugin.base;
197
+ if (base?.setup?.resolveAccount) {
198
+ expect(() => base.setup.resolveAccount(cfg)).toThrow("apiBaseUrl is required");
199
+ }
200
+ });
201
+ });
package/src/channel.ts ADDED
@@ -0,0 +1,165 @@
1
+ import {
2
+ createChatChannelPlugin,
3
+ createChannelPluginBase,
4
+ DEFAULT_ACCOUNT_ID,
5
+ } from "openclaw/plugin-sdk/core";
6
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
7
+ import { StageWhisperClient } from "./client.js";
8
+
9
+ export type StageWhisperAccount = {
10
+ accountId: string | null;
11
+ apiBaseUrl: string;
12
+ integrationId: string;
13
+ relayToken: string;
14
+ label: string;
15
+ };
16
+
17
+ function getChannelSection(cfg: OpenClawConfig): Record<string, unknown> {
18
+ const channels = cfg.channels as
19
+ | Record<string, Record<string, unknown>>
20
+ | undefined;
21
+ return channels?.["stagewhisper"] ?? {};
22
+ }
23
+
24
+ export function resolveAccount(
25
+ cfg: OpenClawConfig,
26
+ accountId?: string | null,
27
+ ): StageWhisperAccount {
28
+ const section = getChannelSection(cfg);
29
+ const pluginCfg = (cfg as Record<string, unknown>)?.["plugins"] as
30
+ | Record<string, unknown>
31
+ | undefined;
32
+ const entries = pluginCfg?.["entries"] as
33
+ | Record<string, Record<string, unknown>>
34
+ | undefined;
35
+ const swConfig = entries?.["stagewhisper"]?.["config"] as
36
+ | Record<string, unknown>
37
+ | undefined;
38
+
39
+ const apiBaseUrl =
40
+ (section["apiBaseUrl"] as string) ??
41
+ (swConfig?.["apiBaseUrl"] as string) ??
42
+ "";
43
+ const integrationId =
44
+ (section["integrationId"] as string) ??
45
+ (swConfig?.["integrationId"] as string) ??
46
+ "";
47
+ const relayToken =
48
+ (section["relayToken"] as string) ??
49
+ (swConfig?.["relayToken"] as string) ??
50
+ "";
51
+ const label =
52
+ (section["label"] as string) ??
53
+ (swConfig?.["label"] as string) ??
54
+ "StageWhisper";
55
+
56
+ if (!apiBaseUrl) throw new Error("stagewhisper: apiBaseUrl is required");
57
+ if (!integrationId)
58
+ throw new Error("stagewhisper: integrationId is required");
59
+ if (!relayToken) throw new Error("stagewhisper: relayToken is required");
60
+
61
+ return {
62
+ accountId: accountId ?? null,
63
+ apiBaseUrl,
64
+ integrationId,
65
+ relayToken,
66
+ label,
67
+ };
68
+ }
69
+
70
+ const base = createChannelPluginBase<StageWhisperAccount>({
71
+ id: "stagewhisper",
72
+ capabilities: {
73
+ chatTypes: ["direct"],
74
+ },
75
+ config: {
76
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
77
+ resolveAccount,
78
+ inspectAccount(cfg: OpenClawConfig) {
79
+ try {
80
+ const account = resolveAccount(cfg);
81
+ return {
82
+ enabled: true,
83
+ configured: true,
84
+ label: account.label,
85
+ apiBaseUrl: account.apiBaseUrl,
86
+ };
87
+ } catch {
88
+ return {
89
+ enabled: false,
90
+ configured: false,
91
+ tokenStatus: "missing",
92
+ };
93
+ }
94
+ },
95
+ },
96
+ setup: {
97
+ applyAccountConfig({ cfg, input }) {
98
+ const channels = (cfg.channels ?? {}) as Record<
99
+ string,
100
+ Record<string, unknown>
101
+ >;
102
+ const section = { ...(channels["stagewhisper"] ?? {}) };
103
+ const raw = input as unknown as Record<string, string>;
104
+
105
+ if (raw["apiBaseUrl"]) section["apiBaseUrl"] = raw["apiBaseUrl"];
106
+ if (raw["integrationId"])
107
+ section["integrationId"] = raw["integrationId"];
108
+ if (raw["relayToken"]) section["relayToken"] = raw["relayToken"];
109
+ if (raw["label"]) section["label"] = raw["label"];
110
+
111
+ return {
112
+ ...cfg,
113
+ channels: { ...channels, stagewhisper: section },
114
+ } as OpenClawConfig;
115
+ },
116
+ },
117
+ });
118
+
119
+ export const stagewhisperPlugin = createChatChannelPlugin<StageWhisperAccount>(
120
+ {
121
+ base: base as unknown as Parameters<typeof createChatChannelPlugin<StageWhisperAccount>>[0]["base"],
122
+
123
+ security: {
124
+ dm: {
125
+ channelKey: "stagewhisper",
126
+ resolvePolicy: () => "closed",
127
+ resolveAllowFrom: () => [],
128
+ defaultPolicy: "closed",
129
+ },
130
+ },
131
+
132
+ pairing: {
133
+ text: {
134
+ idLabel: "StageWhisper pairing code",
135
+ message: "Run this command to complete pairing:",
136
+ notify: async () => {},
137
+ },
138
+ },
139
+
140
+ threading: { topLevelReplyToMode: "reply" },
141
+
142
+ outbound: {
143
+ base: { deliveryMode: "direct" },
144
+ attachedResults: {
145
+ channel: "stagewhisper",
146
+ sendText: async (ctx) => {
147
+ const account = resolveAccount(ctx.cfg, ctx.accountId);
148
+ const client = new StageWhisperClient(
149
+ account.apiBaseUrl,
150
+ account.integrationId,
151
+ account.relayToken,
152
+ );
153
+
154
+ const taskId = ctx.threadId as string | undefined;
155
+ if (!taskId) {
156
+ return { messageId: `sw-noop-${Date.now()}`, ok: true };
157
+ }
158
+
159
+ await client.postReply(taskId, ctx.text);
160
+ return { messageId: `sw-reply-${taskId}-${Date.now()}`, ok: true };
161
+ },
162
+ },
163
+ },
164
+ },
165
+ );
package/src/client.ts ADDED
@@ -0,0 +1,121 @@
1
+ export type TaskPayload = {
2
+ id: string;
3
+ session_id: string;
4
+ title: string;
5
+ request_text: string;
6
+ action_type: string;
7
+ status: string;
8
+ evidence_payload: Record<string, unknown> | null;
9
+ created_at: string;
10
+ };
11
+
12
+ export type PairCompleteResponse = {
13
+ integration_id: string;
14
+ relay_token: string;
15
+ label: string;
16
+ };
17
+
18
+ export type HeartbeatResponse = {
19
+ ok: boolean;
20
+ };
21
+
22
+ export class StageWhisperClient {
23
+ private baseUrl: string;
24
+ private integrationId: string;
25
+ private relayToken: string;
26
+
27
+ constructor(baseUrl: string, integrationId: string, relayToken: string) {
28
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
29
+ this.integrationId = integrationId;
30
+ this.relayToken = relayToken;
31
+ }
32
+
33
+ private headers(): Record<string, string> {
34
+ return {
35
+ Authorization: `Bearer ${this.relayToken}`,
36
+ "Content-Type": "application/json",
37
+ };
38
+ }
39
+
40
+ async completePairing(
41
+ pairingCode: string,
42
+ hostLabel: string,
43
+ ): Promise<PairCompleteResponse> {
44
+ const res = await fetch(`${this.baseUrl}/api/v1/openclaw/pair/complete`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({
48
+ pairing_code: pairingCode,
49
+ host_label: hostLabel,
50
+ }),
51
+ });
52
+ if (!res.ok) {
53
+ const text = await res.text();
54
+ throw new Error(`Pairing failed (${res.status}): ${text}`);
55
+ }
56
+ return res.json();
57
+ }
58
+
59
+ async updateTaskStatus(
60
+ taskId: string,
61
+ status: string,
62
+ remoteTaskId?: string,
63
+ ): Promise<void> {
64
+ const body: Record<string, unknown> = { status };
65
+ if (remoteTaskId) body.remote_task_id = remoteTaskId;
66
+
67
+ const res = await fetch(
68
+ `${this.baseUrl}/api/v1/openclaw/tasks/${taskId}/status`,
69
+ {
70
+ method: "POST",
71
+ headers: this.headers(),
72
+ body: JSON.stringify(body),
73
+ },
74
+ );
75
+ if (!res.ok) {
76
+ const text = await res.text();
77
+ throw new Error(`Status update failed (${res.status}): ${text}`);
78
+ }
79
+ }
80
+
81
+ async postReply(taskId: string, content: string, remoteMessageId?: string): Promise<void> {
82
+ const body: Record<string, unknown> = { content };
83
+ if (remoteMessageId) body.remote_message_id = remoteMessageId;
84
+
85
+ const res = await fetch(
86
+ `${this.baseUrl}/api/v1/openclaw/tasks/${taskId}/reply`,
87
+ {
88
+ method: "POST",
89
+ headers: this.headers(),
90
+ body: JSON.stringify(body),
91
+ },
92
+ );
93
+ if (!res.ok) {
94
+ const text = await res.text();
95
+ throw new Error(`Reply failed (${res.status}): ${text}`);
96
+ }
97
+ }
98
+
99
+ async heartbeat(): Promise<HeartbeatResponse> {
100
+ const res = await fetch(
101
+ `${this.baseUrl}/api/v1/openclaw/integrations/${this.integrationId}/heartbeat`,
102
+ {
103
+ method: "POST",
104
+ headers: this.headers(),
105
+ },
106
+ );
107
+ if (!res.ok) {
108
+ const text = await res.text();
109
+ throw new Error(`Heartbeat failed (${res.status}): ${text}`);
110
+ }
111
+ return res.json();
112
+ }
113
+
114
+ streamUrl(): string {
115
+ return `${this.baseUrl}/api/v1/openclaw/integrations/${this.integrationId}/stream`;
116
+ }
117
+
118
+ streamHeaders(): Record<string, string> {
119
+ return { Authorization: `Bearer ${this.relayToken}` };
120
+ }
121
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
3
+
4
+ export const { setRuntime, getRuntime } = createPluginRuntimeStore<PluginRuntime>(
5
+ "StageWhisper plugin runtime not initialized",
6
+ );
package/src/service.ts ADDED
@@ -0,0 +1,261 @@
1
+ import type {
2
+ OpenClawPluginApi,
3
+ OpenClawPluginServiceContext,
4
+ } from "openclaw/plugin-sdk/core";
5
+ import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
6
+ import { StageWhisperClient } from "./client.js";
7
+ import type { TaskPayload } from "./client.js";
8
+ import type { StageWhisperAccount } from "./channel.js";
9
+ import { resolveAccount } from "./channel.js";
10
+
11
+ const HEARTBEAT_INTERVAL_MS = 30_000;
12
+ const RECONNECT_BASE_MS = 1_000;
13
+ const RECONNECT_MAX_MS = 60_000;
14
+
15
+ type ServiceState = {
16
+ running: boolean;
17
+ connected: boolean;
18
+ lastHeartbeat: Date | null;
19
+ reconnectAttempts: number;
20
+ };
21
+
22
+ export function createRelayService(api: OpenClawPluginApi) {
23
+ const state: ServiceState = {
24
+ running: false,
25
+ connected: false,
26
+ lastHeartbeat: null,
27
+ reconnectAttempts: 0,
28
+ };
29
+
30
+ let abortController: AbortController | null = null;
31
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
32
+
33
+ function resolveServiceAccount(): StageWhisperAccount {
34
+ try {
35
+ return resolveAccount(api.config);
36
+ } catch {
37
+ const pluginCfg = api.pluginConfig as Record<string, unknown>;
38
+ return {
39
+ accountId: null,
40
+ apiBaseUrl: (pluginCfg["apiBaseUrl"] as string) ?? "",
41
+ integrationId: (pluginCfg["integrationId"] as string) ?? "",
42
+ relayToken: (pluginCfg["relayToken"] as string) ?? "",
43
+ label: (pluginCfg["label"] as string) ?? "StageWhisper",
44
+ };
45
+ }
46
+ }
47
+
48
+ function buildTaskMessage(task: TaskPayload): string {
49
+ const lines: string[] = [];
50
+ lines.push(`**${task.title}**`);
51
+ lines.push("");
52
+ lines.push(task.request_text);
53
+
54
+ if (task.evidence_payload) {
55
+ const evidence = task.evidence_payload;
56
+ if (evidence["transcript_excerpt"]) {
57
+ lines.push("");
58
+ lines.push(`Context: ${evidence["transcript_excerpt"]}`);
59
+ }
60
+ if (evidence["signal_summary"]) {
61
+ lines.push(`Signal: ${evidence["signal_summary"]}`);
62
+ }
63
+ if (evidence["tone_summary"]) {
64
+ lines.push(`Tone: ${evidence["tone_summary"]}`);
65
+ }
66
+ if (evidence["playbook_label"]) {
67
+ lines.push(`Playbook: ${evidence["playbook_label"]}`);
68
+ }
69
+ }
70
+
71
+ lines.push("");
72
+ lines.push(`Action type: ${task.action_type}`);
73
+ lines.push(`StageWhisper task: ${task.id}`);
74
+ lines.push(`Session: ${task.session_id}`);
75
+
76
+ return lines.join("\n");
77
+ }
78
+
79
+ async function handleTask(
80
+ task: TaskPayload,
81
+ client: StageWhisperClient,
82
+ ): Promise<void> {
83
+ api.logger.info(`Received task: ${task.title} (${task.id})`);
84
+
85
+ try {
86
+ await client.updateTaskStatus(task.id, "delivered");
87
+ } catch (err) {
88
+ api.logger.warn(`Failed to mark task as delivered: ${err}`);
89
+ }
90
+
91
+ const messageContent = buildTaskMessage(task);
92
+
93
+ try {
94
+ const sessionKey = buildAgentSessionKey({
95
+ agentId: "default",
96
+ channel: "stagewhisper",
97
+ peer: { kind: "direct", id: `sw-session-${task.session_id}` },
98
+ });
99
+
100
+ const result = await api.runtime.subagent.run({
101
+ sessionKey,
102
+ message: messageContent,
103
+ deliver: true,
104
+ });
105
+
106
+ api.logger.info(
107
+ `Task ${task.id} dispatched to agent session (runId: ${result.runId})`,
108
+ );
109
+ await client.updateTaskStatus(task.id, "running");
110
+ } catch (err) {
111
+ api.logger.error(`Failed to dispatch task to agent: ${err}`);
112
+ await client.updateTaskStatus(task.id, "failed").catch(() => {});
113
+ }
114
+ }
115
+
116
+ async function connectStream(account: StageWhisperAccount): Promise<void> {
117
+ const client = new StageWhisperClient(
118
+ account.apiBaseUrl,
119
+ account.integrationId,
120
+ account.relayToken,
121
+ );
122
+
123
+ abortController = new AbortController();
124
+
125
+ const res = await fetch(client.streamUrl(), {
126
+ headers: client.streamHeaders(),
127
+ signal: abortController.signal,
128
+ });
129
+
130
+ if (!res.ok) {
131
+ throw new Error(`Stream connection failed (${res.status})`);
132
+ }
133
+
134
+ if (!res.body) {
135
+ throw new Error("Stream response has no body");
136
+ }
137
+
138
+ state.connected = true;
139
+ state.reconnectAttempts = 0;
140
+ api.logger.info("Connected to StageWhisper relay stream");
141
+
142
+ const reader = res.body.getReader();
143
+ const decoder = new TextDecoder();
144
+ let buffer = "";
145
+
146
+ try {
147
+ while (state.running) {
148
+ const { done, value } = await reader.read();
149
+ if (done) break;
150
+
151
+ buffer += decoder.decode(value, { stream: true });
152
+ const lines = buffer.split("\n");
153
+ buffer = lines.pop() ?? "";
154
+
155
+ for (const line of lines) {
156
+ if (!line.startsWith("data: ")) continue;
157
+ const jsonStr = line.slice(6).trim();
158
+ if (!jsonStr) continue;
159
+
160
+ try {
161
+ const task = JSON.parse(jsonStr) as TaskPayload;
162
+ await handleTask(task, client);
163
+ } catch (parseErr) {
164
+ api.logger.warn(`Failed to parse stream event: ${parseErr}`);
165
+ }
166
+ }
167
+ }
168
+ } finally {
169
+ reader.releaseLock();
170
+ state.connected = false;
171
+ }
172
+ }
173
+
174
+ function backoffMs(): number {
175
+ const ms = Math.min(
176
+ RECONNECT_BASE_MS * Math.pow(2, state.reconnectAttempts),
177
+ RECONNECT_MAX_MS,
178
+ );
179
+ return ms + Math.random() * 1000;
180
+ }
181
+
182
+ async function runLoop(account: StageWhisperAccount): Promise<void> {
183
+ while (state.running) {
184
+ try {
185
+ await connectStream(account);
186
+ } catch (err) {
187
+ if (!state.running) break;
188
+ state.reconnectAttempts++;
189
+ const delay = backoffMs();
190
+ api.logger.warn(
191
+ `Stream disconnected (attempt ${state.reconnectAttempts}), reconnecting in ${Math.round(delay)}ms: ${err}`,
192
+ );
193
+ await new Promise((r) => setTimeout(r, delay));
194
+ }
195
+ }
196
+ }
197
+
198
+ function startHeartbeat(account: StageWhisperAccount): void {
199
+ const client = new StageWhisperClient(
200
+ account.apiBaseUrl,
201
+ account.integrationId,
202
+ account.relayToken,
203
+ );
204
+
205
+ heartbeatTimer = setInterval(async () => {
206
+ try {
207
+ await client.heartbeat();
208
+ state.lastHeartbeat = new Date();
209
+ } catch (err) {
210
+ api.logger.warn(`Heartbeat failed: ${err}`);
211
+ }
212
+ }, HEARTBEAT_INTERVAL_MS);
213
+ }
214
+
215
+ return {
216
+ id: "stagewhisper-relay",
217
+
218
+ async start(_ctx: OpenClawPluginServiceContext): Promise<void> {
219
+ const account = resolveServiceAccount();
220
+ if (
221
+ !account.apiBaseUrl ||
222
+ !account.integrationId ||
223
+ !account.relayToken
224
+ ) {
225
+ api.logger.info(
226
+ "StageWhisper not paired. Run: openclaw stagewhisper pair --code <CODE> --api-url <URL>",
227
+ );
228
+ api.logger.info(
229
+ "Get the pairing code from StageWhisper desktop: Settings → Assistant → Generate Pairing Code",
230
+ );
231
+ return;
232
+ }
233
+
234
+ state.running = true;
235
+ startHeartbeat(account);
236
+ runLoop(account).catch((err) => {
237
+ api.logger.error(`Relay service crashed: ${err}`);
238
+ });
239
+ api.logger.info(
240
+ `StageWhisper relay service started for ${account.label}`,
241
+ );
242
+ },
243
+
244
+ async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
245
+ state.running = false;
246
+ if (heartbeatTimer) {
247
+ clearInterval(heartbeatTimer);
248
+ heartbeatTimer = null;
249
+ }
250
+ if (abortController) {
251
+ abortController.abort();
252
+ abortController = null;
253
+ }
254
+ api.logger.info("StageWhisper relay service stopped");
255
+ },
256
+
257
+ getState(): ServiceState {
258
+ return { ...state };
259
+ },
260
+ };
261
+ }