@agent-native/dispatch 0.13.13 → 0.14.1

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.
Files changed (74) hide show
  1. package/dist/actions/delete-destination.d.ts +12 -12
  2. package/dist/actions/navigate.js +1 -1
  3. package/dist/actions/navigate.js.map +1 -1
  4. package/dist/actions/provider-api-audit.d.ts +7 -0
  5. package/dist/actions/provider-api-audit.d.ts.map +1 -0
  6. package/dist/actions/provider-api-audit.js +74 -0
  7. package/dist/actions/provider-api-audit.js.map +1 -0
  8. package/dist/actions/provider-api-catalog.d.ts +1 -1
  9. package/dist/actions/provider-api-request.js +10 -0
  10. package/dist/actions/provider-api-request.js.map +1 -1
  11. package/dist/actions/send-platform-message.d.ts +1 -0
  12. package/dist/actions/send-platform-message.js +43 -22
  13. package/dist/actions/send-platform-message.js.map +1 -1
  14. package/dist/actions/view-screen.js +9 -0
  15. package/dist/actions/view-screen.js.map +1 -1
  16. package/dist/components/layout/Layout.d.ts.map +1 -1
  17. package/dist/components/layout/Layout.js +75 -70
  18. package/dist/components/layout/Layout.js.map +1 -1
  19. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  20. package/dist/components/messaging-setup-panel.js +203 -127
  21. package/dist/components/messaging-setup-panel.js.map +1 -1
  22. package/dist/routes/index.d.ts.map +1 -1
  23. package/dist/routes/index.js +1 -0
  24. package/dist/routes/index.js.map +1 -1
  25. package/dist/routes/pages/messaging.js +1 -1
  26. package/dist/routes/pages/messaging.js.map +1 -1
  27. package/dist/routes/pages/operations.d.ts +5 -0
  28. package/dist/routes/pages/operations.d.ts.map +1 -0
  29. package/dist/routes/pages/operations.js +51 -0
  30. package/dist/routes/pages/operations.js.map +1 -0
  31. package/dist/routes/pages/overview.d.ts +2 -2
  32. package/dist/server/lib/dispatch-integrations.d.ts +7 -1
  33. package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
  34. package/dist/server/lib/dispatch-integrations.js +178 -8
  35. package/dist/server/lib/dispatch-integrations.js.map +1 -1
  36. package/dist/server/lib/dispatch-routing.d.ts +6 -0
  37. package/dist/server/lib/dispatch-routing.d.ts.map +1 -0
  38. package/dist/server/lib/dispatch-routing.js +30 -0
  39. package/dist/server/lib/dispatch-routing.js.map +1 -0
  40. package/dist/server/lib/env-config.d.ts.map +1 -1
  41. package/dist/server/lib/env-config.js +50 -0
  42. package/dist/server/lib/env-config.js.map +1 -1
  43. package/dist/server/lib/onboarding-steps.d.ts.map +1 -1
  44. package/dist/server/lib/onboarding-steps.js +25 -0
  45. package/dist/server/lib/onboarding-steps.js.map +1 -1
  46. package/dist/server/lib/provider-api.d.ts +2 -2
  47. package/dist/server/lib/provider-api.d.ts.map +1 -1
  48. package/dist/server/plugins/integrations.d.ts.map +1 -1
  49. package/dist/server/plugins/integrations.js +5 -2
  50. package/dist/server/plugins/integrations.js.map +1 -1
  51. package/package.json +2 -2
  52. package/src/actions/navigate.ts +1 -1
  53. package/src/actions/provider-api-audit.spec.ts +52 -0
  54. package/src/actions/provider-api-audit.ts +88 -0
  55. package/src/actions/provider-api-request.ts +10 -0
  56. package/src/actions/send-platform-message.spec.ts +108 -0
  57. package/src/actions/send-platform-message.ts +73 -25
  58. package/src/actions/view-screen.ts +11 -0
  59. package/src/components/layout/Layout.spec.tsx +154 -0
  60. package/src/components/layout/Layout.tsx +196 -189
  61. package/src/components/messaging-setup-panel.spec.tsx +191 -0
  62. package/src/components/messaging-setup-panel.tsx +472 -193
  63. package/src/routes/index.spec.ts +23 -0
  64. package/src/routes/index.ts +1 -0
  65. package/src/routes/pages/messaging.tsx +1 -1
  66. package/src/routes/pages/operations.tsx +134 -0
  67. package/src/server/lib/dispatch-integrations.spec.ts +113 -0
  68. package/src/server/lib/dispatch-integrations.ts +239 -6
  69. package/src/server/lib/dispatch-routing.spec.ts +44 -0
  70. package/src/server/lib/dispatch-routing.ts +42 -0
  71. package/src/server/lib/env-config.ts +50 -0
  72. package/src/server/lib/onboarding-steps.ts +32 -0
  73. package/src/server/plugins/integrations.ts +5 -2
  74. package/src/styles/dispatch-css.spec.ts +11 -0
