@spikelabs/lobster-shell-plugin 0.2.2

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,80 @@
1
+ # @spikelabs/lobster-shell-plugin
2
+
3
+ Connect your OpenClaw agent to [Lobster Shell](https://www.lobstershell.ai) — give your bot a face, voice, and video presence.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @spikelabs/lobster-shell-plugin
9
+ ```
10
+
11
+ OpenClaw discovers the plugin automatically via its `openclaw.plugin.json` manifest.
12
+
13
+ ## Setup
14
+
15
+ 1. Start your OpenClaw gateway
16
+ 2. Visit **http://localhost:18789/lobster/setup** in your browser
17
+ 3. Click **Connect to Lobster Shell**
18
+ 4. Sign in (or create an account) on lobstershell.ai
19
+ 5. You'll be redirected back — the setup page should show "Connected!"
20
+
21
+ No API keys needed. The plugin uses OAuth 2.1 with PKCE to securely link your gateway to your Lobster Shell account.
22
+
23
+ ## How it works
24
+
25
+ Once connected, the plugin:
26
+
27
+ - **Relays messages** between your OpenClaw agent and the Lobster Shell avatar renderer via an Ably real-time bridge
28
+ - **Publishes events** (`message_received`, `agent_thinking`, `agent_response`) so the avatar reacts in real time
29
+ - **Supports Shell Live** — real-time voice/video conversations through LiveKit
30
+
31
+ ## Configuration
32
+
33
+ The plugin works with zero configuration. Optional settings:
34
+
35
+ | Variable | Default | Description |
36
+ |---|---|---|
37
+ | `OPENCLAW_GATEWAY_TOKEN` | `dev-token-1234` | Gateway WebSocket auth token (set this if you changed the default) |
38
+
39
+ You can also override the cloud URL via the plugin config in OpenClaw if you're running a custom Lobster Shell instance:
40
+
41
+ ```json
42
+ {
43
+ "pluginConfig": {
44
+ "lobster-shell": {
45
+ "cloudUrl": "https://your-custom-instance.com"
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Diagnostics
52
+
53
+ Use the `/lobster-status` command in your OpenClaw chat to check connection status:
54
+
55
+ ```
56
+ /lobster-status
57
+ ```
58
+
59
+ This shows plugin version, gateway port, cloud bridge status, and channel info.
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ pnpm install
65
+ pnpm build # Build once
66
+ pnpm dev # Watch mode
67
+ pnpm lint # Type-check
68
+ ```
69
+
70
+ To test with a local OpenClaw Docker container, copy the built plugin:
71
+
72
+ ```bash
73
+ pnpm build
74
+ cp dist/* .openclaw-dev/config/extensions/lobster-shell/dist/
75
+ docker compose restart
76
+ ```
77
+
78
+ ## License
79
+
80
+ MIT
@@ -0,0 +1,202 @@
1
+ // src/oauth-flow.ts
2
+ import { randomBytes, createHash } from "crypto";
3
+ import { readFile, writeFile, mkdir } from "fs/promises";
4
+ import { join } from "path";
5
+ function generateCodeVerifier() {
6
+ return randomBytes(32).toString("base64url");
7
+ }
8
+ function generateCodeChallenge(verifier) {
9
+ return createHash("sha256").update(verifier).digest("base64url");
10
+ }
11
+ var OAuthFlow = class {
12
+ stateDir;
13
+ cloudUrl;
14
+ logger;
15
+ constructor(opts) {
16
+ this.stateDir = opts.stateDir;
17
+ this.cloudUrl = opts.cloudUrl;
18
+ this.logger = opts.logger;
19
+ }
20
+ // -------------------------------------------------------------------------
21
+ // Persistence
22
+ // -------------------------------------------------------------------------
23
+ async loadAuth() {
24
+ try {
25
+ const raw = await readFile(join(this.stateDir, "auth.json"), "utf-8");
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ async saveAuth(state) {
32
+ await mkdir(this.stateDir, { recursive: true });
33
+ await writeFile(join(this.stateDir, "auth.json"), JSON.stringify(state, null, 2));
34
+ }
35
+ async loadPending() {
36
+ try {
37
+ const raw = await readFile(join(this.stateDir, "pending-auth.json"), "utf-8");
38
+ return JSON.parse(raw);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ async savePending(pending) {
44
+ await mkdir(this.stateDir, { recursive: true });
45
+ await writeFile(join(this.stateDir, "pending-auth.json"), JSON.stringify(pending, null, 2));
46
+ }
47
+ // -------------------------------------------------------------------------
48
+ // Step 1: Discover OAuth metadata
49
+ // -------------------------------------------------------------------------
50
+ async discover() {
51
+ const url = `${this.cloudUrl}/.well-known/oauth-authorization-server`;
52
+ this.logger.info(`[lobster:oauth] Discovering OAuth metadata from ${url}`);
53
+ const res = await fetch(url);
54
+ if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status}`);
55
+ return await res.json();
56
+ }
57
+ // -------------------------------------------------------------------------
58
+ // Step 2: Dynamic client registration
59
+ // -------------------------------------------------------------------------
60
+ async registerClient(registrationEndpoint, redirectUri) {
61
+ this.logger.info("[lobster:oauth] Registering OAuth client dynamically");
62
+ const res = await fetch(registrationEndpoint, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ client_name: "Lobster Shell Plugin",
67
+ redirect_uris: [redirectUri],
68
+ grant_types: ["authorization_code", "refresh_token"],
69
+ response_types: ["code"],
70
+ token_endpoint_auth_method: "none"
71
+ // public client (PKCE)
72
+ })
73
+ });
74
+ if (!res.ok) {
75
+ const body = await res.text();
76
+ throw new Error(`Client registration failed: ${res.status} ${body}`);
77
+ }
78
+ return await res.json();
79
+ }
80
+ // -------------------------------------------------------------------------
81
+ // Initiate flow (returns URL to redirect browser to)
82
+ // -------------------------------------------------------------------------
83
+ async initiateFlow(redirectUri) {
84
+ const metadata = await this.discover();
85
+ if (!metadata.registration_endpoint) {
86
+ throw new Error("OAuth server does not support dynamic client registration");
87
+ }
88
+ const { client_id } = await this.registerClient(metadata.registration_endpoint, redirectUri);
89
+ const codeVerifier = generateCodeVerifier();
90
+ const codeChallenge = generateCodeChallenge(codeVerifier);
91
+ const state = randomBytes(16).toString("hex");
92
+ await this.savePending({
93
+ state,
94
+ codeVerifier,
95
+ clientId: client_id,
96
+ tokenEndpoint: metadata.token_endpoint,
97
+ redirectUri
98
+ });
99
+ const authUrl = new URL(metadata.authorization_endpoint);
100
+ authUrl.searchParams.set("response_type", "code");
101
+ authUrl.searchParams.set("client_id", client_id);
102
+ authUrl.searchParams.set("redirect_uri", redirectUri);
103
+ authUrl.searchParams.set("state", state);
104
+ authUrl.searchParams.set("code_challenge", codeChallenge);
105
+ authUrl.searchParams.set("code_challenge_method", "S256");
106
+ authUrl.searchParams.set("scope", "profile");
107
+ this.logger.info("[lobster:oauth] Authorization URL generated");
108
+ return authUrl.toString();
109
+ }
110
+ // -------------------------------------------------------------------------
111
+ // Handle callback (exchange code for tokens, register gateway)
112
+ // -------------------------------------------------------------------------
113
+ async handleCallback(query, gatewayInfo) {
114
+ const { code, state } = query;
115
+ if (!code || !state) throw new Error("Missing code or state parameter");
116
+ const pending = await this.loadPending();
117
+ if (!pending || pending.state !== state) {
118
+ throw new Error("Invalid state parameter \u2014 possible CSRF");
119
+ }
120
+ this.logger.info("[lobster:oauth] Exchanging authorization code for tokens");
121
+ const tokenRes = await fetch(pending.tokenEndpoint, {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
124
+ body: new URLSearchParams({
125
+ grant_type: "authorization_code",
126
+ code,
127
+ redirect_uri: pending.redirectUri,
128
+ client_id: pending.clientId,
129
+ code_verifier: pending.codeVerifier
130
+ })
131
+ });
132
+ if (!tokenRes.ok) {
133
+ const body = await tokenRes.text();
134
+ throw new Error(`Token exchange failed: ${tokenRes.status} ${body}`);
135
+ }
136
+ const tokens = await tokenRes.json();
137
+ this.logger.info("[lobster:oauth] Registering gateway with Spike cloud");
138
+ const registerRes = await fetch(`${this.cloudUrl}/api/gateways/register`, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/json",
142
+ Authorization: `Bearer ${tokens.access_token}`
143
+ },
144
+ body: JSON.stringify({ gatewayInfo })
145
+ });
146
+ if (!registerRes.ok) {
147
+ const body = await registerRes.text();
148
+ throw new Error(`Gateway registration failed: ${registerRes.status} ${body}`);
149
+ }
150
+ const registration = await registerRes.json();
151
+ const authState = {
152
+ clientId: pending.clientId,
153
+ accessToken: tokens.access_token,
154
+ refreshToken: tokens.refresh_token,
155
+ expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1e3,
156
+ gatewayPublicId: registration.gatewayPublicId,
157
+ channelName: registration.channelName,
158
+ tenantToken: registration.tenantToken,
159
+ tenantTokenExpiresAt: registration.tenantTokenExpiresAt
160
+ };
161
+ await this.saveAuth(authState);
162
+ this.logger.info(
163
+ `[lobster:oauth] OAuth flow completed, gateway registered: ${registration.gatewayPublicId}`
164
+ );
165
+ return authState;
166
+ }
167
+ // -------------------------------------------------------------------------
168
+ // Refresh token
169
+ // -------------------------------------------------------------------------
170
+ async refreshAccessToken() {
171
+ const auth = await this.loadAuth();
172
+ if (!auth?.refreshToken) return null;
173
+ const metadata = await this.discover();
174
+ this.logger.info("[lobster:oauth] Refreshing access token");
175
+ const res = await fetch(metadata.token_endpoint, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
178
+ body: new URLSearchParams({
179
+ grant_type: "refresh_token",
180
+ refresh_token: auth.refreshToken,
181
+ client_id: auth.clientId
182
+ })
183
+ });
184
+ if (!res.ok) {
185
+ this.logger.warn(`[lobster:oauth] Token refresh failed: ${res.status}`);
186
+ return null;
187
+ }
188
+ const tokens = await res.json();
189
+ const updated = {
190
+ ...auth,
191
+ accessToken: tokens.access_token,
192
+ refreshToken: tokens.refresh_token ?? auth.refreshToken,
193
+ expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1e3
194
+ };
195
+ await this.saveAuth(updated);
196
+ return updated;
197
+ }
198
+ };
199
+
200
+ export {
201
+ OAuthFlow
202
+ };
@@ -0,0 +1,191 @@
1
+ import { IncomingMessage, ServerResponse } from 'node:http';
2
+
3
+ /**
4
+ * Minimal type declarations for the OpenClaw Plugin SDK.
5
+ * Aligned with openclaw/src/plugins/types.ts — only what we actually use.
6
+ * This avoids a dependency on the full openclaw package.
7
+ */
8
+
9
+ type PluginLogger = {
10
+ debug?: (message: string) => void;
11
+ info: (message: string) => void;
12
+ warn: (message: string) => void;
13
+ error: (message: string) => void;
14
+ };
15
+ type ChannelId = string;
16
+ type ChannelMeta = {
17
+ id: ChannelId;
18
+ label: string;
19
+ selectionLabel: string;
20
+ docsPath: string;
21
+ blurb: string;
22
+ order?: number;
23
+ aliases?: string[];
24
+ detailLabel?: string;
25
+ systemImage?: string;
26
+ };
27
+ type ChannelCapabilities = {
28
+ chatTypes: Array<"direct" | "group" | "thread">;
29
+ media?: boolean;
30
+ polls?: boolean;
31
+ reactions?: boolean;
32
+ edit?: boolean;
33
+ threads?: boolean;
34
+ };
35
+ type ChannelConfigAdapter<ResolvedAccount = unknown> = {
36
+ listAccountIds: (cfg: unknown) => string[];
37
+ resolveAccount: (cfg: unknown, accountId?: string | null) => ResolvedAccount;
38
+ };
39
+ type ChannelOutboundContext = {
40
+ cfg: unknown;
41
+ to: string;
42
+ text: string;
43
+ mediaUrl?: string;
44
+ mediaLocalRoots?: readonly string[];
45
+ replyToId?: string | null;
46
+ threadId?: string | number | null;
47
+ accountId?: string | null;
48
+ silent?: boolean;
49
+ };
50
+ type OutboundDeliveryResult = {
51
+ ok: boolean;
52
+ messageId?: string;
53
+ error?: string;
54
+ };
55
+ type ChannelOutboundAdapter = {
56
+ deliveryMode: "direct" | "gateway" | "hybrid";
57
+ sendText?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
58
+ sendMedia?: (ctx: ChannelOutboundContext) => Promise<OutboundDeliveryResult>;
59
+ };
60
+ type ChannelPlugin = {
61
+ id: ChannelId;
62
+ meta: ChannelMeta;
63
+ capabilities: ChannelCapabilities;
64
+ config: ChannelConfigAdapter;
65
+ outbound?: ChannelOutboundAdapter;
66
+ };
67
+ type PluginServiceContext = {
68
+ config: unknown;
69
+ workspaceDir?: string;
70
+ stateDir: string;
71
+ logger: PluginLogger;
72
+ };
73
+ type PluginService = {
74
+ id: string;
75
+ start: (ctx: PluginServiceContext) => void | Promise<void>;
76
+ stop?: (ctx: PluginServiceContext) => void | Promise<void>;
77
+ };
78
+ type PluginCommandContext = {
79
+ senderId?: string;
80
+ channel: string;
81
+ channelId?: string;
82
+ isAuthorizedSender: boolean;
83
+ args?: string;
84
+ commandBody: string;
85
+ config: unknown;
86
+ from?: string;
87
+ to?: string;
88
+ accountId?: string;
89
+ };
90
+ type PluginCommandResult = {
91
+ text?: string;
92
+ mediaUrl?: string;
93
+ };
94
+ type PluginCommandDefinition = {
95
+ name: string;
96
+ description: string;
97
+ acceptsArgs?: boolean;
98
+ requireAuth?: boolean;
99
+ handler: (ctx: PluginCommandContext) => PluginCommandResult | Promise<PluginCommandResult>;
100
+ };
101
+ type PluginHookMessageContext = {
102
+ channelId: string;
103
+ accountId?: string;
104
+ conversationId?: string;
105
+ };
106
+ type PluginHookMessageSentEvent = {
107
+ to: string;
108
+ content: string;
109
+ success: boolean;
110
+ error?: string;
111
+ };
112
+ type PluginHookMessageReceivedEvent = {
113
+ from: string;
114
+ content: string;
115
+ timestamp?: number;
116
+ metadata?: Record<string, unknown>;
117
+ };
118
+ type PluginHookGatewayStartEvent = {
119
+ port: number;
120
+ };
121
+ type PluginHookGatewayContext = {
122
+ port?: number;
123
+ };
124
+ type PluginHookHandlerMap = {
125
+ message_sent: (event: PluginHookMessageSentEvent, ctx: PluginHookMessageContext) => Promise<void> | void;
126
+ message_received: (event: PluginHookMessageReceivedEvent, ctx: PluginHookMessageContext) => Promise<void> | void;
127
+ gateway_start: (event: PluginHookGatewayStartEvent, ctx: PluginHookGatewayContext) => Promise<void> | void;
128
+ gateway_stop: (event: {
129
+ reason?: string;
130
+ }, ctx: PluginHookGatewayContext) => Promise<void> | void;
131
+ };
132
+ type PluginHookName = keyof PluginHookHandlerMap;
133
+ /**
134
+ * HTTP route handler receives raw Node.js IncomingMessage and ServerResponse.
135
+ * Return `true` (or void) if handled, `false` to pass to next route.
136
+ */
137
+ type OpenClawPluginHttpRouteHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean | void> | boolean | void;
138
+ type OpenClawPluginHttpRouteAuth = "gateway" | "plugin";
139
+ type OpenClawPluginHttpRouteMatch = "exact" | "prefix";
140
+ type OpenClawPluginHttpRouteParams = {
141
+ path: string;
142
+ handler: OpenClawPluginHttpRouteHandler;
143
+ /** "gateway" = gateway auth enforced; "plugin" = plugin handles its own auth */
144
+ auth: OpenClawPluginHttpRouteAuth;
145
+ match?: OpenClawPluginHttpRouteMatch;
146
+ replaceExisting?: boolean;
147
+ };
148
+ type OpenClawPluginApi = {
149
+ id: string;
150
+ name: string;
151
+ version?: string;
152
+ description?: string;
153
+ source: string;
154
+ config: unknown;
155
+ pluginConfig?: Record<string, unknown>;
156
+ logger: PluginLogger;
157
+ registerChannel: (plugin: ChannelPlugin) => void;
158
+ registerService: (service: PluginService) => void;
159
+ registerCommand: (command: PluginCommandDefinition) => void;
160
+ registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void;
161
+ registerHook: (events: string | string[], handler: (...args: unknown[]) => unknown, opts?: {
162
+ name?: string;
163
+ description?: string;
164
+ }) => void;
165
+ on: <K extends PluginHookName>(hookName: K, handler: PluginHookHandlerMap[K], opts?: {
166
+ priority?: number;
167
+ }) => void;
168
+ };
169
+ type OpenClawPluginDefinition = {
170
+ id?: string;
171
+ name?: string;
172
+ description?: string;
173
+ version?: string;
174
+ register?: (api: OpenClawPluginApi) => void | Promise<void>;
175
+ };
176
+ type OpenClawPluginModule = OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise<void>);
177
+
178
+ /**
179
+ * @spikelabs/lobster-shell-plugin — Connect your OpenClaw agent to Lobster Shell cloud.
180
+ *
181
+ * This plugin registers:
182
+ * - A "lobster-shell" channel so the agent can send responses to the avatar renderer
183
+ * - Hooks on message_sent / message_received to relay events to the cloud bridge
184
+ * - A background service that manages the Ably bridge to Lobster Shell
185
+ * - HTTP routes for OAuth setup flow
186
+ * - A /lobster-status command for quick diagnostics
187
+ */
188
+
189
+ declare const plugin: OpenClawPluginModule;
190
+
191
+ export { plugin as default };