@stackable-labs/mcp-app-extension 1.30.0 → 2.0.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 (3) hide show
  1. package/dist/index.js +112 -106
  2. package/dist/server.js +112 -106
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
- import { IDENTITY_EVENT, ACTIVITY_EVENT, SURFACE_TARGET, TEMPLATE_FLAVORS, MESSAGING_ACTION_EXAMPLES, PERMISSIONS, CAPABILITY_PERMISSION_MAP, EVENT_HOOK_PERMISSION_MAP, ALLOWED_ICONS, UI_TAGS, UI_TAG_CATEGORIES, UI_TAG_ATTRIBUTES, tagToComponentName, MESSAGING_SEND_EXAMPLES } from '@stackable-labs/sdk-extension-contracts';
4
+ import { IDENTITY_EVENT, ACTIVITY_EVENT, SURFACE_TARGET, TEMPLATE_FLAVORS, MESSAGING_ACTION_EXAMPLES, PERMISSIONS, CAPABILITY_PERMISSION_MAP, HOOK_PERMISSION_MAP, ALLOWED_ICONS, UI_TAGS, UI_TAG_CATEGORIES, UI_TAG_ATTRIBUTES, tagToComponentName, EVENT_HOOK_PERMISSION_MAP, MESSAGING_SEND_EXAMPLES } from '@stackable-labs/sdk-extension-contracts';
5
5
  import { validatePatternFormat } from '@stackable-labs/lib-contracts';
6
6
  import fs4, { readFile, mkdir, writeFile, constants } from 'fs/promises';
7
7
  import path, { join } from 'path';
@@ -348,6 +348,11 @@ var CORE_CONTENT = `# Extension SDK \u2014 Editorial Notes
348
348
  - Always check the \`loading\` flag before using context values
349
349
  - Context is only available when the host provides it (e.g., when viewing a ticket)
350
350
 
351
+ ### \`messaging.send\` resolves to null without throwing
352
+ - The host silently handles \`no_conversation\` / \`reauth_required\` / \`forbidden\` \u2014 \`send()\` returns null and \`error\` stays unset on these codes
353
+ - These are operational/admin concerns (no active conversation, instance disconnected from messaging, permission revoked) \u2014 surfaced to admins via dashboard remediation UI, not user-actionable from the extension
354
+ - Actionable codes (\`invalid_message\`, \`rate_limited\`, \`upstream_error\`) DO populate \`error\` and throw \u2014 handle in UI
355
+
351
356
  ## Best Practices
352
357
 
353
358
  ### Performance
@@ -506,7 +511,12 @@ useExtendIdentity((claims) => ({
506
511
  external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
507
512
  loyalty_tier: 'bronze', // custom \u2014 sync, known at login
508
513
  verified: false, // custom \u2014 default; updated async post-verification
509
- }))`
514
+ }))`,
515
+ "messaging.send": `import { useMessaging } from '@stackable-labs/sdk-extension-react'
516
+
517
+ // manifest.json: { "permissions": ["messaging:send"] }
518
+ const [send, { enabled, loading, error }] = useMessaging()
519
+ await send({ kind: 'text', body: 'Hello from the extension' })`
510
520
  };
511
521
  var HOOK_SNIPPETS_MEMOIZED = {
512
522
  "events:messaging": `import { useCallback } from 'react'
@@ -836,7 +846,7 @@ The publisher-side bundle scan validates your declaration at submission time:
836
846
 
837
847
  Post a message into the **active conversation** bound to the current Instance. The host attributes each message to the extension via the per-Instance author label set by the admin (see "Author label" below). Two complementary APIs:
838
848
 
839
- 1. **\`useMessaging()\`** \u2014 React hook returning tuple \`[send, { loading, error, data }]\` \u2014 lets callers rename \`send\` per-instance when used multiple times in one component. Preferred for UI components; narrows the error surface to actionable codes only.
849
+ 1. **\`useMessaging()\`** \u2014 React hook returning tuple \`[send, { enabled, loading, error, data }]\` \u2014 lets callers rename \`send\` per-instance when used multiple times in one component. Preferred for UI components; narrows the error surface to actionable codes only. Use \`enabled\` to pre-empt the silent \`no_conversation\` case **without** declaring \`context:read\`.
840
850
  2. **\`messagingSendCapability(payload)\`** / **\`capabilities.messaging.send(payload)\`** \u2014 imperative; useful outside React render. Exposes the full wire-level error taxonomy.
841
851
 
842
852
  ### Manifest contract
@@ -879,14 +889,9 @@ Four kinds:
879
889
  ### Hook usage
880
890
 
881
891
  \`\`\`tsx
882
- import { useMessaging, useContextData } from '@stackable-labs/sdk-extension-react'
892
+ import { useMessaging } from '@stackable-labs/sdk-extension-react'
883
893
 
884
- const { messaging } = useContextData()
885
- const [send, { loading, error }] = useMessaging()
886
-
887
- // Proactive gate: skip the call when there's no conversation \u2014 avoids the
888
- // host-handled no_conversation log + null return.
889
- const canSend = !!messaging?.conversationId
894
+ const [send, { enabled, loading, error }] = useMessaging()
890
895
 
891
896
  const onApprove = async () => {
892
897
  try {
@@ -900,11 +905,15 @@ const onApprove = async () => {
900
905
  }
901
906
  }
902
907
 
903
- if (error === 'rate_limited') {
904
- // Render a "slow down" notice
905
- }
908
+ return (
909
+ <button disabled={!enabled || loading} onClick={onApprove}>
910
+ {error === 'rate_limited' ? 'Slow down\u2026' : 'Approve'}
911
+ </button>
912
+ )
906
913
  \`\`\`
907
914
 
915
+ The \`enabled\` flag is a permission-free, host-pushed signal \u2014 \`true\` whenever an active conversation exists. Wiring buttons to \`disabled={!enabled || loading}\` pre-empts the silent \`no_conversation\` case without forcing the extension to declare \`context:read\`. If the extension already has \`context:read\` for other reasons, \`useContextData().messaging?.conversationId\` is an equivalent gate.
916
+
908
917
  ### Imperative usage
909
918
 
910
919
  \`\`\`tsx
@@ -929,7 +938,7 @@ The wire taxonomy has 6 codes. The hook (\`useMessaging\`) narrows them into two
929
938
 
930
939
  | Code | What the framework does |
931
940
  | --- | --- |
932
- | \`no_conversation\` | \`console.info\` \u2014 pre-empt via \`useContextData().messaging?.conversationId\` |
941
+ | \`no_conversation\` | \`console.info\` \u2014 pre-empt via the \`enabled\` flag from \`useMessaging()\` (no permission needed) |
933
942
  | \`reauth_required\` | \`console.warn\` \u2014 admin sees a "Reconnect" CTA in the dashboard (server flips \`messagingDisconnected: true\`) |
934
943
  | \`forbidden\` | \`console.warn\` \u2014 should not reach in production with correct manifest |
935
944
 
@@ -947,7 +956,7 @@ Three gates fire in series, surfacing the same \`forbidden\` error if any blocks
947
956
 
948
957
  \`reply\` and \`postback\` action clicks fire on extensions with the \`events:messaging\` permission via \`useMessagingEvent\` \u2014 see the \`events:messaging\` section above.
949
958
 
950
- > **Note:** the postback \`payload\` field is currently not surfaced by the Web Widget (only the button text \`actionName\`). Until that gap is closed, design action labels to be self-describing or pair sends with a follow-up \`data.query\` lookup.
959
+ > **Note:** the postback \`payload\` field is currently not surfaced by the Web Widget (only the button text \`actionName\`). Until that gap is closed, design action labels to be self-describing or pair sends with a follow-up \`data.fetch\` lookup.
951
960
  `;
952
961
  };
953
962
 
@@ -1310,16 +1319,12 @@ export function Content(): React.ReactElement {
1310
1319
  </Surface>
1311
1320
  )