@@ -1,15 +1,46 @@
1
- import { agentNativePath } from "@agent-native/core/client";
2
1
  import {
2
+ disconnectManagedIntegrationInstallation,
3
+ listManagedIntegrationBudgets,
4
+ listManagedIntegrationInstallations,
5
+ listManagedIntegrationScopes,
6
+ listIntegrationEnvStatuses,
7
+ listIntegrationStatuses,
8
+ managedIntegrationOAuthUrl,
9
+ managedSlackAgentManifestUrl,
10
+ saveIntegrationEnvVars,
11
+ saveManagedIntegrationBudget,
12
+ saveManagedIntegrationScope,
13
+ setIntegrationEnabled,
14
+ setupIntegration,
15
+ testManagedIntegrationInstallation,
16
+ type ClientIntegrationInstallation,
17
+ type ClientIntegrationScope,
18
+ type ClientIntegrationUsageBudget,
19
+ type ClientIntegrationStatus,
20
+ type IntegrationEnvStatus,
21
+ useFormatters,
22
+ useT,
23
+ } from "@agent-native/core/client";
24
+ import {
25
+ listBuiltInChannelIntegrations,
26
+ type IntegrationCatalogEntry,
27
+ type IntegrationCredentialRequirement,
28
+ } from "@agent-native/core/integrations";
29
+ import {
30
+ IconBrandDiscord,
3
31
  IconBrandSlack,
4
32
  IconBrandTelegram,
33
+ IconBrandTeams,
5
34
  IconBrandWhatsapp,
6
35
  IconCheck,
7
36
  IconChevronRight,
8
37
  IconCopy,
9
38
  IconExternalLink,
39
+ IconFileDescription,
10
40
  IconInfoCircle,
11
41
  IconLoader2,
12
42
  IconMail,
43
+ IconPlug,
13
44
  } from "@tabler/icons-react";
14
45
  import { useEffect, useMemo, useState } from "react";
15
46
  import { toast } from "sonner";
@@ -21,124 +52,46 @@ import {
21
52
  CollapsibleTrigger,
22
53
  } from "./ui/collapsible";
23
54
  import { Input } from "./ui/input";
55
+ import { Switch } from "./ui/switch";
24
56
  import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
25
57
 
