conduyt-mcp 4.94.0 → 4.95.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 CHANGED
@@ -239,8 +239,18 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
239
239
  | `conduyt_get_chat_unread` | `GET /chat/unread`: per channel `{ channelId, unread, soundUnread, newestSeq, latestSeq }`; may list an auto channel you have not opened yet (no membership row) |
240
240
  | `conduyt_list_chat_messages` | List a channel's messages, newest first; every message carries `seq` (commit-ordered position in the channel); does not move the read marker; on a 403 "Not a member of this channel" it calls `GET /chat/channels` once and retries once |
241
241
  | `conduyt_mark_chat_read` | Acknowledge read up to one message (`readUpTo` = message UUID; the marker only moves forward, an older or foreign message is a no-op; same one-retry membership bootstrap) |
242
+ | `conduyt_get_chat_realtime_token` | `GET /realtime/token`: a signed Ably token request for the realtime pipe (`channelIds` up to 10, sent as repeated `channel=`); `{ enabled: false, reason }` when the account switch is off; returns `accountId` / `userId` |
243
+ | `conduyt_get_chat_notifications` | `GET /chat/channels/{id}/notifications`: your level `{ level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }` |
244
+ | `conduyt_set_chat_notifications` | `PATCH /chat/channels/{id}/notifications`: compare-and-set (`expectedUpdatedAt` or null, `expectedMembershipId`, `accountId`, `userId`); a 409 `stale` comes back as an error result carrying the current membership, never retried; 404 `not_a_member` and 403 `identity_mismatch` surface as is |
245
+ | `conduyt_update_team_chat_settings` | `PATCH /settings` `teamChat` (owner/admin only): `realtime`, `realtimePresence`, `quietHours` (`{start,end}` HH:MM or null), `digestMinutes` (0 or 15-240) |
242
246
 
243
- The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapped yet; `GET /chat/messages/{messageId}/location` is a UI deep-link helper and deliberately not wrapped.
247
+ `conduyt_list_chat_messages` also takes `afterSeq` (messages committed after that seq; once, a whole number, never with `cursor`) and every message carries `version` (a counter bumped on each update). `conduyt_get_chat_unread` rows carry `mentions`, `notify`, `updatedAt`, `membershipId` (null = no membership), `membershipSince`, `membershipSeq`, and the response `teamChatRealtime` and `membershipSeqHigh`; `conduyt_list_chat_channels` rows carry `mentionCount` and the same membership fields.
248
+
249
+ The rest of team chat (sending, edit/delete, reactions, pins, claim/handoff, typing, presence, uploads) is not wrapped yet. `GET /chat/messages/{messageId}/location` is a UI deep-link helper and deliberately not wrapped. `GET /cron/team-chat-digest` is a CRON_SECRET-gated job and not key-callable.
250
+
251
+ #### What the API publishes (realtime events, not tools)
252
+
253
+ Team Chat 2.0 publishes thin events (ids and kinds, never message content) on the Ably channels the token above authorizes; this server only fetches the token, it does not subscribe. Chat channel `acct:<account>:chat:<channel>`, event `chat`: `created { channelId, id, seq }`, `updated { channelId, id, what: edited|deleted|reaction|pin|claim|handoff }`, `typing { channelId, userId, name }`. User channel `acct:<account>:user:<user>`, event `user`: `message { channelId, id, seq, dm }`, `read { channelId, seq }`, `chat.membership.notify { channelId, notify, updatedAt, membershipId, membershipSince, membershipSeq }`. Presence channel `acct:<account>:presence` (only with `realtimePresence` on). Everything is behind the account flag `settings.teamChat.realtime`.
244
254
 
245
255
  ### Custom Fields
246
256
  | Tool | Description |