1312
1321
  }`,
1313
- "messaging.send": `import { useMessaging, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
1322
+ "messaging.send": `import { useMessaging, Surface, ui } from '@stackable-labs/sdk-extension-react'
1314
1323
 
1315
1324
  // Requires 'messaging:send' permission. send() throws on actionable errors
1316
1325
  // (state.error holds the typed code); resolves to null on host-handled cases.
1317
1326
  export function Content(): React.ReactElement {
1318
- const { messaging } = useContextData()
1319
- const [send, { loading, error }] = useMessaging()
1320
- // Proactive gate: skip the call when there's no conversation \u2014 avoids the
1321
- // host-handled no_conversation log + null return.
1322
- const canSend = !!messaging?.conversationId
1327
+ const [send, { enabled, loading, error }] = useMessaging()
1323
1328
 
1324
1329
  const onSayHello = async () => {
1325
1330
  try {
@@ -1339,7 +1344,7 @@ export function Content(): React.ReactElement {
1339
1344
  return (
1340
1345
  <Surface id="slot.content">
1341
1346
  <ui.Stack direction="column" gap="2" className="p-3">
1342
- <ui.Button onClick={onSayHello} disabled={loading || !canSend}>Say hello</ui.Button>
1347
+ <ui.Button onClick={onSayHello} disabled={!enabled || loading}>Say hello</ui.Button>
1343
1348
  {(error === 'rate_limited') && <ui.Alert variant="warning">Slow down \u2014 sent too many.</ui.Alert>}
1344
1349
  {(error === 'upstream_error') && <ui.Alert variant="error">Send failed \u2014 please try again.</ui.Alert>}
1345
1350
  {(error === 'invalid_message') && <ui.Alert variant="error">Send failed \u2014 message format invalid.</ui.Alert>}
@@ -1375,10 +1380,6 @@ Bootstrap the extension runtime. Call once in \`src/index.tsx\`.
1375
1380
  ${EXAMPLE_SNIPPETS.bootstrap}
1376
1381
  \`\`\`
1377
1382
 
1378
- ## Surface component
1379
- Wraps content for a target slot. The \`id\` must match a target in \`manifest.json\`.
1380
- - \`<Surface id="slot.content">...</Surface>\`
1381
-
1382
1383
  ## useCapabilities()
1383
1384
  Returns the capabilities object for calling host-mediated APIs.
1384
1385
  \`\`\`tsx
@@ -1389,7 +1390,7 @@ const capabilities = useCapabilities()
1389
1390
  // capabilities.actions.toast(payload)
1390
1391
  // capabilities.actions.invoke(action, payload?) \u2014 actions: newConversation, setConversationTags, setConversationFields, open, close, show, hide
1391
1392
  // capabilities.identity.extend(patch) \u2014 push enrichment claims to user.metadata + JWT custom_claims (imperative; for handler-style at login, use useExtendIdentity hook). Each patch key MUST be declared in manifest.identityClaims or the host filter drops it.
1392
- // capabilities.messaging.send(payload) \u2014 post a message into the active conversation bound to this Instance. Requires messaging:send permission. Author label is admin-set per-Instance (instance.config.settings.messagingDisplayName) with extension.manifest.name fallback. For React state ([send, { loading, error, data }]), prefer the useMessaging hook.
1393
+ // capabilities.messaging.send(payload) \u2014 post a message into the active conversation bound to this Instance. Prefer the useMessaging hook instead. Requires messaging:send permission. Author label is admin-set per-Instance (instance.config.settings.messagingDisplayName) with extension.manifest.name fallback.
1393
1394
  \`\`\`
1394
1395
 
1395
1396
  ## useStore(store, selector?)
@@ -1398,6 +1399,17 @@ Subscribe to a shared store. Re-renders when the selected state changes.
1398
1399
  const viewState = useStore(appStore, (s) => s.viewState)
1399
1400
  \`\`\`
1400
1401
 
1402
+ ## createStore(initialState)
1403
+ Create a shared store for cross-surface state coordination.
1404
+ \`\`\`tsx
1405
+ const appStore = createStore<AppState>({ viewState: { type: 'menu' } })
1406
+ \`\`\`
1407
+
1408
+ **Store\\<T\\> interface**
1409
+ - \`get(): T\` \u2014 read current state
1410
+ - \`set(partial: Partial<T>): void\` \u2014 merge partial state update
1411
+ - \`subscribe(listener: (state: T) => void): () => void\` \u2014 subscribe, returns unsubscribe fn
1412
+
1401
1413
  ## useContextData()
1402
1414
  Reads host-provided context including extension settings. Returns \`{ loading, customerId, customerEmail, messaging, settings, ... }\`. \`messaging.conversationId\` is the active Messaging conversation ID (or \`null\` until one exists).
1403
1415
  \`\`\`tsx
@@ -1424,26 +1436,48 @@ Returns extension-level context.
1424
1436
  const { extensionId } = useExtension()
1425
1437
  \`\`\`
1426
1438
 
1427
- ## createStore(initialState)
1428
- Create a shared store for cross-surface state coordination.
1439
+ ## useEvent(eventType, handler)
1440
+ Generic cross-domain event hook (should not be used unless absolutely required). Subscribe to any event using fully-qualified event types.
1441
+ - \`eventType: EventType\` \u2014 fully-qualified (e.g., \`'activity:product_view'\`, \`'identity:login'\`, \`'messaging:postback'\`)
1442
+ - Domain wildcard (e.g., \`'activity'\`) receives all events in that domain
1443
+ - \`handler: (event: BaseEvent) => void\`
1444
+
1429
1445
  \`\`\`tsx
1430
- const appStore = createStore<AppState>({ viewState: { type: 'menu' } })
1446
+ useEvent('activity', (event) => {
1447
+ console.log('Activity:', event.data)
1448
+ })
1431
1449
  \`\`\`
1432
1450
 
1433
- ### Store\\<T\\> interface
1434
- - \`get(): T\` \u2014 read current state
1435
- - \`set(partial: Partial<T>): void\` \u2014 merge partial state update
1436
- - \`subscribe(listener: (state: T) => void): () => void\` \u2014 subscribe, returns unsubscribe fn
1451
+ ## useMessaging()
1452
+ Send messages into the active conversation bound to this Instance. Wraps the \`messaging.send\` capability with React state, tracking \`enabled\` / \`loading\` / \`error\` / \`data\`. Returns a tuple \`[send, state]\` so callers can rename \`send\` when the hook is used multiple times in one component. Requires \`messaging:send\` permission. The author label rendered above outbound messages is set per-Instance by the admin (Instance settings \`messagingDisplayName\`); falls back to \`extension.manifest.name\` when blank.
1437
1453
 
1438
- ## useIdentityEvent(eventType, handler)
1439
- Subscribe to identity events pushed from the host via the framework. Requires \`events:identity\` permission and matching entries in manifest \`events\` array.
1440
- - \`eventType: ${identityEventTypes}\`
1441
- - \`handler: (event: IdentityEvent) => void\`
1442
- - \`IdentityEvent: { eventName: IdentityEventType, data: { state: IdentityState, timestamp: string } }\`
1443
- - \`IdentityState: { authenticated: boolean, user: UserIdentity | null, expiresAt?: string }\`
1454
+ - **Returns:** \`readonly [send, { enabled, loading, error, data }]\`
1455
+ - **\`send(payload: SendMessagePayload): Promise<SendMessageResponse | null>\`** \u2014 returns the response on success; throws on **actionable** errors; resolves to \`null\` on host-handled errors (SDK logs a breadcrumb, host surfaces remediation)
1456
+ - **\`enabled: boolean\`** \u2014 advisory; \`true\` when an active conversation exists. Permission-free (no \`context:read\` needed). Wire to button \`disabled\` to pre-empt the silent \`no_conversation\` case. Starts \`false\`; flips reactively via host-pushed availability events.
1457
+ - **\`loading: boolean\`** \u2014 true while a call is in flight (matches \`useContextData\`'s \`loading\` for SDK-wide consistency)
1458
+ - **\`data: SendMessageResponse | null\`** \u2014 \`{ messageId, receivedAt }\` from last successful send
1459
+ - **\`error: SendMessageActionableErrorCode | null\`** \u2014 one of \`invalid_message\` / \`rate_limited\` / \`upstream_error\` after a failed send. Host-handled codes (\`no_conversation\` / \`reauth_required\` / \`forbidden\`) never surface here.
1460
+
1461
+ Payload is discriminated by \`kind\`: \`'text'\` / \`'image'\` / \`'file'\` / \`'carousel'\`. See the \`messaging.send\` capability section for the full payload table + action types.
1444
1462
 
1445
1463
  \`\`\`tsx
1446
- ${stripImports(HOOK_SNIPPETS["events:identity"])}
1464
+ import { useMessaging } from '@stackable-labs/sdk-extension-react'
1465
+
1466
+ const [send, { enabled, loading, error }] = useMessaging()
1467
+
1468
+ const onApprove = async () => {
1469
+ try {
1470
+ await send({ kind: 'text', body: 'Approved \u2713' })
1471
+ } catch {
1472
+ // error holds the typed SendMessageActionableErrorCode
1473
+ }
1474
+ }
1475
+
1476
+ return (
1477
+ <button disabled={!enabled || loading} onClick={onApprove}>
1478
+ Approve
1479
+ </button>
1480
+ )
1447
1481
  \`\`\`
1448
1482
 
1449
1483
  ## useMessagingEvent(eventType, handler)
@@ -1474,16 +1508,15 @@ Subscribe to activity events pushed from the host via the framework. Requires \`
1474
1508
  ${stripImports(HOOK_SNIPPETS["events:activity"])}
1475
1509
  \`\`\`
1476
1510
 
1477
- ## useEvent(eventType, handler)
1478
- Generic cross-domain event hook (should not be used unless absolutely required). Subscribe to any event using fully-qualified event types.
1479
- - \`eventType: EventType\` \u2014 fully-qualified (e.g., \`'activity:product_view'\`, \`'identity:login'\`, \`'messaging:postback'\`)
1480
- - Domain wildcard (e.g., \`'activity'\`) receives all events in that domain
1481
- - \`handler: (event: BaseEvent) => void\`
1511
+ ## useIdentityEvent(eventType, handler)
1512
+ Subscribe to identity events pushed from the host via the framework. Requires \`events:identity\` permission and matching entries in manifest \`events\` array.
1513
+ - \`eventType: ${identityEventTypes}\`
1514
+ - \`handler: (event: IdentityEvent) => void\`
1515
+ - \`IdentityEvent: { eventName: IdentityEventType, data: { state: IdentityState, timestamp: string } }\`
1516
+ - \`IdentityState: { authenticated: boolean, user: UserIdentity | null, expiresAt?: string }\`
1482
1517
 
1483
1518
  \`\`\`tsx
1484
- useEvent('activity', (event) => {
1485
- console.log('Activity:', event.data)
1486
- })
1519
+ ${stripImports(HOOK_SNIPPETS["events:identity"])}
1487
1520
  \`\`\`
1488
1521
 
1489
1522
  ## useExtendIdentity(handler)
@@ -1500,41 +1533,12 @@ With \`useCallback\` (for memoized handlers):
1500
1533
  ${HOOK_SNIPPETS_MEMOIZED["identity.extend"]}
1501
1534
  \`\`\`
1502
1535
 
1503
- ## Identity via context.read()
1536
+ **Identity via context.read()**
1504
1537
  Identity state is available in the \`context.read()\` response as an \`identity\` field. Requires \`context:read\` permission (no separate identity permission needed).
1505
1538
  \`\`\`tsx
1506
1539
  const context = await capabilities.context.read()
1507
1540
  // context.identity \u2014 { authenticated, user, expiresAt? }
1508
1541
  \`\`\`
1509
-
1510
- ## useMessaging()
1511
- Send messages into the active conversation bound to this Instance. Wraps the \`messaging.send\` capability with React state, tracking \`loading\` / \`error\` / \`data\`. Returns a tuple \`[send, state]\` so callers can rename \`send\` when the hook is used multiple times in one component. Requires \`messaging:send\` permission. The author label rendered above outbound messages is set per-Instance by the admin (Instance settings \`messagingDisplayName\`); falls back to \`extension.manifest.name\` when blank.
1512
-
1513
- - **Returns:** \`readonly [send, { loading, error, data }]\`
1514
- - **\`send(payload: SendMessagePayload): Promise<SendMessageResponse | null>\`** \u2014 returns the response on success; throws on **actionable** errors; resolves to \`null\` on host-handled errors (SDK logs a breadcrumb, host surfaces remediation)
1515
- - **\`loading: boolean\`** \u2014 true while a call is in flight (matches \`useContextData\`'s \`loading\` for SDK-wide consistency)
1516
- - **\`data: SendMessageResponse | null\`** \u2014 \`{ messageId, receivedAt }\` from last successful send
1517
- - **\`error: SendMessageActionableErrorCode | null\`** \u2014 one of \`invalid_message\` / \`rate_limited\` / \`upstream_error\` after a failed send. Host-handled codes (\`no_conversation\` / \`reauth_required\` / \`forbidden\`) never surface here.
1518
-
1519
- Payload is discriminated by \`kind\`: \`'text'\` / \`'image'\` / \`'file'\` / \`'carousel'\`. See the \`messaging.send\` capability section for the full payload table + action types.
1520
-
1521
- \`\`\`tsx
1522
- import { useMessaging, useContextData } from '@stackable-labs/sdk-extension-react'
1523
-
1524
- const { messaging } = useContextData()
1525
- const [send, { loading, error }] = useMessaging()
1526
-
1527
- // Proactive gate: skip the call when there's no conversation
1528
- const canSend = !!messaging?.conversationId
1529
-
1530
- const onApprove = async () => {
1531
- try {
1532
- await send({ kind: 'text', body: 'Approved \u2713' })
1533
- } catch {
1534
- // error holds the typed SendMessageActionableErrorCode
1535
- }
1536
- }
1537
- \`\`\`
1538
1542
  `;
1539
1543
  };