26
- interface EnvStatus {
27
- key: string;
28
- label: string;
29
- required: boolean;
30
- configured: boolean;
31
- helpText?: string;
32
- }
33
-
34
- interface RequiredEnvKey {
35
- key: string;
36
- label: string;
37
- required: boolean;
38
- helpText?: string;
39
- }
40
-
41
- interface IntegrationStatus {
42
- platform: string;
43
- label: string;
44
- enabled: boolean;
45
- configured: boolean;
46
- webhookUrl?: string;
47
- requiredEnvKeys?: RequiredEnvKey[];
48
- }
58
+ const CHANNELS = listBuiltInChannelIntegrations();
59
+
60
+ const PLATFORM_ICONS: Partial<Record<string, typeof IconBrandSlack>> = {
61
+ slack: IconBrandSlack,
62
+ "microsoft-teams": IconBrandTeams,
63
+ discord: IconBrandDiscord,
64
+ telegram: IconBrandTelegram,
65
+ whatsapp: IconBrandWhatsapp,
66
+ email: IconMail,
67
+ };
68
+
69
+ function hasMissingRequiredCredentials(
70
+ credentials: readonly IntegrationCredentialRequirement[],
71
+ envStatusByKey: Map<string, IntegrationEnvStatus>,
72
+ ) {
73
+ const alternatives = new Map<
74
+ string,
75
+ readonly IntegrationCredentialRequirement[]
76
+ >();
77
+
78
+ for (const credential of credentials) {
79
+ if (!credential.required) continue;
80
+ if (!credential.alternativeGroup) {
81
+ if (!envStatusByKey.get(credential.key)?.configured) return true;
82
+ continue;
83
+ }
84
+ const group = alternatives.get(credential.alternativeGroup) ?? [];
85
+ alternatives.set(credential.alternativeGroup, [...group, credential]);
86
+ }
49
87
 
50
- interface PlatformDefinition {
51
- id: "slack" | "telegram" | "email" | "whatsapp";
52
- label: string;
53
- icon: typeof IconBrandSlack;
54
- description: string;
55
- /** Our own docs anchor — keep these on /docs/messaging so users land on
56
- * the page that explains the platform in plain English. */
57
- docsUrl: string;
58
- /** Optional external link (e.g. to the platform's developer console). */
59
- externalUrl?: string;
60
- externalLabel?: string;
61
- setupSteps: string[];
62
- /** Fallback env keys when the adapter doesn't surface them via
63
- * `IntegrationStatus.requiredEnvKeys`. The panel prefers adapter-supplied
64
- * keys when present so optional fields (webhook secrets, etc.) appear
65
- * automatically. */
66
- envKeys: string[];
88
+ return [...alternatives.values()].some((group) =>
89
+ group.every(
90
+ (credential) => !envStatusByKey.get(credential.key)?.configured,
91
+ ),
92
+ );
67
93
  }
68
94
 
69
- const PLATFORM_DEFINITIONS: PlatformDefinition[] = [
70
- {
71
- id: "slack",
72
- label: "Slack",
73
- icon: IconBrandSlack,
74
- description: "Receive mentions and DMs in one workspace-aware dispatch.",
75
- docsUrl: "/docs/messaging#slack",
76
- externalUrl: "https://api.slack.com/apps",
77
- externalLabel: "Open Slack apps",
78
- envKeys: ["SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET"],
79
- setupSteps: [
80
- "Create or open a Slack app at api.slack.com/apps.",
81
- "Save the bot token and signing secret below — the webhook URL appears once they're saved.",
82
- "Back in Slack, enable Event Subscriptions and paste the webhook URL.",
83
- "Subscribe to app_mention and message.im events, then install the app.",
84
- "Optional but recommended: Basic Information → Display Information → upload an app icon and pick a background color so the bot has a clean avatar in every channel.",
85
- ],
86
- },
87
- {
88
- id: "telegram",
89
- label: "Telegram",
90
- icon: IconBrandTelegram,
91
- description: "Chat with dispatch through a Telegram bot.",
92
- docsUrl: "/docs/messaging#telegram",
93
- externalUrl: "https://t.me/BotFather",
94
- externalLabel: "Open BotFather",
95
- envKeys: ["TELEGRAM_BOT_TOKEN"],
96
- setupSteps: [
97
- "Open @BotFather in Telegram and send /newbot.",
98
- "Save the bot token here, then click Set up webhook below.",
99
- "DM the bot in Telegram to test.",
100
- ],
101
- },
102
- {
103
- id: "email",
104
- label: "Email",
105
- icon: IconMail,
106
- description:
107
- "Give your agent an email address. People can email it directly or CC it on threads.",
108
- docsUrl: "/docs/messaging#email",
109
- externalUrl: "https://resend.com/webhooks",
110
- externalLabel: "Open Resend webhooks",
111
- envKeys: ["EMAIL_AGENT_ADDRESS"],
112
- setupSteps: [
113
- "Save your Resend or SendGrid API key (Vault or onboarding).",
114
- "Pick an email address — the easiest is a free <slug>.resend.app address.",
115
- "If using your own domain, add MX records pointing to your provider.",
116
- "Save the address here, then register the webhook URL below in Resend (event: email.received).",
117
- ],
118
- },
119
- {
120
- id: "whatsapp",
121
- label: "WhatsApp",
122
- icon: IconBrandWhatsapp,
123
- description:
124
- "Receive WhatsApp messages and reply through a Meta-managed phone number.",
125
- docsUrl: "/docs/messaging#whatsapp",
126
- externalUrl: "https://developers.facebook.com/apps",
127
- externalLabel: "Open Meta developer console",
128
- envKeys: [
129
- "WHATSAPP_ACCESS_TOKEN",
130
- "WHATSAPP_VERIFY_TOKEN",
131
- "WHATSAPP_PHONE_NUMBER_ID",
132
- ],
133
- setupSteps: [
134
- "Create a Meta app and add the WhatsApp product.",
135
- "Save the access token, verify token, and phone number ID below.",
136
- "In Meta's WhatsApp configuration, paste the webhook URL and your verify token.",
137
- "Subscribe to the messages field, then enable here.",
138
- ],
139
- },
140
- ];
141
-
142
95
  function HelpTooltip({ content }: { content: string }) {
143
96
  return (
144
97
  <Tooltip>
@@ -211,24 +164,32 @@ function ConnectionStatus({
211
164
  }
212
165
 
213
166
  export function MessagingSetupPanel() {
214
- const [statuses, setStatuses] = useState<IntegrationStatus[]>([]);
167
+ const t = useT();
168
+ const { formatDate } = useFormatters();
169
+ const [statuses, setStatuses] = useState<ClientIntegrationStatus[]>([]);
215
170
  const [loading, setLoading] = useState(true);
216
- const [envStatuses, setEnvStatuses] = useState<EnvStatus[]>([]);
171
+ const [envStatuses, setEnvStatuses] = useState<IntegrationEnvStatus[]>([]);
217
172
  const [envLoading, setEnvLoading] = useState(true);
218
173
  const [envValues, setEnvValues] = useState<Record<string, string>>({});
219
174
  const [savingKeysFor, setSavingKeysFor] = useState<string | null>(null);
220
175
  const [togglingPlatform, setTogglingPlatform] = useState<string | null>(null);
221
176
  const [setupPlatform, setSetupPlatform] = useState<string | null>(null);
222
177
  const [copiedWebhook, setCopiedWebhook] = useState<string | null>(null);
178
+ const [installations, setInstallations] = useState<
179
+ ClientIntegrationInstallation[]
180
+ >([]);
181
+ const [installationAction, setInstallationAction] = useState<string | null>(
182
+ null,
183
+ );
184
+ const [scopes, setScopes] = useState<ClientIntegrationScope[]>([]);
185
+ const [budgets, setBudgets] = useState<ClientIntegrationUsageBudget[]>([]);
186
+ const [scopeBudget, setScopeBudget] = useState<Record<string, string>>({});
187
+ const [savingScope, setSavingScope] = useState<string | null>(null);
223
188
 
224
189
  const refreshStatuses = async () => {
225
190
  setLoading(true);
226
191
  try {
227
- const res = await fetch(
228
- agentNativePath("/_agent-native/integrations/status"),
229
- );
230
- const rows = res.ok ? await res.json() : [];
231
- setStatuses(Array.isArray(rows) ? rows : []);
192
+ setStatuses(await listIntegrationStatuses());
232
193
  } finally {
233
194
  setLoading(false);
234
195
  }
@@ -236,11 +197,10 @@ export function MessagingSetupPanel() {
236
197
 
237
198
  useEffect(() => {
238
199
  let active = true;
239
- fetch(agentNativePath("/_agent-native/integrations/status"))
240
- .then((res) => (res.ok ? res.json() : []))
200
+ listIntegrationStatuses()
241
201
  .then((rows) => {
242
202
  if (active) {
243
- setStatuses(Array.isArray(rows) ? rows : []);
203
+ setStatuses(rows);
244
204
  setLoading(false);
245
205
  }
246
206
  })
@@ -254,11 +214,32 @@ export function MessagingSetupPanel() {
254
214
 
255
215
  useEffect(() => {
256
216
  let active = true;
257
- fetch(agentNativePath("/_agent-native/env-status"))
258
- .then((res) => (res.ok ? res.json() : []))
217
+ listManagedIntegrationInstallations("slack")
218
+ .then((rows) => {
219
+ if (active) setInstallations(rows);
220
+ })
221
+ .catch(() => {});
222
+ listManagedIntegrationScopes("slack")
223
+ .then((rows) => {
224
+ if (active) setScopes(rows);
225
+ })
226
+ .catch(() => {});
227
+ listManagedIntegrationBudgets()
228
+ .then((rows) => {
229
+ if (active) setBudgets(rows);
230
+ })
231
+ .catch(() => {});
232
+ return () => {
233
+ active = false;
234
+ };
235
+ }, []);
236
+
237
+ useEffect(() => {
238
+ let active = true;
239
+ listIntegrationEnvStatuses()
259
240
  .then((rows) => {
260
241
  if (active) {
261
- setEnvStatuses(Array.isArray(rows) ? rows : []);
242
+ setEnvStatuses(rows);
262
243
  setEnvLoading(false);
263
244
  }
264
245
  })
@@ -282,15 +263,16 @@ export function MessagingSetupPanel() {
282
263
  const refreshEnvStatus = async () => {
283
264
  setEnvLoading(true);
284
265
  try {
285
- const res = await fetch(agentNativePath("/_agent-native/env-status"));
286
- const rows = res.ok ? await res.json() : [];
287
- setEnvStatuses(Array.isArray(rows) ? rows : []);
266
+ setEnvStatuses(await listIntegrationEnvStatuses());
288
267
  } finally {
289
268
  setEnvLoading(false);
290
269
  }
291
270
  };
292
271
 
293
- const saveEnvKeys = async (platform: PlatformDefinition, keys: string[]) => {
272
+ const saveEnvKeys = async (
273
+ platform: IntegrationCatalogEntry,
274
+ keys: string[],
275
+ ) => {
294
276
  const vars = keys
295
277
  .map((key) => ({ key, value: envValues[key]?.trim() || "" }))
296
278
  .filter((item) => item.value);
@@ -302,18 +284,9 @@ export function MessagingSetupPanel() {
302
284
 
303
285
  setSavingKeysFor(platform.id);
304
286
  try {
305
- const res = await fetch(agentNativePath("/_agent-native/env-vars"), {
306
- method: "POST",
307
- headers: { "Content-Type": "application/json" },
308
- body: JSON.stringify({ vars }),
309
- });
287
+ await saveIntegrationEnvVars(vars);
310
288
 
311
- if (!res.ok) {
312
- const payload = await res.json().catch(() => ({}));
313
- throw new Error(payload.error || "Failed to save credentials");
314
- }
315
-
316
- toast.success(`${platform.label} credentials saved`);
289
+ toast.success(`${platform.name} credentials saved`);
317
290
  setEnvValues((current) => {
318
291
  const next = { ...current };
319
292
  for (const key of keys) delete next[key];
@@ -331,28 +304,16 @@ export function MessagingSetupPanel() {
331
304
  };
332
305
 
333
306
  const togglePlatform = async (
334
- platform: PlatformDefinition,
307
+ platform: IntegrationCatalogEntry,
335
308
  enabled: boolean,
336
309
  ) => {
337
310
  setTogglingPlatform(platform.id);
338
311
  try {
339
- const action = enabled ? "disable" : "enable";
340
- const res = await fetch(
341
- agentNativePath(`/_agent-native/integrations/${platform.id}/${action}`),
342
- {
343
- method: "POST",
344
- },
345
- );
346
- if (!res.ok) {
347
- const payload = await res.json().catch(() => ({}));
348
- throw new Error(
349
- payload.error || `Failed to ${action} ${platform.label}`,
350
- );
351
- }
312
+ await setIntegrationEnabled(platform.id, !enabled);
352
313
  toast.success(
353
314
  enabled
354
- ? `${platform.label} disconnected`
355
- : `${platform.label} connected`,
315
+ ? `${platform.name} disconnected`
316
+ : `${platform.name} connected`,
356
317
  );
357
318
  await refreshStatuses();
358
319
  } catch (error) {
@@ -364,30 +325,21 @@ export function MessagingSetupPanel() {
364
325
  }
365
326
  };
366
327
 
367
- const runSetup = async (platform: PlatformDefinition) => {
328
+ const runSetup = async (platform: IntegrationCatalogEntry) => {
368
329
  setSetupPlatform(platform.id);
369
330
  try {
370
- const res = await fetch(
371
- agentNativePath(`/_agent-native/integrations/${platform.id}/setup`),
372
- {
373
- method: "POST",
374
- },
375
- );
376
- if (!res.ok) {
377
- const payload = await res.json().catch(() => ({}));
378
- throw new Error(payload.error || `Failed to set up ${platform.label}`);
379
- }
331
+ await setupIntegration(platform.id);
380
332
  toast.success(
381
333
  platform.id === "telegram"
382
334
  ? "Telegram webhook registered"
383
- : `${platform.label} setup complete`,
335
+ : `${platform.name} setup complete`,
384
336
  );
385
337
  await refreshStatuses();
386
338
  } catch (error) {
387
339
  toast.error(
388
340
  error instanceof Error
389
341
  ? error.message
390
- : `Failed to set up ${platform.label}`,
342
+ : `Failed to set up ${platform.name}`,
391
343
  );
392
344
  } finally {
393
345
  setSetupPlatform(null);
@@ -401,24 +353,113 @@ export function MessagingSetupPanel() {
401
353
  setTimeout(() => setCopiedWebhook(null), 1500);
402
354
  };
403
355
 
356
+ const runInstallationAction = async (
357
+ installation: ClientIntegrationInstallation,
358
+ action: "test" | "disconnect",
359
+ ) => {
360
+ setInstallationAction(`${action}:${installation.id}`);
361
+ try {
362
+ await (action === "test"
363
+ ? testManagedIntegrationInstallation(installation.id)
364
+ : disconnectManagedIntegrationInstallation(installation.id));
365
+ setInstallations(await listManagedIntegrationInstallations("slack"));
366
+ toast.success(
367
+ action === "test"
368
+ ? t("messaging.managed.connectionChecked")
369
+ : t("messaging.managed.workspaceDisconnected"),
370
+ );
371
+ } catch (error) {
372
+ toast.error(
373
+ error instanceof Error
374
+ ? error.message
375
+ : t("messaging.managed.actionFailed"),
376
+ );
377
+ } finally {
378
+ setInstallationAction(null);
379
+ }
380
+ };
381
+
382
+ const updateScopePolicy = async (
383
+ scope: ClientIntegrationScope,
384
+ policy: Partial<ClientIntegrationScope["policy"]>,
385
+ ) => {
386
+ setSavingScope(scope.id);
387
+ try {
388
+ await saveManagedIntegrationScope({
389
+ platform: scope.platform,
390
+ tenantId: scope.tenantId,
391
+ conversationId: scope.conversationId,
392
+ conversationType: scope.conversationType,
393
+ trust: scope.trust,
394
+ orgId: scope.orgId,
395
+ serviceOwnerEmail: scope.serviceOwnerEmail,
396
+ defaultModel: scope.defaultModel,
397
+ policy: { ...scope.policy, ...policy },
398
+ });
399
+ setScopes(await listManagedIntegrationScopes("slack"));
400
+ toast.success(t("messaging.managed.channelPolicyUpdated"));
401
+ } catch (error) {
402
+ toast.error(
403
+ error instanceof Error
404
+ ? error.message
405
+ : t("messaging.managed.policyUpdateFailed"),
406
+ );
407
+ } finally {
408
+ setSavingScope(null);
409
+ }
410
+ };
411
+
412
+ const saveScopeBudget = async (scope: ClientIntegrationScope) => {
413
+ const dollars = Number(scopeBudget[scope.id]);
414
+ if (!Number.isFinite(dollars) || dollars <= 0) {
415
+ toast.error(t("messaging.managed.positiveMonthlyBudget"));
416
+ return;
417
+ }
418
+ setSavingScope(`budget:${scope.id}`);
419
+ try {
420
+ await saveManagedIntegrationBudget({
421
+ subject: {
422
+ type: "scope",
423
+ scope: {
424
+ platform: scope.platform,
425
+ tenantId: scope.tenantId,
426
+ conversationId: scope.conversationId,
427
+ },
428
+ },
429
+ period: "month",
430
+ limitMicros: Math.round(dollars * 1_000_000),
431
+ });
432
+ setBudgets(await listManagedIntegrationBudgets());
433
+ toast.success(t("messaging.managed.channelBudgetSaved"));
434
+ } catch (error) {
435
+ toast.error(
436
+ error instanceof Error
437
+ ? error.message
438
+ : t("messaging.managed.budgetSaveFailed"),
439
+ );
440
+ } finally {
441
+ setSavingScope(null);
442
+ }
443
+ };
444
+
404
445
  return (
405
446
  <div className="space-y-4">
406
447
  <div className="grid gap-4 xl:grid-cols-2">
407
- {PLATFORM_DEFINITIONS.map((platform) => {
448
+ {CHANNELS.map((platform) => {
408
449
  const status = statusByPlatform.get(platform.id);
409
- const configured = !!status?.configured;
450
+ const configured =
451
+ !!status?.configured ||
452
+ (platform.id === "slack" &&
453
+ installations.some(
454
+ (installation) => installation.status === "connected",
455
+ ));
410
456
  const enabled = !!status?.enabled;
411
- // Prefer adapter-supplied env keys (includes optional fields like
412
- // webhook secrets); fall back to the static list.
413
- const adapterKeys = status?.requiredEnvKeys;
414
- const envKeys: RequiredEnvKey[] =
415
- adapterKeys && adapterKeys.length > 0
416
- ? adapterKeys
417
- : platform.envKeys.map((key) => ({
418
- key,
419
- label: key,
420
- required: true,
421
- }));
457
+ const envKeys = platform.credentialRequirements;
458
+ const missingRequiredCredentials = hasMissingRequiredCredentials(
459
+ envKeys,
460
+ envStatusByKey,
461
+ );
462
+ const Icon = PLATFORM_ICONS[platform.iconKey] ?? IconPlug;
422
463
 
423
464
  return (
424
465
  <section
@@ -428,12 +469,12 @@ export function MessagingSetupPanel() {
428
469
  <div className="flex items-start justify-between gap-4">
429
470
  <div className="flex items-start gap-3">
430
471
  <div className="flex h-10 w-10 items-center justify-center rounded-xl border bg-muted/30 text-foreground">
431
- <platform.icon size={18} />
472
+ <Icon size={18} />
432
473
  </div>
433
474
  <div>
434
475
  <div className="flex items-center gap-2">
435
476
  <h3 className="text-base font-semibold text-foreground">
436
- {platform.label}
477
+ {platform.name}
437
478
  </h3>
438
479
  <ConnectionStatus
439
480
  configured={configured}
@@ -452,12 +493,16 @@ export function MessagingSetupPanel() {
452
493
  size="sm"
453
494
  className="h-7 px-2 text-xs text-muted-foreground"
454
495
  >
455
- <a href={platform.docsUrl} target="_blank" rel="noreferrer">
496
+ <a
497
+ href={platform.documentation.href}
498
+ target="_blank"
499
+ rel="noreferrer"
500
+ >
456
501
  Docs
457
502
  <IconExternalLink className="ml-1 h-3 w-3" />
458
503
  </a>
459
504
  </Button>
460
- {platform.externalUrl ? (
505
+ {platform.documentation.externalHref ? (
461
506
  <Button
462
507
  asChild
463
508
  variant="ghost"
@@ -465,11 +510,11 @@ export function MessagingSetupPanel() {
465
510
  className="h-7 px-2 text-xs text-muted-foreground"
466
511
  >
467
512
  <a
468
- href={platform.externalUrl}
513
+ href={platform.documentation.externalHref}
469
514
  target="_blank"
470
515
  rel="noreferrer"
471
516
  >
472
- {platform.externalLabel ?? "Open"}
517
+ {platform.documentation.externalLabel ?? "Open"}
473
518
  <IconExternalLink className="ml-1 h-3 w-3" />
474
519
  </a>
475
520
  </Button>
@@ -477,6 +522,240 @@ export function MessagingSetupPanel() {
477
522
  </div>
478
523
  </div>
479
524
 
525
+ {platform.id === "slack" ? (
526
+ <div className="mt-5 space-y-3 rounded-xl border bg-muted/20 p-4">
527
+ <div className="flex flex-wrap items-center justify-between gap-3">
528
+ <div>
529
+ <div className="text-sm font-medium text-foreground">
530
+ {t("messaging.managed.title")}
531
+ </div>
532
+ <p className="mt-1 text-xs text-muted-foreground">
533
+ {t("messaging.managed.description")}
534
+ </p>
535
+ <p className="mt-1 text-xs text-muted-foreground">
536
+ {t("messaging.managed.agentManifestDescription")}
537
+ </p>
538
+ </div>
539
+ <div className="space-y-2">
540
+ <div className="flex flex-wrap items-center gap-2">
541
+ <Button asChild variant="outline" size="sm">
542
+ <a href={managedSlackAgentManifestUrl()}>
543
+ <IconFileDescription className="mr-2 h-4 w-4" />
544
+ {t("messaging.managed.agentManifest")}
545
+ </a>
546
+ </Button>
547
+ {missingRequiredCredentials ? (
548
+ <Button size="sm" disabled>
549
+ <IconBrandSlack className="mr-2 h-4 w-4" />
550
+ {t("messaging.managed.addToSlack")}
551
+ </Button>
552
+ ) : (
553
+ <Button asChild size="sm">
554
+ <a href={managedIntegrationOAuthUrl("slack")}>
555
+ <IconBrandSlack className="mr-2 h-4 w-4" />
556
+ {t("messaging.managed.addToSlack")}
557
+ </a>
558
+ </Button>
559
+ )}
560
+ </div>
561
+ {missingRequiredCredentials ? (
562
+ <p className="max-w-72 text-xs text-amber-700 dark:text-amber-300">
563
+ {t("messaging.managed.requiredCredentials")}
564
+ </p>
565
+ ) : null}
566
+ </div>
567
+ </div>
568
+ {installations.length ? (
569
+ <div className="space-y-2">
570
+ {installations.map((installation) => (
571
+ <div
572
+ key={installation.id}
573
+ className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-background/60 px-3 py-2"
574
+ >
575
+ <div>
576
+ <div className="text-sm font-medium text-foreground">
577
+ {installation.teamName ||
578
+ installation.enterpriseName ||
579
+ installation.teamId ||
580
+ t("messaging.managed.workspaceFallback")}
581
+ </div>
582
+ <div className="mt-0.5 text-xs text-muted-foreground">
583
+ {t("messaging.managed.scopesUpdated", {
584
+ count: installation.scopes.length,
585
+ date: formatDate(installation.updatedAt, {
586
+ dateStyle: "medium",
587
+ }),
588
+ })}
589
+ </div>
590
+ </div>
591
+ <div className="flex items-center gap-2">
592
+ <StatusPill
593
+ tone={
594
+ installation.health === "healthy"
595
+ ? "success"
596
+ : installation.health === "degraded" ||
597
+ installation.health === "revoked"
598
+ ? "warning"
599
+ : "neutral"
600
+ }
601
+ label={t(
602
+ `messaging.managed.health.${installation.health}`,
603
+ )}
604
+ />
605
+ <Button
606
+ variant="outline"
607
+ size="sm"
608
+ onClick={() =>
609
+ runInstallationAction(installation, "test")
610
+ }
611
+ disabled={
612
+ installationAction === `test:${installation.id}`
613
+ }
614
+ >
615
+ {t("messaging.managed.test")}
616
+ </Button>
617
+ <Button
618
+ variant="ghost"
619
+ size="sm"
620
+ onClick={() =>
621
+ runInstallationAction(
622
+ installation,
623
+ "disconnect",
624
+ )
625
+ }
626
+ disabled={
627
+ installationAction ===
628
+ `disconnect:${installation.id}`
629
+ }
630
+ >
631
+ {t("messaging.managed.disconnect")}
632
+ </Button>
633
+ </div>
634
+ </div>
635
+ ))}
636
+ </div>
637
+ ) : (
638
+ <p className="text-xs text-muted-foreground">
639
+ {t("messaging.managed.empty")}
640
+ </p>
641
+ )}
642
+ </div>
643
+ ) : null}
644
+
645
+ {platform.id === "slack" && scopes.length ? (
646
+ <div className="mt-4 space-y-3 rounded-xl border bg-muted/20 p-4">
647
+ <div>
648
+ <div className="text-sm font-medium text-foreground">
649
+ {t("messaging.managed.channelAccessTitle")}
650
+ </div>
651
+ <p className="mt-1 text-xs text-muted-foreground">
652
+ {t("messaging.managed.channelAccessDescription")}
653
+ </p>
654
+ </div>
655
+ {scopes.map((scope) => {
656
+ const subjectId = JSON.stringify([
657
+ scope.platform,
658
+ scope.tenantId,
659
+ scope.conversationId,
660
+ ]);
661
+ const budget = budgets.find(
662
+ (item) =>
663
+ item.subjectType === "scope" &&
664
+ item.subjectId === subjectId &&
665
+ item.period === "month",
666
+ );
667
+ return (
668
+ <div
669
+ key={scope.id}
670
+ className="space-y-3 rounded-lg border bg-background/60 p-3"
671
+ >
672
+ <div className="flex flex-wrap items-center justify-between gap-2">
673
+ <div>
674
+ <div className="font-mono text-xs text-foreground">
675
+ {scope.conversationId}
676
+ </div>
677
+ <div className="mt-0.5 text-xs text-muted-foreground">
678
+ {t("messaging.managed.isolatedIdentity", {
679
+ trust: t(
680
+ `messaging.managed.trust.${scope.trust}`,
681
+ ),
682
+ })}
683
+ </div>
684
+ </div>
685
+ <StatusPill
686
+ tone={
687
+ scope.trust === "trusted" ? "success" : "warning"
688
+ }
689
+ label={t(`messaging.managed.trust.${scope.trust}`)}
690
+ />
691
+ </div>
692
+ <div className="grid gap-2 sm:grid-cols-3">
693
+ {[
694
+ [
695
+ "requireMention",
696
+ t("messaging.managed.requireMention"),
697
+ ],
698
+ ["allowGuests", t("messaging.managed.allowGuests")],
699
+ [
700
+ "allowExternalShared",
701
+ t("messaging.managed.allowSlackConnect"),
702
+ ],
703
+ ].map(([key, label]) => (
704
+ <label
705
+ key={key}
706
+ className="flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 text-xs"
707
+ >
708
+ {label}
709
+ <Switch
710
+ checked={
711
+ scope.policy[
712
+ key as keyof ClientIntegrationScope["policy"]
713
+ ]
714
+ }
715
+ disabled={savingScope === scope.id}
716
+ onCheckedChange={(checked) =>
717
+ updateScopePolicy(scope, { [key]: checked })
718
+ }
719
+ />
720
+ </label>
721
+ ))}
722
+ </div>
723
+ <div className="flex flex-wrap items-end gap-2">
724
+ <div className="min-w-40 flex-1 space-y-1">
725
+ <label className="text-xs font-medium text-foreground">
726
+ {t("messaging.managed.monthlyBudgetUsd")}
727
+ </label>
728
+ <Input
729
+ inputMode="decimal"
730
+ value={
731
+ scopeBudget[scope.id] ??
732
+ (budget
733
+ ? String(budget.limitMicros / 1_000_000)
734
+ : "")
735
+ }
736
+ onChange={(event) =>
737
+ setScopeBudget((current) => ({
738
+ ...current,
739
+ [scope.id]: event.target.value,
740
+ }))
741
+ }
742
+ placeholder="25"
743
+ />
744
+ </div>
745
+ <Button
746
+ variant="outline"
747
+ onClick={() => saveScopeBudget(scope)}
748
+ disabled={savingScope === `budget:${scope.id}`}
749
+ >
750
+ {t("messaging.managed.saveBudget")}
751
+ </Button>
752
+ </div>
753
+ </div>
754
+ );
755
+ })}
756
+ </div>
757
+ ) : null}
758
+
480
759
  <Collapsible className="mt-5">
481
760
  <CollapsibleTrigger className="group flex w-full cursor-pointer items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground">
482
761
  <IconChevronRight className="h-3.5 w-3.5 transition-transform group-data-[state=open]:rotate-90" />
@@ -485,7 +764,7 @@ export function MessagingSetupPanel() {
485
764
  <CollapsibleContent>
486
765
  <div className="mt-2 rounded-xl border bg-muted/20 p-4">
487
766
  <ol className="space-y-2 text-sm text-muted-foreground">
488
- {platform.setupSteps.map((step, index) => (
767
+ {platform.setup.steps.map((step, index) => (
489
768
  <li key={step} className="flex gap-2">
490
769
  <span className="text-muted-foreground/60">
491
770
  {index + 1}.
@@ -568,7 +847,7 @@ export function MessagingSetupPanel() {
568
847
  );
569
848
  })}
570
849
  </div>
571
- {envKeys.some((k) => !envStatusByKey.get(k.key)?.configured) ? (
850
+ {missingRequiredCredentials ? (
572
851
  <Button
573
852
  variant="outline"
574
853
  onClick={() =>
@@ -604,7 +883,7 @@ export function MessagingSetupPanel() {
604
883
  variant="outline"
605
884
  size="icon"
606
885
  onClick={() => copyWebhook(status.webhookUrl!)}
607
- aria-label={`Copy ${platform.label} webhook URL`}
886
+ aria-label={`Copy ${platform.name} webhook URL`}
608
887
  >
609
888
  {copiedWebhook === status.webhookUrl ? (
610
889
  <IconCheck className="h-4 w-4" />