package/dist/client.d.ts CHANGED
@@ -30,6 +30,8 @@ export interface RequestOptions {
30
30
  maxResponseBytes?: number;
31
31
  /** Extra request headers (e.g. Idempotency-Key on POST /tasks); never overrides Authorization / Content-Type. */
32
32
  headers?: Record<string, string>;
33
+ /** Send a GET exactly once: a 429/5xx (or a timeout) is the caller's first refusal, never retried or replaced by a later answer. */
34
+ noRetry?: boolean;
33
35
  }
34
36
  /**
35
37
  * Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
package/dist/client.js CHANGED
@@ -341,7 +341,7 @@ export class ConduytClient {
341
341
  async requestRaw(method, path, body, opts) {
342
342
  const timeoutMs = opts?.timeoutMs ?? this.timeoutMs;
343
343
  const maxResponseBytes = opts?.maxResponseBytes ?? this.maxResponseBytes;
344
- const canRetry = method === "GET";
344
+ const canRetry = method === "GET" && !opts?.noRetry;
345
345
  const attempts = canRetry ? MAX_GET_RETRIES + 1 : 1;
346
346
  // TOTAL time budget across every attempt + backoff: 1.5x the per-call
347
347
  // deadline. Without it, 3 stacked attempt timeouts + backoffs could hold
@@ -1,6 +1,25 @@
1
1
  import { z } from "zod";
2
- import { ConduytApiError, formatResult } from "../client.js";
2
+ import { ConduytApiError, formatResult, formatToolError } from "../client.js";
3
+ import { patchGeneralSettings } from "./lifecycle.js";
3
4
  import { strictInput } from "./strict.js";
5
+ /** The error text's details are ONE JSON object and the final token (client.ts error grammar): try each `{` from the left. */
6
+ function errorDetailsOf(message) {
7
+ for (let i = message.indexOf("{"); i !== -1; i = message.indexOf("{", i + 1)) {
8
+ try {
9
+ const v = JSON.parse(message.slice(i));
10
+ if (v && typeof v === "object" && !Array.isArray(v))
11
+ return v;
12
+ }
13
+ catch { /* not the details object: try the next brace */ }
14
+ }
15
+ return null;
16
+ }
17
+ /** the 409 stale answer: the API's body (code, error, current membership) as a structured error result, never retried */
18
+ function formatToolError409(message, details) {
19
+ const base = formatToolError(message);
20
+ return { ...base, content: [{ type: "text", text: JSON.stringify({ ok: false, status: 409, code: details.code ?? "stale", error: typeof details.error === "string" ? details.error : "stale", current: details.current ?? null, message }, null, 2) }] };
21
+ }
22
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
4
23
  /** The messages route's membership refusal (403 "Not a member of this channel"); any other 403 (a permission) is NOT this. */