1540
1544
 
@@ -4160,7 +4164,7 @@ alongside existing surfaces.
4160
4164
  Add the target to the \`targets\` array in \`packages/extension/public/manifest.json\`.
4161
4165
  Also add any required permissions based on the target-permission mapping:
4162
4166
  - \`slot.header\` \u2192 \`context:read\`
4163
- - \`slot.content\` \u2192 \`context:read\`, \`data:query\`, \`actions:toast\`, \`actions:invoke\`
4167
+ - \`slot.content\` \u2192 \`context:read\`, \`data:query\`, \`actions:toast\`, \`actions:invoke\`, \`messaging:send\`
4164
4168
  - \`slot.footer\` \u2192 (none)
4165
4169
  - \`slot.footer-links\` \u2192 (none)
4166
4170
 
@@ -4237,13 +4241,7 @@ useIdentityEvent('refresh', (event) => {
4237
4241
  console.log('verified =>', event.data.state.user?.metadata?.verified)
4238
4242
  })
4239
4243
  `,
4240
- "messaging.send": `
4241
- // Send a message into the active conversation bound to this Instance.
4242
- // Requires the 'messaging:send' permission. The author label rendered above
4243
- // outbound messages is set per-Instance by the admin (Instance settings
4244
- // messagingDisplayName); falls back to extension.manifest.name when blank.
4245
- const [send, { loading, error }] = useMessaging()
4246
- const result = await send({ kind: 'text', body: 'Hello from the extension' })
4244
+ "messaging.send": `${HOOK_SNIPPETS["messaging.send"]}
4247
4245
 
4248
4246
  // Actionable errors throw + populate state.error: 'invalid_message' (bad payload),
4249
4247
  // 'rate_limited' (slow down), 'upstream_error' (transient \u2014 retry). Branch in
@@ -4251,7 +4249,8 @@ const result = await send({ kind: 'text', body: 'Hello from the extension' })
4251
4249
  // Host-handled cases ('no_conversation' / 'reauth_required' / 'forbidden')
4252
4250
  // resolve to null without throwing; the SDK logs a breadcrumb and the host
4253
4251
  // surfaces remediation to admins (e.g. dashboard Reconnect UI). Extensions
4254
- // can pre-empt 'no_conversation' via useContextData().messaging?.conversationId.
4252
+ // can pre-empt 'no_conversation' via the 'enabled' flag from useMessaging()
4253
+ // (permission-free) \u2014 wire to button disabled={!enabled || loading}.
4255
4254
  if (error === 'rate_limited') { /* show "slow down" UI */ }
4256
4255
  `
4257
4256
  };
@@ -4330,7 +4329,7 @@ export function Content(): React.ReactElement {
4330
4329
  // ../../sdk/extension/ai-docs/src/commands/add-capability.ts
4331
4330
  var generateAddCapabilityCommand = () => {
4332
4331
  const fm = frontmatter({
4333
- description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, identity.extend, events:identity, events:messaging, events:activity) in this extension",
4332
+ description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, messaging.send, identity.extend, events:identity, events:messaging, events:activity) in this extension",
4334
4333
  targets: ["*"]
4335
4334
  });
4336
4335
  return `${fm}
