From f1a7f254d1a69ed45cddda6353ce827c999b5617 Mon Sep 17 00:00:00 2001
From: Pranav Katariya
` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
@@ -117,7 +117,7 @@ Run `moltbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.molt.bot/gateway)** — single control plane for sessions, channels, tools, and events.
-- **[Multi-channel inbox](https://docs.molt.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
+- **[Multi-channel inbox](https://docs.molt.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, Zoom, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.molt.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.molt.bot/nodes/voicewake) + [Talk Mode](https://docs.molt.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.molt.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.molt.bot/platforms/mac/canvas#canvas-a2ui).
@@ -139,7 +139,7 @@ Run `moltbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.molt.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.molt.bot/nodes/audio).
### Channels
-- [Channels](https://docs.molt.bot/channels): [WhatsApp](https://docs.molt.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.molt.bot/channels/telegram) (grammY), [Slack](https://docs.molt.bot/channels/slack) (Bolt), [Discord](https://docs.molt.bot/channels/discord) (discord.js), [Google Chat](https://docs.molt.bot/channels/googlechat) (Chat API), [Signal](https://docs.molt.bot/channels/signal) (signal-cli), [iMessage](https://docs.molt.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.molt.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.molt.bot/channels/msteams) (extension), [Matrix](https://docs.molt.bot/channels/matrix) (extension), [Zalo](https://docs.molt.bot/channels/zalo) (extension), [Zalo Personal](https://docs.molt.bot/channels/zalouser) (extension), [WebChat](https://docs.molt.bot/web/webchat).
+- [Channels](https://docs.molt.bot/channels): [WhatsApp](https://docs.molt.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.molt.bot/channels/telegram) (grammY), [Slack](https://docs.molt.bot/channels/slack) (Bolt), [Discord](https://docs.molt.bot/channels/discord) (discord.js), [Google Chat](https://docs.molt.bot/channels/googlechat) (Chat API), [Signal](https://docs.molt.bot/channels/signal) (signal-cli), [iMessage](https://docs.molt.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.molt.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.molt.bot/channels/msteams) (extension), [Matrix](https://docs.molt.bot/channels/matrix) (extension), [Zalo](https://docs.molt.bot/channels/zalo) (extension), [Zalo Personal](https://docs.molt.bot/channels/zalouser) (extension), [Zoom](https://docs.molt.bot/channels/zoom) (extension), [WebChat](https://docs.molt.bot/web/webchat).
- [Group routing](https://docs.molt.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.molt.bot/channels).
### Apps + nodes
@@ -170,7 +170,7 @@ Run `moltbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
-WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
+WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / Zoom / WebChat
│
▼
┌───────────────────────────────┐
@@ -253,7 +253,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
-Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
+Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/Zoom/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
diff --git a/docs/channels/index.md b/docs/channels/index.md
index 10651e6d1..05dc8859b 100644
--- a/docs/channels/index.md
+++ b/docs/channels/index.md
@@ -29,6 +29,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
+- [Zoom](/channels/zoom) — Zoom Team Chat via Team Chat Bot API (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes
diff --git a/docs/channels/zoom.md b/docs/channels/zoom.md
new file mode 100644
index 000000000..787689678
--- /dev/null
+++ b/docs/channels/zoom.md
@@ -0,0 +1,246 @@
+---
+summary: "Zoom Team Chat plugin support status, capabilities, and configuration"
+read_when:
+ - Working on Zoom Team Chat integration
+ - Setting up Zoom bot webhooks
+---
+# Zoom Team Chat
+
+Status: production-ready plugin for Zoom Team Chat direct messages via Team Chat Bot API and webhooks.
+
+## Quick setup
+
+1) Create a 'General App; in [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)
+2) Enable the Team Chat surface and note your credentials (Client ID, Client Secret, Bot JID, Webhook Secret Token)
+3) Install the plugin: `pnpm install` (the zoom extension is included in the workspace)
+4) Configure webhook URL pointing to your gateway (see Webhook Setup below)
+5) Set credentials in config:
+ ```json5
+ {
+ channels: {
+ zoom: {
+ enabled: true,
+ clientId: "YOUR_CLIENT_ID",
+ clientSecret: "YOUR_CLIENT_SECRET",
+ botJid: "YOUR_BOT_JID@xmppdev.zoom.us",
+ secretToken: "YOUR_SECRET_TOKEN",
+ dm: { policy: "open" }
+ }
+ }
+ }
+ ```
+6) Start the gateway
+7) Message the bot in Zoom Team Chat
+
+## What it is
+
+- A Zoom Team Chat channel plugin that receives messages via webhooks
+- Direct message support (groups not currently supported)
+- Webhook server runs on port 3001 (Gateway Control UI uses 3000)
+- OAuth 2.0 client credentials flow for API authentication
+
+## Setup (detailed)
+
+### 1. Create Zoom Team Chat App
+
+1) Go to [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)
+2) Click "Create" and select "Team Chat App"
+3) Enable the **Bot** feature
+4) Note your credentials from the app settings:
+ - **Client ID**
+ - **Client Secret**
+ - **Bot JID** (e.g., `bot@xmppdev.zoom.us`)
+ - **Secret Token** (for webhook verification)
+
+### 2. Configure Webhooks
+
+The bot receives messages through webhooks. Configure the webhook URL in your Zoom app settings:
+
+**Development (using tunnel):**
+```
+https://YOUR-SUBDOMAIN.frp.zoomappgo.cloud/webhooks/zoom
+```
+
+**Production:**
+```
+https://your-domain.com/webhooks/zoom
+```
+
+Subscribe to these event types:
+- `bot_notification` - Required for receiving messages
+
+### 3. Install App to Your Account
+
+1) In app settings, click "Install"
+2) Authorize the app
+3) The bot will appear in your Zoom Team Chat
+
+### 4. Configure Moltbot
+
+Add to `moltbot.yaml`:
+
+```yaml
+channels:
+ zoom:
+ enabled: true
+ clientId: YOUR_CLIENT_ID
+ clientSecret: YOUR_CLIENT_SECRET
+ botJid: YOUR_BOT_JID@xmppdev.zoom.us
+ secretToken: YOUR_SECRET_TOKEN
+ apiHost: https://zoomdev.us # Use https://api.zoom.us for production
+ oauthHost: https://zoomdev.us # Use https://zoom.us for production
+ dm:
+ policy: open # open | closed | allowlist
+```
+
+### 5. Start Gateway
+
+```bash
+moltbot gateway run
+```
+
+The webhook server will start on port 3001.
+
+### 6. Test
+
+Send a direct message to your bot in Zoom Team Chat. The bot should respond with context-aware replies.
+
+## Configuration Options
+
+### DM Policies
+
+**Open** (default):
+```yaml
+dm:
+ policy: open
+```
+Anyone can message the bot.
+
+**Closed**:
+```yaml
+dm:
+ policy: closed
+```
+Bot rejects all DMs.
+
+**Allowlist**:
+```yaml
+dm:
+ policy: allowlist
+ allowFrom:
+ - user1@xmppdev.zoom.us
+ - user2@xmppdev.zoom.us
+```
+Only specified users can message the bot.
+
+### Development vs Production
+
+**Development (zoomdev.us):**
+```yaml
+channels:
+ zoom:
+ apiHost: https://zoomdev.us
+ oauthHost: https://zoomdev.us
+ botJid: bot@xmppdev.zoom.us
+```
+
+**Production (zoom.us):**
+```yaml
+channels:
+ zoom:
+ apiHost: https://api.zoom.us
+ oauthHost: https://zoom.us
+ botJid: bot@xmpp.zoom.us
+```
+
+## Features
+
+- **Direct Messages**: One-on-one conversations
+- **Conversation History**: Full context retention across messages
+- **Streaming Responses**: Real-time message updates
+- **Tool Execution**: All standard Moltbot tools are available
+- **Session Management**: Persistent sessions per user
+
+## Session Keys
+
+Session keys follow the format:
+```
+zoom:default:user@xmppdev.zoom.us
+```
+
+Sessions are stored at:
+```
+~/.moltbot/agents/{agentId}/sessions/{session-id}.jsonl
+```
+
+## Capabilities
+
+- **Chat types**: Direct messages only (groups not supported)
+- **Reactions**: Not supported
+- **Threads**: Not supported
+- **Media**: Not currently supported
+- **Streaming**: Supported (coalesced with 1500 char minimum, 1000ms idle)
+
+## Troubleshooting
+
+### Port 3000 Conflict
+
+If you see "port 3000 already in use":
+- Gateway Control UI runs on port 3000
+- Zoom webhook server uses port 3001
+- This is expected and correct
+
+### Webhook Not Receiving Messages
+
+1. Check tunnel is running (if using FRP or similar)
+2. Verify webhook URL in Zoom app settings matches your tunnel/domain
+3. Check gateway logs for errors
+4. Verify `secretToken` matches your app configuration
+
+### Bot Not Responding
+
+1. Verify credentials in `moltbot.yaml` are correct
+2. Check Anthropic API key is valid
+3. Ensure `botJid` matches your app's Bot JID exactly
+4. Confirm app is installed to your Zoom account
+5. Check gateway logs for authentication errors
+
+### OAuth Issues
+
+If you see authentication errors:
+- Verify `clientId` and `clientSecret` are correct
+- Check `oauthHost` matches your environment (dev vs prod)
+- Review gateway logs for OAuth token errors
+
+## Architecture
+
+The Zoom plugin follows Moltbot's standard channel plugin architecture:
+
+- **Extension**: `extensions/zoom/` - Plugin registration and metadata
+- **Core**: `src/zoom/` - Webhook server, message handling, API client
+- **Monitor**: Starts Express server on port 3001, handles webhook events
+- **Message Handler**: Integrates with `dispatchInboundMessage()` for AI routing
+
+For implementation details, see the [plugin source code](https://github.com/moltbot/moltbot/tree/main/extensions/zoom).
+
+## Security
+
+**Never commit credentials!**
+
+Store sensitive values in `moltbot.yaml` (gitignored) and use `moltbot-example.yaml` for documentation templates.
+
+Webhook events are verified using the `secretToken` from your Zoom app configuration.
+
+## Limitations
+
+- Groups are not currently supported
+- Media uploads not yet implemented
+- Reactions not supported
+- Thread support not available
+
+## See Also
+
+- [Channels Overview](/channels)
+- [Gateway Configuration](/gateway/configuration)
+- [Security Policies](/gateway/security)
+- [Zoom App Marketplace](https://marketplace.zoom.us/)
diff --git a/docs/docs.json b/docs/docs.json
index a463479aa..eaaae2f84 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -996,6 +996,7 @@
"channels/matrix",
"channels/zalo",
"channels/zalouser",
+ "channels/zoom",
"broadcast-groups",
"channels/troubleshooting",
"channels/location"
diff --git a/extensions/zoom/README.md b/extensions/zoom/README.md
new file mode 100644
index 000000000..1a7186c6d
--- /dev/null
+++ b/extensions/zoom/README.md
@@ -0,0 +1,52 @@
+# @moltbot/zoom
+
+Zoom Team Chat channel plugin for Moltbot (Team Chat Bot API).
+
+## Install (local checkout)
+
+```bash
+moltbot plugins install ./extensions/zoom
+```
+
+## Install (npm)
+
+```bash
+moltbot plugins install @moltbot/zoom
+```
+
+Onboarding: select Zoom and confirm the install prompt to fetch the plugin automatically.
+
+## Config
+
+```json5
+{
+ channels: {
+ zoom: {
+ enabled: true,
+ clientId: "YOUR_CLIENT_ID",
+ clientSecret: "YOUR_CLIENT_SECRET",
+ botJid: "YOUR_BOT_JID@xmppdev.zoom.us",
+ secretToken: "YOUR_SECRET_TOKEN",
+ apiHost: "https://zoomdev.us", // Use https://api.zoom.us for production
+ oauthHost: "https://zoomdev.us", // Use https://zoom.us for production
+ dm: {
+ policy: "open" // open | closed | allowlist
+ }
+ }
+ }
+}
+```
+
+## Setup
+
+1. Create a Team Chat App in [Zoom App Marketplace](https://marketplace.zoom.us/develop/create)
+2. Enable the **Bot** feature
+3. Configure webhook URL: `https://your-domain.com/webhooks/zoom`
+4. Subscribe to `bot_notification` event
+5. Install app to your Zoom account
+6. Add credentials to config (above)
+7. Restart the gateway
+
+The webhook server runs on port 3001 (Gateway Control UI uses 3000).
+
+Full documentation: [https://docs.molt.bot/channels/zoom](https://docs.molt.bot/channels/zoom)
\ No newline at end of file
diff --git a/extensions/zoom/clawdbot.plugin.json b/extensions/zoom/clawdbot.plugin.json
new file mode 100644
index 000000000..47c92ea11
--- /dev/null
+++ b/extensions/zoom/clawdbot.plugin.json
@@ -0,0 +1,11 @@
+{
+ "id": "zoom",
+ "channels": [
+ "zoom"
+ ],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/extensions/zoom/index.ts b/extensions/zoom/index.ts
new file mode 100644
index 000000000..edc34883d
--- /dev/null
+++ b/extensions/zoom/index.ts
@@ -0,0 +1,18 @@
+import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
+import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
+
+import { zoomPlugin } from "./src/channel.js";
+import { setZoomRuntime } from "./src/runtime.js";
+
+const plugin = {
+ id: "zoom",
+ name: "Zoom Team Chat",
+ description: "Zoom Team Chat channel plugin",
+ configSchema: emptyPluginConfigSchema(),
+ register(api: MoltbotPluginApi) {
+ setZoomRuntime(api.runtime);
+ api.registerChannel({ plugin: zoomPlugin });
+ },
+};
+
+export default plugin;
diff --git a/extensions/zoom/package.json b/extensions/zoom/package.json
new file mode 100644
index 000000000..0b124b019
--- /dev/null
+++ b/extensions/zoom/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@moltbot/zoom",
+ "version": "2026.1.27",
+ "type": "module",
+ "description": "Moltbot Zoom Team Chat channel plugin",
+ "moltbot": {
+ "extensions": [
+ "./index.ts"
+ ],
+ "channel": {
+ "id": "zoom",
+ "label": "Zoom",
+ "selectionLabel": "Zoom Team Chat",
+ "docsPath": "/channels/zoom",
+ "docsLabel": "zoom",
+ "blurb": "Zoom Team Chat via Team Chat Bot API.",
+ "order": 45,
+ "quickstartAllowFrom": true
+ },
+ "install": {
+ "npmSpec": "@moltbot/zoom",
+ "localPath": "extensions/zoom",
+ "defaultChoice": "local"
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/zoom/src/channel.ts b/extensions/zoom/src/channel.ts
new file mode 100644
index 000000000..6c835336b
--- /dev/null
+++ b/extensions/zoom/src/channel.ts
@@ -0,0 +1,109 @@
+import {
+ buildChannelConfigSchema,
+ DEFAULT_ACCOUNT_ID,
+ getChatChannelMeta,
+ type ChannelPlugin,
+ type ResolvedZoomAccount,
+ resolveZoomAccount,
+ ZoomConfigSchema,
+ monitorZoomProvider,
+ zoomOnboardingAdapter,
+} from "clawdbot/plugin-sdk";
+
+const meta = getChatChannelMeta("zoom");
+
+export const zoomPlugin: ChannelPlugin = {
+ id: "zoom",
+ meta: {
+ ...meta,
+ },
+ onboarding: zoomOnboardingAdapter,
+ capabilities: {
+ chatTypes: ["direct"],
+ reactions: false,
+ threads: false,
+ media: false,
+ nativeCommands: false,
+ },
+ streaming: {
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
+ },
+ reload: { configPrefixes: ["channels.zoom"] },
+ configSchema: buildChannelConfigSchema(ZoomConfigSchema),
+ config: {
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
+ resolveAccount: (cfg, accountId) => resolveZoomAccount({ cfg, accountId }),
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
+ setAccountEnabled: ({ cfg, enabled }) => {
+ if (!cfg.channels) cfg.channels = {};
+ if (!(cfg.channels as any).zoom) (cfg.channels as any).zoom = {};
+ (cfg.channels as any).zoom.enabled = enabled;
+ return cfg;
+ },
+ deleteAccount: ({ cfg }) => {
+ if ((cfg.channels as any)?.zoom) delete (cfg.channels as any).zoom;
+ return cfg;
+ },
+ isConfigured: (account) =>
+ Boolean(
+ account.clientId?.trim() &&
+ account.clientSecret?.trim() &&
+ account.botJid?.trim() &&
+ account.secretToken?.trim()
+ ),
+ describeAccount: (account) => ({
+ accountId: account.accountId,
+ name: "Zoom Team Chat",
+ enabled: account.enabled,
+ configured: Boolean(
+ account.clientId?.trim() &&
+ account.clientSecret?.trim() &&
+ account.botJid?.trim() &&
+ account.secretToken?.trim()
+ ),
+ }),
+ resolveAllowFrom: ({ cfg, accountId }) => {
+ const account = resolveZoomAccount({ cfg, accountId });
+ return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry));
+ },
+ formatAllowFrom: ({ allowFrom }) =>
+ allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
+ },
+ security: {
+ resolveDmPolicy: ({ account }) => ({
+ policy: account.config.dm?.policy ?? "open",
+ allowFrom: account.config.dm?.allowFrom ?? [],
+ allowFromPath: "channels.zoom.dm.",
+ approveHint: "Send a message to the bot to get started",
+ normalizeEntry: (raw) => raw.trim(),
+ }),
+ collectWarnings: () => [],
+ },
+ messaging: {
+ normalizeTarget: (target) => target.trim(),
+ targetResolver: {
+ looksLikeId: (input) => input.includes("@xmpp") || input.includes("@xmppdev"),
+ hint: "",
+ },
+ },
+ directory: {
+ self: async () => null,
+ listPeers: async () => [],
+ listGroups: async () => [],
+ },
+ gateway: {
+ startAccount: async (ctx) => {
+ const account = ctx.account;
+ ctx.log?.info(`[${account.accountId}] starting Zoom provider`);
+
+ // Call monitor directly (imported from SDK)
+ return monitorZoomProvider({
+ accountId: account.accountId,
+ config: ctx.cfg,
+ runtime: ctx.runtime,
+ abortSignal: ctx.abortSignal,
+ port: 3001, // Use 3001 to avoid conflict with gateway Control UI on 3000
+ });
+ },
+ },
+};
diff --git a/extensions/zoom/src/runtime.ts b/extensions/zoom/src/runtime.ts
new file mode 100644
index 000000000..9f2b10509
--- /dev/null
+++ b/extensions/zoom/src/runtime.ts
@@ -0,0 +1,2 @@
+// Re-export from src/zoom for plugin registration
+export { setZoomRuntime, getZoomRuntime } from "../../../src/zoom/runtime.js";
diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit
index c2fe5149b..335739020 100755
--- a/git-hooks/pre-commit
+++ b/git-hooks/pre-commit
@@ -1,4 +1,12 @@
#!/bin/sh
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
[ -z "$ROOT" ] && exit 0
+
+# Use Node 22 via nvm if available
+export NVM_DIR="$HOME/.nvm"
+if [ -s "$NVM_DIR/nvm.sh" ]; then
+ . "$NVM_DIR/nvm.sh"
+ nvm use 22 > /dev/null 2>&1 || true
+fi
+
node "$ROOT/scripts/format-staged.js"
diff --git a/scripts/format-staged.js b/scripts/format-staged.js
index 0ad2d7dd7..8f9d8b659 100644
--- a/scripts/format-staged.js
+++ b/scripts/format-staged.js
@@ -58,14 +58,27 @@ function filterOutPartialTargets(targets, partialTargets) {
}
function resolveOxfmtCommand(repoRoot) {
+ // Run oxfmt via node to avoid Node.js ESM issues with extensionless binaries
+ const cliPath = path.join(repoRoot, "node_modules", "oxfmt", "dist", "cli.js");
+ if (fs.existsSync(cliPath)) {
+ return { command: "node", args: [cliPath] };
+ }
+
+ // Fallback to pnpm
+ const pnpmResult = spawnSync("pnpm", ["--version"], { stdio: "ignore", cwd: repoRoot });
+ if (pnpmResult.status === 0) {
+ return { command: "pnpm", args: ["oxfmt"] };
+ }
+
+ // Fallback to direct oxfmt if available
const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt";
const local = path.join(repoRoot, "node_modules", ".bin", binName);
if (fs.existsSync(local)) {
return { command: local, args: [] };
}
- const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" });
- if (result.status === 0) {
+ const globalResult = spawnSync("oxfmt", ["--version"], { stdio: "ignore" });
+ if (globalResult.status === 0) {
return { command: "oxfmt", args: [] };
}
@@ -79,7 +92,11 @@ function getGitPaths(args, repoRoot) {
}
function formatFiles(repoRoot, oxfmt, files) {
- const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], {
+ const args =
+ oxfmt.command === "pnpm" ? [...oxfmt.args, "--write", ...files]
+ : oxfmt.command === "node" ? [...oxfmt.args, "--write", ...files]
+ : ["--write", ...oxfmt.args, ...files];
+ const result = spawnSync(oxfmt.command, args, {
cwd: repoRoot,
stdio: "inherit",
});
diff --git a/src/channels/plugins/onboarding/zoom.ts b/src/channels/plugins/onboarding/zoom.ts
new file mode 100644
index 000000000..a4965e973
--- /dev/null
+++ b/src/channels/plugins/onboarding/zoom.ts
@@ -0,0 +1,147 @@
+import type { MoltbotConfig } from "../../../config/config.js";
+import type { DmPolicy } from "../../../config/types.js";
+import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
+import { resolveZoomAccount, type ZoomConfig } from "../../../zoom/config.js";
+import { formatDocsLink } from "../../../terminal/links.js";
+import type { WizardPrompter } from "../../../wizard/prompts.js";
+import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
+import { addWildcardAllowFrom } from "./helpers.js";
+
+const channel = "zoom" as const;
+
+function setZoomDmPolicy(cfg: MoltbotConfig, dmPolicy: DmPolicy) {
+ const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined;
+ const allowFrom =
+ dmPolicy === "open" ? addWildcardAllowFrom(zoomConfig?.dm?.allowFrom) : undefined;
+ return {
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ zoom: {
+ ...zoomConfig,
+ dm: {
+ ...zoomConfig?.dm,
+ policy: dmPolicy,
+ ...(allowFrom ? { allowFrom } : {}),
+ },
+ },
+ },
+ };
+}
+
+async function noteZoomHelp(prompter: WizardPrompter): Promise {
+ await prompter.note(
+ [
+ "1) Go to Zoom App Marketplace (marketplace.zoom.us/develop/create)",
+ "2) Create a Team Chat App and enable Bot feature",
+ "3) Copy Client ID, Client Secret, Bot JID, and Secret Token",
+ "4) Configure webhook URL to point to your gateway",
+ `Docs: ${formatDocsLink("/channels/zoom")}`,
+ "Website: https://molt.bot",
+ ].join("\n"),
+ "Zoom Team Chat setup",
+ );
+}
+
+const dmPolicy: ChannelOnboardingDmPolicy = {
+ label: "Zoom",
+ channel,
+ policyKey: "channels.zoom.dm.policy",
+ allowFromKey: "channels.zoom.dm.allowFrom",
+ getCurrent: (cfg) => {
+ const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined;
+ return zoomConfig?.dm?.policy ?? "pairing";
+ },
+ setPolicy: (cfg, policy) => setZoomDmPolicy(cfg, policy),
+};
+
+export const zoomOnboardingAdapter: ChannelOnboardingAdapter = {
+ channel,
+ getStatus: async ({ cfg }) => {
+ const account = resolveZoomAccount({
+ cfg,
+ accountId: DEFAULT_ACCOUNT_ID,
+ });
+ const configured = Boolean(
+ account.clientId?.trim() &&
+ account.clientSecret?.trim() &&
+ account.botJid?.trim() &&
+ account.secretToken?.trim(),
+ );
+ return {
+ channel,
+ configured,
+ statusLines: [`Zoom: ${configured ? "configured" : "not configured"}`],
+ selectionHint: configured ? "configured" : "not configured",
+ quickstartScore: configured ? 2 : 3,
+ };
+ },
+ configure: async ({ cfg, prompter }) => {
+ const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined;
+ let next = cfg;
+
+ await noteZoomHelp(prompter);
+
+ const clientId = await prompter.text({
+ message: "Zoom Client ID",
+ placeholder: "lfcOyplW...",
+ validate: (value) => (value?.trim() ? undefined : "Client ID is required"),
+ });
+
+ const clientSecret = await prompter.text({
+ message: "Zoom Client Secret",
+ placeholder: "rm48DW7m...",
+ validate: (value) => (value?.trim() ? undefined : "Client Secret is required"),
+ });
+
+ const botJid = await prompter.text({
+ message: "Zoom Bot JID",
+ placeholder: "bot@xmpp.zoom.us",
+ validate: (value) =>
+ value?.includes("@xmpp")
+ ? undefined
+ : "Bot JID should contain @xmpp.zoom.us or @xmppdev.zoom.us",
+ });
+
+ const secretToken = await prompter.text({
+ message: "Zoom Secret Token",
+ placeholder: "kVJhHKxo...",
+ validate: (value) => (value?.trim() ? undefined : "Secret Token is required"),
+ });
+
+ // Determine environment from Bot JID
+ const isDev = botJid.includes("@xmppdev");
+ const apiHost = isDev ? "https://zoomdev.us" : "https://api.zoom.us";
+ const oauthHost = isDev ? "https://zoomdev.us" : "https://zoom.us";
+
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ zoom: {
+ ...zoomConfig,
+ enabled: true,
+ clientId,
+ clientSecret,
+ botJid,
+ secretToken,
+ apiHost,
+ oauthHost,
+ },
+ },
+ };
+
+ return { cfg: next };
+ },
+ dmPolicy,
+ disable: (cfg) => {
+ const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined;
+ return {
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ zoom: { ...zoomConfig, enabled: false },
+ },
+ };
+ },
+};
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index 0813caefd..b0013df26 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -307,6 +307,12 @@ export {
} from "../channels/plugins/normalize/telegram.js";
export { collectTelegramStatusIssues } from "../channels/plugins/status-issues/telegram.js";
+// Channel: Zoom
+export { resolveZoomAccount, type ResolvedZoomAccount, type ZoomConfig } from "../zoom/config.js";
+export { ZoomConfigSchema } from "../zoom/config.js";
+export { monitorZoomProvider, type MonitorZoomOpts } from "../zoom/monitor.js";
+export { zoomOnboardingAdapter } from "../channels/plugins/onboarding/zoom.js";
+
// Channel: Signal
export {
listSignalAccountIds,
diff --git a/src/zoom/config.ts b/src/zoom/config.ts
new file mode 100644
index 000000000..ebf4ff186
--- /dev/null
+++ b/src/zoom/config.ts
@@ -0,0 +1,53 @@
+import { z } from "zod";
+import type { MoltbotConfig } from "../config/config.js";
+
+export const ZoomConfigSchema = z.object({
+ enabled: z.boolean().optional(),
+ clientId: z.string().optional(),
+ clientSecret: z.string().optional(),
+ botJid: z.string().optional(),
+ secretToken: z.string().optional(),
+ apiHost: z.string().optional(),
+ oauthHost: z.string().optional(),
+ dm: z
+ .object({
+ policy: z.enum(["open", "allowlist", "pairing"]).optional(),
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
+ })
+ .optional(),
+});
+
+export type ZoomConfig = z.infer;
+
+export type ResolvedZoomAccount = {
+ accountId: string;
+ enabled: boolean;
+ clientId?: string;
+ clientSecret?: string;
+ botJid?: string;
+ secretToken?: string;
+ apiHost: string;
+ oauthHost: string;
+ config: ZoomConfig;
+};
+
+export function resolveZoomAccount(params: {
+ cfg: MoltbotConfig;
+ accountId?: string | null;
+}): ResolvedZoomAccount {
+ const { cfg } = params;
+ const zoomConfig = (cfg.channels as any)?.zoom as ZoomConfig | undefined;
+ const config = zoomConfig ?? {};
+
+ return {
+ accountId: "default",
+ enabled: config.enabled ?? true,
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ botJid: config.botJid,
+ secretToken: config.secretToken,
+ apiHost: config.apiHost || "https://zoom.us",
+ oauthHost: config.oauthHost || "https://zoom.us",
+ config,
+ };
+}
diff --git a/src/zoom/context.ts b/src/zoom/context.ts
new file mode 100644
index 000000000..2150cf7f6
--- /dev/null
+++ b/src/zoom/context.ts
@@ -0,0 +1,26 @@
+import type { HistoryEntry } from "../auto-reply/reply/history.js";
+import type { MoltbotConfig } from "../config/config.js";
+import type { SessionScope } from "../config/sessions.js";
+import type { DmPolicy } from "../config/types.js";
+import type { RuntimeEnv } from "../runtime.js";
+import type { ResolvedZoomAccount } from "./config.js";
+
+export type ZoomMonitorContext = {
+ cfg: MoltbotConfig;
+ accountId: string;
+ account: ResolvedZoomAccount;
+ runtime: RuntimeEnv;
+
+ historyLimit: number;
+ channelHistories: Map;
+ sessionScope: SessionScope;
+ mainKey: string;
+
+ dmEnabled: boolean;
+ dmPolicy: DmPolicy;
+ allowFrom: string[];
+
+ textLimit: number;
+ replyToMode: "off" | "first" | "all";
+ removeAckAfterReply: boolean;
+};
diff --git a/src/zoom/index.ts b/src/zoom/index.ts
new file mode 100644
index 000000000..1a2952d3c
--- /dev/null
+++ b/src/zoom/index.ts
@@ -0,0 +1,3 @@
+export { resolveZoomAccount, type ResolvedZoomAccount, type ZoomConfig } from "./config.js";
+export { monitorZoomProvider, type MonitorZoomOpts } from "./monitor.js";
+export { setZoomRuntime, getZoomRuntime } from "./runtime.js";
diff --git a/src/zoom/message-handler.ts b/src/zoom/message-handler.ts
new file mode 100644
index 000000000..ce63f1f1b
--- /dev/null
+++ b/src/zoom/message-handler.ts
@@ -0,0 +1,87 @@
+import { dispatchInboundMessage } from "../auto-reply/dispatch.js";
+import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
+import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
+import { resolveAgentRoute } from "../routing/resolve-route.js";
+import type { MsgContext } from "../auto-reply/templating.js";
+import type { ZoomMonitorContext } from "./context.js";
+import { danger } from "../globals.js";
+
+type ZoomMessage = {
+ userJid: string;
+ userName: string;
+ cmd: string;
+ accountId: string;
+ toJid: string;
+ timestamp: number;
+};
+
+export async function handleZoomMessage(params: {
+ ctx: ZoomMonitorContext;
+ message: ZoomMessage;
+ sendReply: (message: string) => Promise;
+}): Promise {
+ const { ctx, message, sendReply } = params;
+ const { cfg, runtime, account } = ctx;
+
+ // Resolve agent route
+ const route = resolveAgentRoute({
+ cfg,
+ channel: "zoom",
+ accountId: account.accountId,
+ });
+
+ // Build message context (like Slack's prepareSlackMessage)
+ const msgCtx: MsgContext = {
+ // Required fields
+ From: message.userJid,
+ SenderName: message.userName,
+ Body: message.cmd,
+
+ // Computed fields
+ SessionKey: route.sessionKey,
+ AccountId: route.accountId,
+
+ // Optional fields
+ RawBody: message.cmd,
+ CommandBody: message.cmd,
+ BodyForAgent: message.cmd,
+ BodyForCommands: message.cmd,
+ ChatType: "direct",
+ Provider: "zoom",
+ Timestamp: message.timestamp,
+ };
+
+ // Finalize context (normalizes fields, adds computed properties)
+ const ctxPayload = finalizeInboundContext(msgCtx);
+
+ // Create reply dispatcher (like Slack's dispatchPreparedSlackMessage)
+ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
+ deliver: async (payload) => {
+ // Deliver reply back to Zoom
+ const text = payload.text || "";
+
+ if (text) {
+ await sendReply(text);
+ }
+ },
+ onError: (err, info) => {
+ runtime.error?.(danger(`zoom ${info.kind} reply failed: ${String(err)}`));
+ },
+ });
+
+ // Dispatch to clawdbot's core system (THE KEY CALL!)
+ const { queuedFinal, counts } = await dispatchInboundMessage({
+ ctx: ctxPayload,
+ cfg,
+ dispatcher,
+ replyOptions,
+ });
+
+ markDispatchIdle();
+
+ const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
+
+ if (anyReplyDelivered) {
+ runtime.log(`zoom: Delivered ${counts.final ?? 0} reply(ies) to ${message.userName}`);
+ }
+}
diff --git a/src/zoom/monitor.ts b/src/zoom/monitor.ts
new file mode 100644
index 000000000..6dcfb757b
--- /dev/null
+++ b/src/zoom/monitor.ts
@@ -0,0 +1,341 @@
+import express from "express";
+import crypto from "crypto";
+import { loadConfig } from "../config/config.js";
+import type { MoltbotConfig } from "../config/config.js";
+import type { RuntimeEnv } from "../runtime.js";
+import type { ResolvedZoomAccount } from "./config.js";
+import { resolveZoomAccount } from "./config.js";
+import { handleZoomMessage } from "./message-handler.js";
+import type { ZoomMonitorContext } from "./context.js";
+import type { SessionScope } from "../config/sessions/types.js";
+
+export type MonitorZoomOpts = {
+ accountId?: string;
+ config?: MoltbotConfig;
+ runtime?: RuntimeEnv;
+ abortSignal?: AbortSignal;
+ port?: number;
+};
+
+type ZoomWebhookPayload = {
+ event: string;
+ payload: {
+ accountId: string;
+ userJid: string;
+ userName: string;
+ robotJid: string;
+ cmd: string;
+ timestamp: number;
+ toJid: string;
+ userId: string;
+ plainToken?: string;
+ };
+};
+
+let cachedBotToken: { token: string; expiresAt: number } | null = null;
+
+function encryptToken(token: string, secretToken: string): string {
+ const hash = crypto.createHmac("sha256", secretToken).update(token).digest("hex");
+ return hash;
+}
+
+async function getBotToken(account: ResolvedZoomAccount): Promise {
+ // Use cached token if valid
+ if (cachedBotToken && cachedBotToken.expiresAt > Date.now()) {
+ return cachedBotToken.token;
+ }
+
+ const { clientId, clientSecret, oauthHost } = account;
+ if (!clientId || !clientSecret) {
+ throw new Error("Zoom clientId and clientSecret required");
+ }
+
+ const authHeader = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
+
+ const response = await fetch(`${oauthHost}/oauth/token?grant_type=client_credentials`, {
+ method: "POST",
+ headers: {
+ Authorization: `Basic ${authHeader}`,
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to get bot token: ${response.status} ${error}`);
+ }
+
+ const data = (await response.json()) as { access_token: string; expires_in: number };
+
+ cachedBotToken = {
+ token: data.access_token,
+ expiresAt: Date.now() + (data.expires_in - 300) * 1000,
+ };
+
+ return data.access_token;
+}
+
+async function sendZoomMessage(params: {
+ account: ResolvedZoomAccount;
+ toJid: string;
+ accountId: string;
+ message: string;
+}): Promise {
+ const { account, toJid, accountId, message } = params;
+ const { botJid, apiHost } = account;
+
+ if (!botJid) {
+ throw new Error("Zoom botJid required");
+ }
+
+ const accessToken = await getBotToken(account);
+
+ const response = await fetch(`${apiHost}/v2/im/chat/messages`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ robot_jid: botJid,
+ to_jid: toJid,
+ account_id: accountId,
+ user_jid: toJid,
+ content: {
+ head: {
+ text: message,
+ },
+ },
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to send message: ${response.status} ${error}`);
+ }
+}
+
+export async function monitorZoomProvider(opts: MonitorZoomOpts = {}): Promise {
+ const { config, runtime, abortSignal, port = 3001 } = opts;
+
+ // Load config and resolve account (matching Slack/Telegram pattern)
+ const cfg = config ?? loadConfig();
+ const account = resolveZoomAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+
+ if (!runtime) {
+ throw new Error("Runtime required for Zoom provider");
+ }
+
+ const { secretToken } = account;
+ if (!secretToken) {
+ throw new Error("Zoom secretToken required for webhook verification");
+ }
+
+ // Build monitor context internally (moved from channel.ts)
+ const sessionScope: SessionScope = cfg.session?.scope ?? "per-sender";
+
+ const ctx: ZoomMonitorContext = {
+ cfg,
+ accountId: account.accountId,
+ account,
+ runtime,
+ historyLimit: 10,
+ channelHistories: new Map(),
+ sessionScope,
+ mainKey: "", // Not used for Zoom (sessions are per-user)
+ dmEnabled: account.enabled,
+ dmPolicy: account.config.dm?.policy ?? "open",
+ allowFrom: (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)),
+ textLimit: 4000,
+ replyToMode: "off",
+ removeAckAfterReply: false,
+ };
+
+ const app = express();
+ app.use(express.json());
+
+ // Health check
+ app.get("/health", (req, res) => {
+ res.json({ status: "ok", service: "zoom-webhook" });
+ });
+
+ // OAuth callback endpoint (for app installation)
+ app.get("/api/zoomapp/auth", async (req, res) => {
+ const code = req.query.code as string;
+
+ if (!code) {
+ runtime.log("zoom: OAuth callback missing code parameter");
+ return res.status(400).send("Missing authorization code");
+ }
+
+ runtime.log(`zoom: OAuth callback received, exchanging code for tokens...`);
+
+ try {
+ // Exchange authorization code for access token
+ const tokenUrl = `${account.oauthHost}/oauth/token`;
+ const tokenParams = new URLSearchParams();
+ tokenParams.set("grant_type", "authorization_code");
+ tokenParams.set("redirect_uri", `${req.protocol}://${req.get("host")}/api/zoomapp/auth`);
+ tokenParams.set("code", code);
+
+ const authHeader = Buffer.from(`${account.clientId}:${account.clientSecret}`).toString(
+ "base64",
+ );
+
+ const tokenResponse = await fetch(tokenUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Authorization: `Basic ${authHeader}`,
+ },
+ body: tokenParams.toString(),
+ });
+
+ if (!tokenResponse.ok) {
+ const error = await tokenResponse.text();
+ runtime.error?.(`zoom: Token exchange failed: ${tokenResponse.status} ${error}`);
+ return res.status(500).send("Failed to complete installation");
+ }
+
+ const tokenData = (await tokenResponse.json()) as { access_token: string };
+ runtime.log("zoom: Successfully exchanged code for access token");
+
+ // Get deep link to redirect user back to Zoom app
+ const deepLinkResponse = await fetch(`${account.apiHost}/v2/zoomapp/deeplink`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${tokenData.access_token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ action: "go" }),
+ });
+
+ if (deepLinkResponse.ok) {
+ const deepLinkData = (await deepLinkResponse.json()) as { deeplink: string };
+ runtime.log("zoom: Redirecting to Zoom app");
+ return res.redirect(deepLinkData.deeplink);
+ }
+
+ // Fallback: show success page if deep link fails
+ runtime.log("zoom: Deep link failed, showing success page");
+ res.send(`
+
+
+
+ Zoom App Installed
+
+
+
+ ✅
+ Zoom App Installed Successfully!
+ Your bot is now active. You can close this window and start messaging your bot in Zoom Team Chat.
+ Bot Name: Jarvis 🤖
+ Send a message to get started!
+
+
+ `);
+ } catch (error) {
+ runtime.error?.(`zoom: OAuth error: ${error}`);
+ return res.status(500).send("Installation failed");
+ }
+ });
+
+ // Webhook endpoint
+ app.post("/webhooks/zoom", async (req, res) => {
+ try {
+ const body = req.body as ZoomWebhookPayload;
+
+ // Handle URL validation
+ if (body.event === "endpoint.url_validation") {
+ const plainToken = body.payload.plainToken || "";
+ const encryptedToken = encryptToken(plainToken, secretToken);
+
+ runtime.log("zoom: URL validation successful");
+ return res.json({
+ plainToken,
+ encryptedToken,
+ });
+ }
+
+ // Handle bot notification
+ if (body.event === "bot_notification") {
+ const { userJid, userName, cmd, accountId, toJid, timestamp } = body.payload;
+
+ runtime.log(`zoom: Message from ${userName}: ${cmd}`);
+
+ // Use clawdbot's dispatch system (handles AI, history, streaming, etc.)
+ await handleZoomMessage({
+ ctx,
+ message: {
+ userJid,
+ userName,
+ cmd,
+ accountId,
+ toJid,
+ timestamp,
+ },
+ sendReply: async (replyText: string) => {
+ runtime.log(`zoom: Sending response: ${replyText.substring(0, 80)}...`);
+ await sendZoomMessage({
+ account,
+ toJid: userJid,
+ accountId,
+ message: replyText,
+ });
+ runtime.log("zoom: Response sent successfully");
+ },
+ });
+
+ return res.json({ success: true });
+ }
+
+ return res.json({ success: true });
+ } catch (error) {
+ runtime.error?.(`zoom: Webhook error: ${error}`);
+ return res.status(500).json({ error: "Internal server error" });
+ }
+ });
+
+ const server = await new Promise((resolve, reject) => {
+ const srv = app.listen(port, () => {
+ runtime.log(`zoom: Webhook server listening on port ${port}`);
+ runtime.log(`zoom: Webhook URL: http://localhost:${port}/webhooks/zoom`);
+ resolve(srv);
+ });
+ srv.on("error", reject);
+ });
+
+ // Graceful shutdown handler
+ const stopServer = () => {
+ runtime.log("zoom: Shutting down webhook server...");
+ server.close(() => {
+ runtime.log("zoom: Webhook server stopped");
+ });
+ };
+
+ abortSignal?.addEventListener("abort", stopServer, { once: true });
+
+ try {
+ if (abortSignal?.aborted) return;
+ // Wait for abort signal
+ if (abortSignal) {
+ await new Promise((resolve) => {
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
+ });
+ } else {
+ // If no abort signal, wait forever (process will handle SIGINT/SIGTERM)
+ await new Promise(() => {});
+ }
+ } finally {
+ abortSignal?.removeEventListener("abort", stopServer);
+ server.close();
+ }
+}
diff --git a/src/zoom/runtime.ts b/src/zoom/runtime.ts
new file mode 100644
index 000000000..8ab4177d7
--- /dev/null
+++ b/src/zoom/runtime.ts
@@ -0,0 +1,14 @@
+import type { PluginRuntime } from "../plugin-sdk/index.js";
+
+let runtime: PluginRuntime | null = null;
+
+export function setZoomRuntime(next: PluginRuntime) {
+ runtime = next;
+}
+
+export function getZoomRuntime(): PluginRuntime {
+ if (!runtime) {
+ throw new Error("Zoom runtime not initialized");
+ }
+ return runtime;
+}