5
24
  export function isMembershipRefusal(err) {
6
25
  return err instanceof Error && /\b403\b/.test(err.message) && /Not a member of this channel/.test(err.message)
@@ -24,24 +43,29 @@ async function withMembershipBootstrap(client, read) {
24
43
  }
25
44
  /** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
26
45
  export function registerChatTools(server, client) {
27
- strictInput(server.tool("conduyt_list_chat_channels", "List the team chat channels the API key's user belongs to (GET /api/v1/chat/channels): id, name, kind, members, last message, counts. SIDE EFFECT: it also creates the caller's memberships in the account's AUTO channels (needs-a-hand, new-leads, wins) the first time, so a user who never opened Team Chat can read them. conduyt_list_chat_messages and conduyt_mark_chat_read already do this once for you when a channel listed by conduyt_get_chat_unread answers 403 'Not a member of this channel'; call this tool directly to discover channel ids.", {}, async () => {
46
+ strictInput(server.tool("conduyt_list_chat_channels", "List the team chat channels the API key's user belongs to (GET /api/v1/chat/channels): id, name, kind, members, last message, counts. Each row also carries the Team Chat 2.0 fields mentionCount (unread @mentions of you), notify (your stored notification level: all, mentions, mute, or null = the kind's default), updatedAt (when notify last changed, null = never), membershipId (your member row; changes after a removal and re-add), membershipSince (when this membership began) and membershipSeq (strictly increasing incarnation number). SIDE EFFECT: it also creates the caller's memberships in the account's AUTO channels (needs-a-hand, new-leads, wins) the first time, so a user who never opened Team Chat can read them. conduyt_list_chat_messages and conduyt_mark_chat_read already do this once for you when a channel listed by conduyt_get_chat_unread answers 403 'Not a member of this channel'; call this tool directly to discover channel ids.", {}, async () => {
28
47
  const result = await client.get("/api/v1/chat/channels");
29
48
  return formatResult(result);
30
49
  }));
31
- strictInput(server.tool("conduyt_get_chat_unread", "Team chat unread vector for the API key's user (GET /api/v1/chat/unread): one row per channel the user is a member of, { channelId, unread, soundUnread, newestSeq, latestSeq }. unread is the badge count (auto channels are 0 by design); soundUnread counts unread messages in every channel, auto ones included; newestSeq is the seq of the channel's newest message not written by the user (null = none, panic cards left out); latestSeq is the same including panic cards. Read-only: no writes, never creates a membership, so a row can be an AUTO channel the caller has not opened yet (no membership row: reading it first answers 403, which conduyt_list_chat_messages / conduyt_mark_chat_read recover from by calling conduyt_list_chat_channels once and retrying once). Use it to decide which channel to read next, then conduyt_list_chat_messages.", {}, async () => {
50
+ strictInput(server.tool("conduyt_get_chat_unread", "Team chat unread vector for the API key's user (GET /api/v1/chat/unread): one row per channel the user is a member of, { channelId, unread, soundUnread, newestSeq, latestSeq }. unread is the badge count (auto channels are 0 by design); soundUnread counts unread messages in every channel, auto ones included; newestSeq is the seq of the channel's newest message not written by the user (null = none, panic cards left out); latestSeq is the same including panic cards. Team Chat 2.0 (account flag settings.teamChat.realtime) adds per row: mentions (unread @mentions of you), notify (your stored notification level: all, mentions, mute, or null = default), updatedAt (when notify last changed, null = never), membershipId (null = no membership row), membershipSince and membershipSeq; and per response: teamChatRealtime (whether the account's realtime switch is on) and membershipSeqHigh (the highest membershipSeq among your rows). Read-only: no writes, never creates a membership, so a row can be an AUTO channel the caller has not opened yet (no membership row: reading it first answers 403, which conduyt_list_chat_messages / conduyt_mark_chat_read recover from by calling conduyt_list_chat_channels once and retrying once). Use it to decide which channel to read next, then conduyt_list_chat_messages.", {}, async () => {
32
51
  const result = await client.get("/api/v1/chat/unread");
33
52
  return formatResult(result);
34
53
  }));
35
- strictInput(server.tool("conduyt_list_chat_messages", "List messages in a team chat channel (GET /api/v1/chat/channels/{id}/messages), newest first, paged by cursor. The caller must be a channel member (else 403). Every message carries seq, its commit-ordered position inside the channel (a total order: the list and the cursor follow it). Read-only: this tool does not move the read marker. On a 403 'Not a member of this channel' it calls GET /chat/channels once (which creates the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is; acknowledge with conduyt_mark_chat_read.", {
54
+ strictInput(server.tool("conduyt_list_chat_messages", "List messages in a team chat channel (GET /api/v1/chat/channels/{id}/messages), newest first, paged by cursor. The caller must be a channel member (else 403). Every message carries seq, its commit-ordered position inside the channel (a total order: the list and the cursor follow it), and version, a server counter bumped on every update (edit, delete, reaction, pin, claim, handoff) so a client can tell which copy of a message is newer. afterSeq=N is the realtime catch-up read: the messages committed AFTER seq N (give it once, a whole number 0-999999999, never together with cursor: both are refused here before any request); the page is still newest first and hasMore says more follow it (then re-read the latest page). Read-only: this tool does not move the read marker. On a 403 'Not a member of this channel' it calls GET /chat/channels once (which creates the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is; acknowledge with conduyt_mark_chat_read.", {
36
55
  channelId: z.string().uuid().describe("Channel UUID"),
37
56
  limit: z.number().int().min(1).max(100).optional().describe("Messages per page (1-100, default 50)"),
38
57
  cursor: z.string().uuid().optional().describe("nextCursor from the previous page (a message UUID)"),
39
- }, async ({ channelId, limit, cursor }) => {
58
+ afterSeq: z.number().int().min(0).max(999_999_999).optional().describe("Only messages committed after this seq (the realtime catch-up read). Not combinable with cursor"),
59
+ }, async ({ channelId, limit, cursor, afterSeq }) => {
60
+ if (afterSeq !== undefined && cursor !== undefined)
61
+ throw new Error("afterSeq and cursor cannot be combined");
40
62
  const params = new URLSearchParams();
41
63
  if (limit !== undefined)
42
64
  params.set("limit", String(limit));
43
65
  if (cursor !== undefined)
44
66
  params.set("cursor", cursor);
67
+ if (afterSeq !== undefined)
68
+ params.set("afterSeq", String(afterSeq));
45
69
  const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
46
70
  return formatResult(result);
47
71
  }));
@@ -52,4 +76,54 @@ export function registerChatTools(server, client) {
52
76
  const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?limit=1&readUpTo=${readUpTo}`));
53
77
  return formatResult(result);
54
78
  }));
79
+ strictInput(server.tool("conduyt_get_chat_realtime_token", "Team Chat 2.0 realtime token (GET /api/v1/realtime/token): a signed Ably token request for the account's realtime pipe, scoped to your own user channel, the account presence channel (only when settings.teamChat.realtimePresence is on) and every chat channel you are a member of (the 100 most recently active, plus the ones you name). Answers { enabled: true, tokenRequest, accountId, userId, channels, presence, expiresAt } or { enabled: false, reason: 'off' | 'unconfigured' } when the account's realtime switch (settings.teamChat.realtime) is off or the provider is not configured: stay on polling then. accountId and userId are the values conduyt_set_chat_notifications needs. channelIds (optional, up to 10 UUIDs) are sent as repeated channel=<uuid> parameters; a malformed id or more than 10 is refused 400 here or by the API; an inactive or unaccepted member gets 403 'Not an active member of this account'; 429 'Too many token requests' (30 a minute) is surfaced from the FIRST request: this read is sent once and never retried. Read-only; the token is short-lived and never to be logged.", {
80
+ channelIds: z.array(z.string().uuid()).max(10).optional().describe("Channels to include in the capability (each one you are a member of), up to 10"),
81
+ }, async ({ channelIds }) => {
82
+ const params = new URLSearchParams();
83
+ for (const id of channelIds ?? [])
84
+ params.append("channel", id);
85
+ const result = await client.get(`/api/v1/realtime/token?${params}`, { noRetry: true });
86
+ return formatResult(result);
87
+ }));
88
+ strictInput(server.tool("conduyt_get_chat_notifications", "Your notification level for one team chat channel (GET /api/v1/chat/channels/{id}/notifications): { level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }. level is the effective level (all = every message pushes, mentions = only @mentions of you, mute = nothing but a direct @mention); notify is the STORED value (null = the kind's default: a DM is followed, a channel is mentions-only); updatedAt is the version of that value (null = never set) and membershipId the member row; pass both to conduyt_set_chat_notifications. Members only (else 403 'Not a member of this channel', recovered once like the other reads by calling GET /chat/channels and retrying once). Read-only.", { channelId: z.string().uuid().describe("Channel UUID") }, async ({ channelId }) => {
89
+ const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/notifications`));
90
+ return formatResult(result);
91
+ }));
92
+ strictInput(server.tool("conduyt_set_chat_notifications", "Set your notification level for one team chat channel (PATCH /api/v1/chat/channels/{id}/notifications). The write is compare-and-set against what you last READ: read conduyt_get_chat_notifications first and send its updatedAt as expectedUpdatedAt (null = never set) and its membershipId as expectedMembershipId; accountId and userId are the person you mean (from conduyt_get_chat_realtime_token), and a key whose user is someone else is refused 403 identity_mismatch. level: all, mentions, mute, or default (back to the kind's default). Success returns the same fields as the read. A stale write is NOT applied and NOT retried: the tool answers an error result whose body is { ok: false, status: 409, code: 'stale', error, current: { notify, updatedAt, membershipId, membershipSince, membershipSeq } } with the CURRENT membership, so you re-decide from it (a different membershipId means you were removed and re-added). 404 not_a_member (no longer a member) and 403 identity_mismatch / 'Not a member of this channel' are surfaced as is; 400 for a bad level, expectedUpdatedAt or expectedMembershipId. Takes effect only while the account's realtime switch (settings.teamChat.realtime) is on.", {
93
+ channelId: z.string().uuid().describe("Channel UUID"),
94
+ level: z.enum(["all", "mentions", "mute", "default"]).describe("all, mentions, mute, or default (the kind's default)"),
95
+ expectedUpdatedAt: z.string().datetime({ offset: true }).nullable().describe("The updatedAt you last read (ISO date), or null when it was null (never set)"),
96
+ expectedMembershipId: z.string().uuid().describe("The membershipId you last read"),
97
+ accountId: z.string().uuid().describe("Your account id (conduyt_get_chat_realtime_token returns it)"),
98
+ userId: z.string().uuid().describe("Your user id (conduyt_get_chat_realtime_token returns it)"),
99
+ }, async ({ channelId, level, expectedUpdatedAt, expectedMembershipId, accountId, userId }) => {
100
+ try {
101
+ const result = await client.patch(`/api/v1/chat/channels/${channelId}/notifications`, { level, expectedUpdatedAt, expectedMembershipId, accountId: accountId.toLowerCase(), userId: userId.toLowerCase() });
102
+ return formatResult(result);
103
+ }
104
+ catch (err) {
105
+ if (err instanceof ConduytApiError && err.status === 409) {
106
+ const details = errorDetailsOf(err.message) ?? {};
107
+ return formatToolError409(err.message, details);
108
+ }
109
+ throw err;
110
+ }
111
+ }));
112
+ strictInput(server.tool("conduyt_update_team_chat_settings", "Change the account's Team Chat 2.0 settings (PATCH /api/v1/settings, settings.teamChat). OWNER / ADMIN ONLY (else 403 forbidden 'Only an owner or admin can change Team Chat realtime delivery') and settings:edit. Pass only what changes (at least one): realtime (instant delivery of messages, edits, reactions, pins, typing, read receipts and unread signals over the realtime pipe; off = the chat polls; the account default is off, on for the QA tenant), realtimePresence (online/away over the pipe; needs realtime on; default off), quietHours ({ start, end } as HH:MM in the account timezone, end may be earlier than start to cross midnight; null clears; needs realtime on: no pushes and no digests in the window), digestMinutes (the missed-message email digest window: 0 = off, or 15..240, default 30; needs realtime on). The tool sends the settings revision it read; a 409 (another save landed first) comes back as { conflict: true, current } with nothing overwritten. A 422 names the invalid field.", {
113
+ realtime: z.boolean().optional().describe("Team Chat realtime delivery on/off"),
114
+ realtimePresence: z.boolean().optional().describe("Online/away presence over the realtime pipe (needs realtime)"),
115
+ quietHours: z.object({
116
+ start: z.string().regex(HHMM).describe("HH:MM in the account timezone"),
117
+ end: z.string().regex(HHMM).describe("HH:MM in the account timezone (may be earlier than start)"),
118
+ }).strict().nullable().optional().describe("Quiet-hours window, or null for none"),
119
+ digestMinutes: z.number().int().refine((n) => n === 0 || (n >= 15 && n <= 240), { message: "digestMinutes is 0 (off) or 15..240" }).optional().describe("Missed-message digest window in minutes: 0 = off, 15..240"),
120
+ }, async (params) => {
121
+ const teamChat = {};
122
+ for (const k of ["realtime", "realtimePresence", "quietHours", "digestMinutes"])
123
+ if (params[k] !== undefined)
124
+ teamChat[k] = params[k];
125
+ if (Object.keys(teamChat).length === 0)
126
+ throw new Error("nothing to change: pass at least one of realtime, realtimePresence, quietHours, digestMinutes");
127
+ return patchGeneralSettings(client, { teamChat });
128
+ }));
55
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.94.0",
3
+ "version": "4.95.0",
4
4
  "mcpName": "com.conduyt/mcp",
5
5
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-10-17T19:39:12.198Z",
3
- "toolCount": 355,
4
- "totalEndpoints": 1096,
2
+ "generatedAt": "2026-10-20T15:03:32.151Z",
3
+ "toolCount": 359,
4
+ "totalEndpoints": 1102,
5
5
  "counts": {
6
- "covered": 314,
7
- "excluded": 326,
8
- "planned": 456,
6
+ "covered": 317,
7
+ "excluded": 328,
8
+ "planned": 457,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -1310,6 +1310,18 @@
1310
1310
  "status": "planned",
1311
1311
  "reason": "team chat read/post for agents"
1312
1312
  },
1313
+ "GET /api/v1/chat/channels/:id/notifications": {
1314
+ "status": "covered",
1315
+ "tools": [
1316
+ "conduyt_get_chat_notifications"
1317
+ ]
1318
+ },
1319
+ "PATCH /api/v1/chat/channels/:id/notifications": {
1320
+ "status": "covered",
1321
+ "tools": [
1322
+ "conduyt_set_chat_notifications"
1323
+ ]
1324
+ },
1313
1325
  "GET /api/v1/chat/channels/:id/pins": {
1314
1326
  "status": "planned",
1315
1327
  "reason": "team chat read/post for agents"
@@ -1761,6 +1773,10 @@
1761
1773
  "status": "excluded",
1762
1774
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1763
1775
  },
1776
+ "GET /api/v1/cron/process-ad-conversions": {
1777
+ "status": "excluded",
1778
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1779
+ },
1764
1780
  "GET /api/v1/cron/process-auto-dispositions": {
1765
1781
  "status": "excluded",
1766
1782
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -2081,6 +2097,10 @@
2081
2097
  "status": "excluded",
2082
2098
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
2083
2099
  },
2100
+ "GET /api/v1/cron/team-chat-digest": {
2101
+ "status": "excluded",
2102
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
2103
+ },
2084
2104
  "GET /api/v1/cron/trial-lifecycle-emails": {
2085
2105
  "status": "excluded",
2086
2106
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -3343,6 +3363,10 @@
3343
3363
  "conduyt_check_message"
3344
3364
  ]
3345
3365
  },
3366
+ "POST /api/v1/messages/check-links": {
3367
+ "status": "planned",
3368
+ "reason": "message surfaces beyond send tools"
3369
+ },
3346
3370
  "GET /api/v1/messages/sms/:id": {
3347
3371
  "status": "planned",
3348
3372
  "reason": "message surfaces beyond send tools"
@@ -3724,6 +3748,12 @@
3724
3748
  "status": "planned",
3725
3749
  "reason": "quick note surfaces"
3726
3750
  },
3751
+ "GET /api/v1/realtime/token": {
3752
+ "status": "covered",
3753
+ "tools": [
3754
+ "conduyt_get_chat_realtime_token"
3755
+ ]
3756
+ },
3727
3757
  "POST /api/v1/remote-assist/end": {
3728
3758
  "status": "excluded",
3729
3759
  "reason": "realtime browser RTC surface"