@@ -4346,6 +4345,7 @@ Ask which capability to add. Valid capabilities:
4346
4345
  - \`context.read\` \u2014 read host-provided context (customerId, customerEmail, messaging.conversationId, etc.)
4347
4346
  - \`actions.toast\` \u2014 show toast notifications (success, error, info, warning)
4348
4347
  - \`actions.invoke\` \u2014 invoke host actions (e.g., open new conversation)
4348
+ - \`messaging.send\` \u2014 send messages into the active conversation bound to this Instance (text/image/file/carousel + reply/link/postback actions)
4349
4349
  - \`identity.extend\` \u2014 enrich identity JWT claims before signing
4350
4350
  - \`events:identity\` \u2014 subscribe to identity events (login, logout, refresh, expired)
4351
4351
  - \`events:messaging\` \u2014 subscribe to messaging events (postback button clicks)
@@ -4358,6 +4358,7 @@ Add the corresponding permission to \`packages/extension/public/manifest.json\`:
4358
4358
  - \`context.read\` \u2192 \`"context:read"\`
4359
4359
  - \`actions.toast\` \u2192 \`"actions:toast"\`
4360
4360
  - \`actions.invoke\` \u2192 \`"actions:invoke"\`
4361
+ - \`messaging.send\` \u2192 \`"messaging:send"\`
4361
4362
  - \`identity.extend\` \u2192 \`"identity:extend"\`
4362
4363
  - \`events:identity\` \u2192 \`"events:identity"\` (also add entries to \`events\` array, e.g. \`["identity:login", "identity:logout"]\`)
4363
4364
  - \`events:messaging\` \u2192 \`"events:messaging"\` (also add entries to \`events\` array, e.g. \`["messaging:postback:Buy Now"]\`)
@@ -4400,12 +4401,15 @@ const capabilities = useCapabilities()
4400
4401
  // actions.invoke: capabilities.actions.invoke('newConversation', { tags: ['order'], fields: [{ id: 'field_id', value: 'val' }] })
4401
4402
  \`\`\`
4402
4403
 
4403
- ### Special handling (events + identity.extend):
4404
+ ### Special handling (events + identity.extend + messaging.send):
4404
4405
 
4405
4406
  #### For events \u2014 ALWAYS use dedicated hooks (INSTEAD of useCapabilities direct):
4406
4407
  Events are subscribed via \`useIdentityEvent\` / \`useMessagingEvent\` / \`useActivityEvent\` \u2014 never use \`capabilities.events.*\` directly (events are not part of the \`capabilities\` object).
4407
4408
 
4408
- #### For identity.extend \u2014 choose the CORRECT option:
4409
+ #### For messaging.send \u2014 ALWAYS use the \`useMessaging\` hook:
4410
+ \`useMessaging\` returns a \`[send, { enabled, loading, error }]\` tuple. The \`send\` function is the imperative call wrapped with React state + typed errors. ALWAYS use the hook \u2014 do NOT call \`capabilities.messaging.send(...)\` directly from \`useCapabilities()\`. The hook is the canonical pattern: state management (\`enabled\`/\`loading\`) + typed errors (\`error\`) + correct payload shape (\`{ kind, body }\` with the \`kind\` discriminator). Wire send buttons to \`disabled={!enabled || loading}\` \u2014 the \`enabled\` flag pre-empts the silent \`no_conversation\` case without declaring \`context:read\`. See the snippet below for the full error-handling surface (actionable codes vs. host-handled codes).
4411
+
4412
+ #### For identity.extend \u2014 CHOOSE the correct option:
4409
4413
  - **\`useExtendIdentity(handler)\`** \u2014 synchronous hook, fires only once at initial login. ALWAYS use for known-at-login enrichment.
4410
4414
  - **\`capabilities.identity.extend(patch)\`** \u2014 imperative call via \`useCapabilities()\`, fires post-login (after async verification, webhook callbacks, user-triggered flows). Re-signs the JWT and broadcasts \`identity:refresh\`.
4411
4415
 
@@ -4424,6 +4428,11 @@ ${HOOK_SNIPPETS["events:messaging"]}
4424
4428
  ${HOOK_SNIPPETS["events:activity"]}
4425
4429
  \`\`\`
4426
4430
 
4431
+ \`\`\`tsx
4432
+ // messaging.send \u2014 use useMessaging hook (returns [send, { enabled, loading, error }] tuple)
4433
+ ${HOOK_SNIPPETS["messaging.send"]}
4434
+ \`\`\`
4435
+
4427
4436
  \`\`\`tsx
4428
4437
  // identity.extend \u2014 login-time useExtendIdentity hook OR post-login imperative capabilities.identity.extend
4429
4438
  ${CAPABILITY_SNIPPETS["identity.extend"]}
@@ -4435,6 +4444,7 @@ ${CAPABILITY_SNIPPETS["identity.extend"]}
4435
4444
  - For data/context/actions: confirm accessed via \`useCapabilities()\` hook
4436
4445
  - For events: confirm using \`useIdentityEvent\`, \`useMessagingEvent\`, or \`useActivityEvent\` hooks
4437
4446
  - For identity.extend: confirm using \`useExtendIdentity\` hook (login-time) and/or \`capabilities.identity.extend(patch)\` (imperative post-login)
4447
+ - For messaging.send: confirm using \`useMessaging\` hook
4438
4448
  `;
4439
4449
  };
4440
4450
 
@@ -5383,16 +5393,12 @@ export function Content() {
5383
5393
  </Surface>
5384
5394
  )
5385
5395
  }`,
5386
- "messaging.send": `import { useMessaging, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
5396
+ "messaging.send": `import { useMessaging, Surface, ui } from '@stackable-labs/sdk-extension-react'
5387
5397
 
5388
5398
  // Requires 'messaging:send' permission. send() throws on actionable errors
5389
5399
  // (state.error holds the typed code); resolves to null on host-handled cases.
5390
5400
  export function Content() {
5391
- const { messaging } = useContextData()
5392
- const [send, { loading, error }] = useMessaging()
5393
- // Proactive gate: skip the call when there's no conversation \u2014 avoids the
5394
- // host-handled no_conversation log + null return.
5395
- const canSend = !!messaging?.conversationId
5401
+ const [send, { enabled, loading, error }] = useMessaging()
5396
5402
 
5397
5403
  const onSayHello = async () => {
5398
5404
  try {
@@ -5412,7 +5418,7 @@ export function Content() {
5412
5418
  return (
5413
5419
  <Surface id="slot.content">
5414
5420
  <ui.Stack direction="column" gap="2" className="p-3">
5415
- <ui.Button onClick={onSayHello} disabled={loading || !canSend}>Say hello</ui.Button>
5421
+ <ui.Button onClick={onSayHello} disabled={!enabled || loading}>Say hello</ui.Button>
5416
5422
  {(error === 'rate_limited') && <ui.Alert variant="warning">Slow down \u2014 sent too many.</ui.Alert>}
5417
5423
  {(error === 'upstream_error') && <ui.Alert variant="error">Send failed \u2014 please try again.</ui.Alert>}
5418
5424
  {(error === 'invalid_message') && <ui.Alert variant="error">Send failed \u2014 message format invalid.</ui.Alert>}
@@ -5511,12 +5517,11 @@ await capabilities.identity.extend({ verified: true })
5511
5517
  useIdentityEvent('refresh', (event) => {
5512
5518
  console.log('verified =>', event.data.state.user?.metadata?.verified)
5513
5519
  })`,
5514
- "messaging.send": `// Send a message into the active conversation bound to this Instance.
5515
- // Requires the 'messaging:send' permission. The author label rendered above
5516
- // outbound messages is set per-Instance by the admin (Instance settings
5517
- // messagingDisplayName); falls back to extension.manifest.name when blank.
5518
- const [send, { loading, error }] = useMessaging()
5519
- const result = await send({ kind: 'text', body: 'Hello from the extension' })
5520
+ "messaging.send": `import { useMessaging } from '@stackable-labs/sdk-extension-react'
5521
+
5522
+ // manifest.json: { "permissions": ["messaging:send"] }
5523
+ const [send, { enabled, loading, error }] = useMessaging()
5524
+ await send({ kind: 'text', body: 'Hello from the extension' })
5520
5525
 
5521
5526
  // Actionable errors throw + populate state.error: 'invalid_message' (bad payload),
5522
5527
  // 'rate_limited' (slow down), 'upstream_error' (transient \u2014 retry). Branch in
@@ -5524,7 +5529,8 @@ const result = await send({ kind: 'text', body: 'Hello from the extension' })
5524
5529
  // Host-handled cases ('no_conversation' / 'reauth_required' / 'forbidden')
5525
5530
  // resolve to null without throwing; the SDK logs a breadcrumb and the host
5526
5531
  // surfaces remediation to admins (e.g. dashboard Reconnect UI). Extensions
5527
- // can pre-empt 'no_conversation' via useContextData().messaging?.conversationId.
5532
+ // can pre-empt 'no_conversation' via the 'enabled' flag from useMessaging()
5533
+ // (permission-free) \u2014 wire to button disabled={!enabled || loading}.
5528
5534
  if (error === 'rate_limited') { /* show "slow down" UI */ }`
5529
5535
  };
5530
5536
  var EVENT_SNIPPETS2 = {
@@ -5901,7 +5907,7 @@ ${errors.map((e) => `- ${e}`).join("\n")}`);
5901
5907
  usedPermissions.add(permission);
5902
5908
  }
5903
5909
  }
5904
- for (const [hook, permission] of Object.entries(EVENT_HOOK_PERMISSION_MAP)) {
5910
+ for (const [hook, permission] of Object.entries(HOOK_PERMISSION_MAP)) {
5905
5911
  if (allSource.includes(hook)) {
5906
5912
  usedPermissions.add(permission);
5907
5913
  }
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { IDENTITY_EVENT, ACTIVITY_EVENT, SURFACE_TARGET, TEMPLATE_FLAVORS, MESSAGING_ACTION_EXAMPLES, PERMISSIONS, CAPABILITY_PERMISSION_MAP, EVENT_HOOK_PERMISSION_MAP, ALLOWED_ICONS, UI_TAGS, UI_TAG_CATEGORIES, UI_TAG_ATTRIBUTES, tagToComponentName, MESSAGING_SEND_EXAMPLES } from '@stackable-labs/sdk-extension-contracts';
2
+ import { IDENTITY_EVENT, ACTIVITY_EVENT, SURFACE_TARGET, TEMPLATE_FLAVORS, MESSAGING_ACTION_EXAMPLES, PERMISSIONS, CAPABILITY_PERMISSION_MAP, HOOK_PERMISSION_MAP, ALLOWED_ICONS, UI_TAGS, UI_TAG_CATEGORIES, UI_TAG_ATTRIBUTES, tagToComponentName, EVENT_HOOK_PERMISSION_MAP, MESSAGING_SEND_EXAMPLES } from '@stackable-labs/sdk-extension-contracts';
3
3
  import { validatePatternFormat } from '@stackable-labs/lib-contracts';
4
4
  import { readFile } from 'fs/promises';
5
5
  import { join } from 'path';
@@ -341,6 +341,11 @@ var CORE_CONTENT = `# Extension SDK \u2014 Editorial Notes
341
341
  - Always check the \`loading\` flag before using context values
342
342
  - Context is only available when the host provides it (e.g., when viewing a ticket)
343
343
 
344
+ ### \`messaging.send\` resolves to null without throwing
345
+ - The host silently handles \`no_conversation\` / \`reauth_required\` / \`forbidden\` \u2014 \`send()\` returns null and \`error\` stays unset on these codes
346
+ - These are operational/admin concerns (no active conversation, instance disconnected from messaging, permission revoked) \u2014 surfaced to admins via dashboard remediation UI, not user-actionable from the extension
347
+ - Actionable codes (\`invalid_message\`, \`rate_limited\`, \`upstream_error\`) DO populate \`error\` and throw \u2014 handle in UI
348
+
344
349
  ## Best Practices
345
350
 
346
351
  ### Performance
@@ -499,7 +504,12 @@ useExtendIdentity((claims) => ({
499
504
  external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
500
505
  loyalty_tier: 'bronze', // custom \u2014 sync, known at login
501
506
  verified: false, // custom \u2014 default; updated async post-verification
502
- }))`
507
+ }))`,
508
+ "messaging.send": `import { useMessaging } from '@stackable-labs/sdk-extension-react'
509
+
510
+ // manifest.json: { "permissions": ["messaging:send"] }
511
+ const [send, { enabled, loading, error }] = useMessaging()
512
+ await send({ kind: 'text', body: 'Hello from the extension' })`
503
513
  };
504
514
  var HOOK_SNIPPETS_MEMOIZED = {
505
515
  "events:messaging": `import { useCallback } from 'react'
@@ -829,7 +839,7 @@ The publisher-side bundle scan validates your declaration at submission time:
829
839
 
830
840
  Post a message into the **active conversation** bound to the current Instance. The host attributes each message to the extension via the per-Instance author label set by the admin (see "Author label" below). Two complementary APIs:
831
841
 
832
- 1. **\`useMessaging()\`** \u2014 React hook returning tuple \`[send, { loading, error, data }]\` \u2014 lets callers rename \`send\` per-instance when used multiple times in one component. Preferred for UI components; narrows the error surface to actionable codes only.
842
+ 1. **\`useMessaging()\`** \u2014 React hook returning tuple \`[send, { enabled, loading, error, data }]\` \u2014 lets callers rename \`send\` per-instance when used multiple times in one component. Preferred for UI components; narrows the error surface to actionable codes only. Use \`enabled\` to pre-empt the silent \`no_conversation\` case **without** declaring \`context:read\`.
833
843
  2. **\`messagingSendCapability(payload)\`** / **\`capabilities.messaging.send(payload)\`** \u2014 imperative; useful outside React render. Exposes the full wire-level error taxonomy.
834
844
 
835
845
  ### Manifest contract
@@ -872,14 +882,9 @@ Four kinds:
872
882
  ### Hook usage
873
883
 
874
884
  \`\`\`tsx
875
- import { useMessaging, useContextData } from '@stackable-labs/sdk-extension-react'
885
+ import { useMessaging } from '@stackable-labs/sdk-extension-react'
876
886
 
877
- const { messaging } = useContextData()
878
- const [send, { loading, error }] = useMessaging()
879
-
880
- // Proactive gate: skip the call when there's no conversation \u2014 avoids the
881
- // host-handled no_conversation log + null return.
882
- const canSend = !!messaging?.conversationId
887
+ const [send, { enabled, loading, error }] = useMessaging()
883
888
 
884
889
  const onApprove = async () => {
885
890
  try {
@@ -893,11 +898,15 @@ const onApprove = async () => {
893
898
  }
894
899
  }
895
900
 
896
- if (error === 'rate_limited') {
897
- // Render a "slow down" notice
898
- }
901
+ return (
902
+ <button disabled={!enabled || loading} onClick={onApprove}>
903
+ {error === 'rate_limited' ? 'Slow down\u2026' : 'Approve'}
904
+ </button>
905
+ )
899
906
  \`\`\`
900
907
 
908
+ The \`enabled\` flag is a permission-free, host-pushed signal \u2014 \`true\` whenever an active conversation exists. Wiring buttons to \`disabled={!enabled || loading}\` pre-empts the silent \`no_conversation\` case without forcing the extension to declare \`context:read\`. If the extension already has \`context:read\` for other reasons, \`useContextData().messaging?.conversationId\` is an equivalent gate.
909
+
901
910
  ### Imperative usage
902
911
 
903
912
  \`\`\`tsx
@@ -922,7 +931,7 @@ The wire taxonomy has 6 codes. The hook (\`useMessaging\`) narrows them into two
922
931
 
923
932
  | Code | What the framework does |
924
933
  | --- | --- |
925
- | \`no_conversation\` | \`console.info\` \u2014 pre-empt via \`useContextData().messaging?.conversationId\` |
934
+ | \`no_conversation\` | \`console.info\` \u2014 pre-empt via the \`enabled\` flag from \`useMessaging()\` (no permission needed) |
926
935
  | \`reauth_required\` | \`console.warn\` \u2014 admin sees a "Reconnect" CTA in the dashboard (server flips \`messagingDisconnected: true\`) |
927
936
  | \`forbidden\` | \`console.warn\` \u2014 should not reach in production with correct manifest |
928
937
 
@@ -940,7 +949,7 @@ Three gates fire in series, surfacing the same \`forbidden\` error if any blocks
940
949
 
941
950
  \`reply\` and \`postback\` action clicks fire on extensions with the \`events:messaging\` permission via \`useMessagingEvent\` \u2014 see the \`events:messaging\` section above.
942
951
 
943
- > **Note:** the postback \`payload\` field is currently not surfaced by the Web Widget (only the button text \`actionName\`). Until that gap is closed, design action labels to be self-describing or pair sends with a follow-up \`data.query\` lookup.
952
+ > **Note:** the postback \`payload\` field is currently not surfaced by the Web Widget (only the button text \`actionName\`). Until that gap is closed, design action labels to be self-describing or pair sends with a follow-up \`data.fetch\` lookup.
944
953
  `;
945
954
  };
946
955
 
@@ -1303,16 +1312,12 @@ export function Content(): React.ReactElement {
1303
1312
  </Surface>
1304
1313
  )
1305
1314
  }`,
1306
- "messaging.send": `import { useMessaging, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
1315
+ "messaging.send": `import { useMessaging, Surface, ui } from '@stackable-labs/sdk-extension-react'
1307
1316
 
1308
1317
  // Requires 'messaging:send' permission. send() throws on actionable errors
1309
1318
  // (state.error holds the typed code); resolves to null on host-handled cases.
1310
1319
  export function Content(): React.ReactElement {
1311
- const { messaging } = useContextData()
1312
- const [send, { loading, error }] = useMessaging()
1313
- // Proactive gate: skip the call when there's no conversation \u2014 avoids the
1314
- // host-handled no_conversation log + null return.
1315
- const canSend = !!messaging?.conversationId
1320
+ const [send, { enabled, loading, error }] = useMessaging()
1316
1321
 
1317
1322
  const onSayHello = async () => {
1318
1323
  try {
@@ -1332,7 +1337,7 @@ export function Content(): React.ReactElement {
1332
1337
  return (
1333
1338
  <Surface id="slot.content">
1334
1339
  <ui.Stack direction="column" gap="2" className="p-3">
1335
- <ui.Button onClick={onSayHello} disabled={loading || !canSend}>Say hello</ui.Button>
1340
+ <ui.Button onClick={onSayHello} disabled={!enabled || loading}>Say hello</ui.Button>
1336
1341
  {(error === 'rate_limited') && <ui.Alert variant="warning">Slow down \u2014 sent too many.</ui.Alert>}
1337
1342
  {(error === 'upstream_error') && <ui.Alert variant="error">Send failed \u2014 please try again.</ui.Alert>}
1338
1343
  {(error === 'invalid_message') && <ui.Alert variant="error">Send failed \u2014 message format invalid.</ui.Alert>}
@@ -1368,10 +1373,6 @@ Bootstrap the extension runtime. Call once in \`src/index.tsx\`.
1368
1373
  ${EXAMPLE_SNIPPETS.bootstrap}
1369
1374
  \`\`\`
1370
1375
 
1371
- ## Surface component
1372
- Wraps content for a target slot. The \`id\` must match a target in \`manifest.json\`.
1373
- - \`<Surface id="slot.content">...</Surface>\`
1374
-
1375
1376
  ## useCapabilities()
1376
1377
  Returns the capabilities object for calling host-mediated APIs.
1377
1378
  \`\`\`tsx
@@ -1382,7 +1383,7 @@ const capabilities = useCapabilities()
1382
1383
  // capabilities.actions.toast(payload)
1383
1384
  // capabilities.actions.invoke(action, payload?) \u2014 actions: newConversation, setConversationTags, setConversationFields, open, close, show, hide
1384
1385
  // capabilities.identity.extend(patch) \u2014 push enrichment claims to user.metadata + JWT custom_claims (imperative; for handler-style at login, use useExtendIdentity hook). Each patch key MUST be declared in manifest.identityClaims or the host filter drops it.
1385
- // capabilities.messaging.send(payload) \u2014 post a message into the active conversation bound to this Instance. Requires messaging:send permission. Author label is admin-set per-Instance (instance.config.settings.messagingDisplayName) with extension.manifest.name fallback. For React state ([send, { loading, error, data }]), prefer the useMessaging hook.
1386
+ // capabilities.messaging.send(payload) \u2014 post a message into the active conversation bound to this Instance. Prefer the useMessaging hook instead. Requires messaging:send permission. Author label is admin-set per-Instance (instance.config.settings.messagingDisplayName) with extension.manifest.name fallback.
1386
1387
  \`\`\`
1387
1388
 
1388
1389
  ## useStore(store, selector?)
@@ -1391,6 +1392,17 @@ Subscribe to a shared store. Re-renders when the selected state changes.
1391
1392
  const viewState = useStore(appStore, (s) => s.viewState)
1392
1393
  \`\`\`
1393
1394
 
1395
+ ## createStore(initialState)
1396
+ Create a shared store for cross-surface state coordination.
1397
+ \`\`\`tsx
1398
+ const appStore = createStore<AppState>({ viewState: { type: 'menu' } })
1399
+ \`\`\`
1400
+
1401
+ **Store\\<T\\> interface**
1402
+ - \`get(): T\` \u2014 read current state
1403
+ - \`set(partial: Partial<T>): void\` \u2014 merge partial state update
1404
+ - \`subscribe(listener: (state: T) => void): () => void\` \u2014 subscribe, returns unsubscribe fn
1405
+
1394
1406
  ## useContextData()
1395
1407
  Reads host-provided context including extension settings. Returns \`{ loading, customerId, customerEmail, messaging, settings, ... }\`. \`messaging.conversationId\` is the active Messaging conversation ID (or \`null\` until one exists).
1396
1408
  \`\`\`tsx
@@ -1417,26 +1429,48 @@ Returns extension-level context.
1417
1429
  const { extensionId } = useExtension()
1418
1430
  \`\`\`
1419
1431
 
1420
- ## createStore(initialState)
1421
- Create a shared store for cross-surface state coordination.
1432
+ ## useEvent(eventType, handler)
1433
+ Generic cross-domain event hook (should not be used unless absolutely required). Subscribe to any event using fully-qualified event types.
1434
+ - \`eventType: EventType\` \u2014 fully-qualified (e.g., \`'activity:product_view'\`, \`'identity:login'\`, \`'messaging:postback'\`)
1435
+ - Domain wildcard (e.g., \`'activity'\`) receives all events in that domain
1436
+ - \`handler: (event: BaseEvent) => void\`
1437
+
1422
1438
  \`\`\`tsx
1423
- const appStore = createStore<AppState>({ viewState: { type: 'menu' } })
1439
+ useEvent('activity', (event) => {
1440
+ console.log('Activity:', event.data)
1441
+ })
1424
1442
  \`\`\`
1425
1443
 
1426
- ### Store\\<T\\> interface
1427
- - \`get(): T\` \u2014 read current state
1428
- - \`set(partial: Partial<T>): void\` \u2014 merge partial state update
1429
- - \`subscribe(listener: (state: T) => void): () => void\` \u2014 subscribe, returns unsubscribe fn
1444
+ ## useMessaging()
1445
+ Send messages into the active conversation bound to this Instance. Wraps the \`messaging.send\` capability with React state, tracking \`enabled\` / \`loading\` / \`error\` / \`data\`. Returns a tuple \`[send, state]\` so callers can rename \`send\` when the hook is used multiple times in one component. Requires \`messaging:send\` permission. The author label rendered above outbound messages is set per-Instance by the admin (Instance settings \`messagingDisplayName\`); falls back to \`extension.manifest.name\` when blank.
1430
1446
 
1431
- ## useIdentityEvent(eventType, handler)
1432
- Subscribe to identity events pushed from the host via the framework. Requires \`events:identity\` permission and matching entries in manifest \`events\` array.
1433
- - \`eventType: ${identityEventTypes}\`
1434
- - \`handler: (event: IdentityEvent) => void\`
1435
- - \`IdentityEvent: { eventName: IdentityEventType, data: { state: IdentityState, timestamp: string } }\`
1436
- - \`IdentityState: { authenticated: boolean, user: UserIdentity | null, expiresAt?: string }\`
1447
+ - **Returns:** \`readonly [send, { enabled, loading, error, data }]\`
1448
+ - **\`send(payload: SendMessagePayload): Promise<SendMessageResponse | null>\`** \u2014 returns the response on success; throws on **actionable** errors; resolves to \`null\` on host-handled errors (SDK logs a breadcrumb, host surfaces remediation)
1449
+ - **\`enabled: boolean\`** \u2014 advisory; \`true\` when an active conversation exists. Permission-free (no \`context:read\` needed). Wire to button \`disabled\` to pre-empt the silent \`no_conversation\` case. Starts \`false\`; flips reactively via host-pushed availability events.
1450
+ - **\`loading: boolean\`** \u2014 true while a call is in flight (matches \`useContextData\`'s \`loading\` for SDK-wide consistency)
1451
+ - **\`data: SendMessageResponse | null\`** \u2014 \`{ messageId, receivedAt }\` from last successful send
1452
+ - **\`error: SendMessageActionableErrorCode | null\`** \u2014 one of \`invalid_message\` / \`rate_limited\` / \`upstream_error\` after a failed send. Host-handled codes (\`no_conversation\` / \`reauth_required\` / \`forbidden\`) never surface here.
1453
+
1454
+ Payload is discriminated by \`kind\`: \`'text'\` / \`'image'\` / \`'file'\` / \`'carousel'\`. See the \`messaging.send\` capability section for the full payload table + action types.
1437
1455
 
1438
1456
  \`\`\`tsx
1439
- ${stripImports(HOOK_SNIPPETS["events:identity"])}
1457
+ import { useMessaging } from '@stackable-labs/sdk-extension-react'
1458
+
1459
+ const [send, { enabled, loading, error }] = useMessaging()
1460
+
1461
+ const onApprove = async () => {
1462
+ try {
1463
+ await send({ kind: 'text', body: 'Approved \u2713' })
1464
+ } catch {
1465
+ // error holds the typed SendMessageActionableErrorCode
1466
+ }
1467
+ }
1468
+
1469
+ return (
1470
+ <button disabled={!enabled || loading} onClick={onApprove}>
1471
+ Approve
1472
+ </button>
1473
+ )
1440
1474
  \`\`\`
1441
1475
 
1442
1476
  ## useMessagingEvent(eventType, handler)
@@ -1467,16 +1501,15 @@ Subscribe to activity events pushed from the host via the framework. Requires \`
1467
1501
  ${stripImports(HOOK_SNIPPETS["events:activity"])}
1468
1502
  \`\`\`
1469
1503
 
1470
- ## useEvent(eventType, handler)
1471
- Generic cross-domain event hook (should not be used unless absolutely required). Subscribe to any event using fully-qualified event types.
1472
- - \`eventType: EventType\` \u2014 fully-qualified (e.g., \`'activity:product_view'\`, \`'identity:login'\`, \`'messaging:postback'\`)
1473
- - Domain wildcard (e.g., \`'activity'\`) receives all events in that domain
1474
- - \`handler: (event: BaseEvent) => void\`
1504
+ ## useIdentityEvent(eventType, handler)
1505
+ Subscribe to identity events pushed from the host via the framework. Requires \`events:identity\` permission and matching entries in manifest \`events\` array.
1506
+ - \`eventType: ${identityEventTypes}\`
1507
+ - \`handler: (event: IdentityEvent) => void\`
1508
+ - \`IdentityEvent: { eventName: IdentityEventType, data: { state: IdentityState, timestamp: string } }\`
1509
+ - \`IdentityState: { authenticated: boolean, user: UserIdentity | null, expiresAt?: string }\`
1475
1510
 
1476
1511
  \`\`\`tsx
1477
- useEvent('activity', (event) => {
1478
- console.log('Activity:', event.data)
1479
- })
1512
+ ${stripImports(HOOK_SNIPPETS["events:identity"])}
1480
1513
  \`\`\`
1481
1514
 
1482
1515
  ## useExtendIdentity(handler)
@@ -1493,41 +1526,12 @@ With \`useCallback\` (for memoized handlers):
1493
1526
  ${HOOK_SNIPPETS_MEMOIZED["identity.extend"]}
1494
1527
  \`\`\`
1495
1528
 
1496
- ## Identity via context.read()
1529
+ **Identity via context.read()**
1497
1530
  Identity state is available in the \`context.read()\` response as an \`identity\` field. Requires \`context:read\` permission (no separate identity permission needed).
1498
1531
  \`\`\`tsx
1499
1532
  const context = await capabilities.context.read()
1500
1533
  // context.identity \u2014 { authenticated, user, expiresAt? }
1501
1534
  \`\`\`
1502
-
1503
- ## useMessaging()
1504
- Send messages into the active conversation bound to this Instance. Wraps the \`messaging.send\` capability with React state, tracking \`loading\` / \`error\` / \`data\`. Returns a tuple \`[send, state]\` so callers can rename \`send\` when the hook is used multiple times in one component. Requires \`messaging:send\` permission. The author label rendered above outbound messages is set per-Instance by the admin (Instance settings \`messagingDisplayName\`); falls back to \`extension.manifest.name\` when blank.
1505
-
1506
- - **Returns:** \`readonly [send, { loading, error, data }]\`
1507
- - **\`send(payload: SendMessagePayload): Promise<SendMessageResponse | null>\`** \u2014 returns the response on success; throws on **actionable** errors; resolves to \`null\` on host-handled errors (SDK logs a breadcrumb, host surfaces remediation)
1508
- - **\`loading: boolean\`** \u2014 true while a call is in flight (matches \`useContextData\`'s \`loading\` for SDK-wide consistency)
1509
- - **\`data: SendMessageResponse | null\`** \u2014 \`{ messageId, receivedAt }\` from last successful send
1510
- - **\`error: SendMessageActionableErrorCode | null\`** \u2014 one of \`invalid_message\` / \`rate_limited\` / \`upstream_error\` after a failed send. Host-handled codes (\`no_conversation\` / \`reauth_required\` / \`forbidden\`) never surface here.
1511
-
1512
- Payload is discriminated by \`kind\`: \`'text'\` / \`'image'\` / \`'file'\` / \`'carousel'\`. See the \`messaging.send\` capability section for the full payload table + action types.
1513
-
1514
- \`\`\`tsx
1515
- import { useMessaging, useContextData } from '@stackable-labs/sdk-extension-react'
1516
-
1517
- const { messaging } = useContextData()
1518
- const [send, { loading, error }] = useMessaging()
1519
-
1520
- // Proactive gate: skip the call when there's no conversation
1521
- const canSend = !!messaging?.conversationId
1522
-
1523
- const onApprove = async () => {
1524
- try {
1525
- await send({ kind: 'text', body: 'Approved \u2713' })
1526
- } catch {
1527
- // error holds the typed SendMessageActionableErrorCode
1528
- }
1529
- }
1530
- \`\`\`
1531
1535
  `;
1532
1536
  };
1533
1537
 
@@ -4153,7 +4157,7 @@ alongside existing surfaces.
4153
4157
  Add the target to the \`targets\` array in \`packages/extension/public/manifest.json\`.
4154
4158
  Also add any required permissions based on the target-permission mapping:
4155
4159
  - \`slot.header\` \u2192 \`context:read\`
4156
- - \`slot.content\` \u2192 \`context:read\`, \`data:query\`, \`actions:toast\`, \`actions:invoke\`
4160
+ - \`slot.content\` \u2192 \`context:read\`, \`data:query\`, \`actions:toast\`, \`actions:invoke\`, \`messaging:send\`
4157
4161
  - \`slot.footer\` \u2192 (none)
4158
4162
  - \`slot.footer-links\` \u2192 (none)
4159
4163
 
@@ -4230,13 +4234,7 @@ useIdentityEvent('refresh', (event) => {
4230
4234
  console.log('verified =>', event.data.state.user?.metadata?.verified)
4231
4235
  })
4232
4236
  `,
4233
- "messaging.send": `
4234
- // Send a message into the active conversation bound to this Instance.
4235
- // Requires the 'messaging:send' permission. The author label rendered above
4236
- // outbound messages is set per-Instance by the admin (Instance settings
4237
- // messagingDisplayName); falls back to extension.manifest.name when blank.
4238
- const [send, { loading, error }] = useMessaging()
4239
- const result = await send({ kind: 'text', body: 'Hello from the extension' })
4237
+ "messaging.send": `${HOOK_SNIPPETS["messaging.send"]}
4240
4238
 
4241
4239
  // Actionable errors throw + populate state.error: 'invalid_message' (bad payload),
4242
4240
  // 'rate_limited' (slow down), 'upstream_error' (transient \u2014 retry). Branch in
@@ -4244,7 +4242,8 @@ const result = await send({ kind: 'text', body: 'Hello from the extension' })
4244
4242
  // Host-handled cases ('no_conversation' / 'reauth_required' / 'forbidden')
4245
4243
  // resolve to null without throwing; the SDK logs a breadcrumb and the host
4246
4244
  // surfaces remediation to admins (e.g. dashboard Reconnect UI). Extensions
4247
- // can pre-empt 'no_conversation' via useContextData().messaging?.conversationId.
4245
+ // can pre-empt 'no_conversation' via the 'enabled' flag from useMessaging()
4246
+ // (permission-free) \u2014 wire to button disabled={!enabled || loading}.
4248
4247
  if (error === 'rate_limited') { /* show "slow down" UI */ }
4249
4248
  `
4250
4249
  };
@@ -4323,7 +4322,7 @@ export function Content(): React.ReactElement {
4323
4322
  // ../../sdk/extension/ai-docs/src/commands/add-capability.ts
4324
4323
  var generateAddCapabilityCommand = () => {
4325
4324
  const fm = frontmatter({
4326
- description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, identity.extend, events:identity, events:messaging, events:activity) in this extension",
4325
+ description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, messaging.send, identity.extend, events:identity, events:messaging, events:activity) in this extension",
4327
4326
  targets: ["*"]
4328
4327
  });
4329
4328
  return `${fm}
@@ -4339,6 +4338,7 @@ Ask which capability to add. Valid capabilities:
4339
4338
  - \`context.read\` \u2014 read host-provided context (customerId, customerEmail, messaging.conversationId, etc.)
4340
4339
  - \`actions.toast\` \u2014 show toast notifications (success, error, info, warning)
4341
4340
  - \`actions.invoke\` \u2014 invoke host actions (e.g., open new conversation)
4341
+ - \`messaging.send\` \u2014 send messages into the active conversation bound to this Instance (text/image/file/carousel + reply/link/postback actions)
4342
4342
  - \`identity.extend\` \u2014 enrich identity JWT claims before signing
4343
4343
  - \`events:identity\` \u2014 subscribe to identity events (login, logout, refresh, expired)
4344
4344
  - \`events:messaging\` \u2014 subscribe to messaging events (postback button clicks)
@@ -4351,6 +4351,7 @@ Add the corresponding permission to \`packages/extension/public/manifest.json\`:
4351
4351
  - \`context.read\` \u2192 \`"context:read"\`
4352
4352
  - \`actions.toast\` \u2192 \`"actions:toast"\`
4353
4353
  - \`actions.invoke\` \u2192 \`"actions:invoke"\`
4354
+ - \`messaging.send\` \u2192 \`"messaging:send"\`
4354
4355
  - \`identity.extend\` \u2192 \`"identity:extend"\`
4355
4356
  - \`events:identity\` \u2192 \`"events:identity"\` (also add entries to \`events\` array, e.g. \`["identity:login", "identity:logout"]\`)
4356
4357
  - \`events:messaging\` \u2192 \`"events:messaging"\` (also add entries to \`events\` array, e.g. \`["messaging:postback:Buy Now"]\`)
@@ -4393,12 +4394,15 @@ const capabilities = useCapabilities()
4393
4394
  // actions.invoke: capabilities.actions.invoke('newConversation', { tags: ['order'], fields: [{ id: 'field_id', value: 'val' }] })
4394
4395
  \`\`\`
4395
4396
 
4396
- ### Special handling (events + identity.extend):
4397
+ ### Special handling (events + identity.extend + messaging.send):
4397
4398
 
4398
4399
  #### For events \u2014 ALWAYS use dedicated hooks (INSTEAD of useCapabilities direct):
4399
4400
  Events are subscribed via \`useIdentityEvent\` / \`useMessagingEvent\` / \`useActivityEvent\` \u2014 never use \`capabilities.events.*\` directly (events are not part of the \`capabilities\` object).
4400
4401
 
4401
- #### For identity.extend \u2014 choose the CORRECT option:
4402
+ #### For messaging.send \u2014 ALWAYS use the \`useMessaging\` hook:
4403
+ \`useMessaging\` returns a \`[send, { enabled, loading, error }]\` tuple. The \`send\` function is the imperative call wrapped with React state + typed errors. ALWAYS use the hook \u2014 do NOT call \`capabilities.messaging.send(...)\` directly from \`useCapabilities()\`. The hook is the canonical pattern: state management (\`enabled\`/\`loading\`) + typed errors (\`error\`) + correct payload shape (\`{ kind, body }\` with the \`kind\` discriminator). Wire send buttons to \`disabled={!enabled || loading}\` \u2014 the \`enabled\` flag pre-empts the silent \`no_conversation\` case without declaring \`context:read\`. See the snippet below for the full error-handling surface (actionable codes vs. host-handled codes).
4404
+
4405
+ #### For identity.extend \u2014 CHOOSE the correct option:
4402
4406
  - **\`useExtendIdentity(handler)\`** \u2014 synchronous hook, fires only once at initial login. ALWAYS use for known-at-login enrichment.
4403
4407
  - **\`capabilities.identity.extend(patch)\`** \u2014 imperative call via \`useCapabilities()\`, fires post-login (after async verification, webhook callbacks, user-triggered flows). Re-signs the JWT and broadcasts \`identity:refresh\`.
4404
4408
 
@@ -4417,6 +4421,11 @@ ${HOOK_SNIPPETS["events:messaging"]}
4417
4421
  ${HOOK_SNIPPETS["events:activity"]}
4418
4422
  \`\`\`
4419
4423
 
4424
+ \`\`\`tsx
4425
+ // messaging.send \u2014 use useMessaging hook (returns [send, { enabled, loading, error }] tuple)
4426
+ ${HOOK_SNIPPETS["messaging.send"]}
4427
+ \`\`\`
4428
+
4420
4429
  \`\`\`tsx
4421
4430
  // identity.extend \u2014 login-time useExtendIdentity hook OR post-login imperative capabilities.identity.extend
4422
4431
  ${CAPABILITY_SNIPPETS["identity.extend"]}
@@ -4428,6 +4437,7 @@ ${CAPABILITY_SNIPPETS["identity.extend"]}
4428
4437
  - For data/context/actions: confirm accessed via \`useCapabilities()\` hook
4429
4438
  - For events: confirm using \`useIdentityEvent\`, \`useMessagingEvent\`, or \`useActivityEvent\` hooks
4430
4439
  - For identity.extend: confirm using \`useExtendIdentity\` hook (login-time) and/or \`capabilities.identity.extend(patch)\` (imperative post-login)
4440
+ - For messaging.send: confirm using \`useMessaging\` hook
4431
4441
  `;
4432
4442
  };
4433
4443
 
@@ -5376,16 +5386,12 @@ export function Content() {
5376
5386
  </Surface>
5377
5387
  )
5378
5388
  }`,
5379
- "messaging.send": `import { useMessaging, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
5389
+ "messaging.send": `import { useMessaging, Surface, ui } from '@stackable-labs/sdk-extension-react'
5380
5390
 
5381
5391
  // Requires 'messaging:send' permission. send() throws on actionable errors
5382
5392
  // (state.error holds the typed code); resolves to null on host-handled cases.
5383
5393
  export function Content() {
5384
- const { messaging } = useContextData()
5385
- const [send, { loading, error }] = useMessaging()
5386
- // Proactive gate: skip the call when there's no conversation \u2014 avoids the
5387
- // host-handled no_conversation log + null return.
5388
- const canSend = !!messaging?.conversationId
5394
+ const [send, { enabled, loading, error }] = useMessaging()
5389
5395
 
5390
5396
  const onSayHello = async () => {
5391
5397
  try {
@@ -5405,7 +5411,7 @@ export function Content() {
5405
5411
  return (
5406
5412
  <Surface id="slot.content">
5407
5413
  <ui.Stack direction="column" gap="2" className="p-3">
5408
- <ui.Button onClick={onSayHello} disabled={loading || !canSend}>Say hello</ui.Button>
5414
+ <ui.Button onClick={onSayHello} disabled={!enabled || loading}>Say hello</ui.Button>
5409
5415
  {(error === 'rate_limited') && <ui.Alert variant="warning">Slow down \u2014 sent too many.</ui.Alert>}
5410
5416
  {(error === 'upstream_error') && <ui.Alert variant="error">Send failed \u2014 please try again.</ui.Alert>}
5411
5417
  {(error === 'invalid_message') && <ui.Alert variant="error">Send failed \u2014 message format invalid.</ui.Alert>}
@@ -5504,12 +5510,11 @@ await capabilities.identity.extend({ verified: true })
5504
5510
  useIdentityEvent('refresh', (event) => {
5505
5511
  console.log('verified =>', event.data.state.user?.metadata?.verified)
5506
5512
  })`,
5507
- "messaging.send": `// Send a message into the active conversation bound to this Instance.
5508
- // Requires the 'messaging:send' permission. The author label rendered above
5509
- // outbound messages is set per-Instance by the admin (Instance settings
5510
- // messagingDisplayName); falls back to extension.manifest.name when blank.
5511
- const [send, { loading, error }] = useMessaging()
5512
- const result = await send({ kind: 'text', body: 'Hello from the extension' })
5513
+ "messaging.send": `import { useMessaging } from '@stackable-labs/sdk-extension-react'
5514
+
5515
+ // manifest.json: { "permissions": ["messaging:send"] }
5516
+ const [send, { enabled, loading, error }] = useMessaging()
5517
+ await send({ kind: 'text', body: 'Hello from the extension' })
5513
5518
 
5514
5519
  // Actionable errors throw + populate state.error: 'invalid_message' (bad payload),
5515
5520
  // 'rate_limited' (slow down), 'upstream_error' (transient \u2014 retry). Branch in
@@ -5517,7 +5522,8 @@ const result = await send({ kind: 'text', body: 'Hello from the extension' })
5517
5522
  // Host-handled cases ('no_conversation' / 'reauth_required' / 'forbidden')
5518
5523
  // resolve to null without throwing; the SDK logs a breadcrumb and the host
5519
5524
  // surfaces remediation to admins (e.g. dashboard Reconnect UI). Extensions
5520
- // can pre-empt 'no_conversation' via useContextData().messaging?.conversationId.
5525
+ // can pre-empt 'no_conversation' via the 'enabled' flag from useMessaging()
5526
+ // (permission-free) \u2014 wire to button disabled={!enabled || loading}.
5521
5527
  if (error === 'rate_limited') { /* show "slow down" UI */ }`
5522
5528
  };
5523
5529
  var EVENT_SNIPPETS2 = {
@@ -5850,7 +5856,7 @@ ${errors.map((e) => `- ${e}`).join("\n")}`);
5850
5856
  usedPermissions.add(permission);
5851
5857
  }
5852
5858
  }
5853
- for (const [hook, permission] of Object.entries(EVENT_HOOK_PERMISSION_MAP)) {
5859
+ for (const [hook, permission] of Object.entries(HOOK_PERMISSION_MAP)) {
5854
5860
  if (allSource.includes(hook)) {
5855
5861
  usedPermissions.add(permission);
5856
5862
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackable-labs/mcp-app-extension",
3
- "version": "1.30.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mcp-app-extension": "./dist/index.js"