@stackable-labs/mcp-app-extension 1.21.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +575 -225
  2. package/dist/server.js +575 -225
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -458,11 +458,19 @@ ${iconList}
458
458
  var HOOK_SNIPPETS = {
459
459
  "events:identity": `import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
460
460
 
461
+ // manifest events: ["identity:login", "identity:logout", "identity:refresh"]
461
462
  useIdentityEvent('login', (event) => {
462
- console.log('User logged in:', event.data.state.user?.email)
463
+ // event.data.state.user.metadata is populated with any enrichment from sibling
464
+ // extensions with identity:extend (declared in their manifest.identityClaims)
465
+ console.log('User logged in:', event.data.state.user?.email, event.data.state.user?.metadata)
463
466
  })
464
467
  useIdentityEvent('logout', () => {
465
468
  console.log('User logged out')
469
+ })
470
+ // identity:refresh fires after any extension calls capabilities.identity.extend({...}).
471
+ // Listen here to react to post-login enrichment (verification, tier upgrades, etc.).
472
+ useIdentityEvent('refresh', (event) => {
473
+ console.log('Identity refreshed \u2014 metadata:', event.data.state.user?.metadata)
466
474
  })`,
467
475
  "events:messaging": `import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
468
476
 
@@ -474,11 +482,23 @@ useMessagingEvent('postback:Buy Now', (event) => {
474
482
  useActivityEvent('product_view', (event) => {
475
483
  console.log('Activity:', event.eventName, event.data)
476
484
  })`,
477
- "extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
478
-
485
+ "identity.extend": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
486
+
487
+ // manifest.json:
488
+ // {
489
+ // "permissions": ["identity:extend"],
490
+ // "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
491
+ // }
492
+ // Standard JWT claims (external_id, email, name) are exempt from declaration.
493
+ // Custom claims must be in manifest.identityClaims or they're dropped with a warn.
494
+ //
495
+ // Fires ONCE at initial login \u2014 return what's known synchronously. For post-login
496
+ // async updates (e.g., after verification completes via a webhook or polling),
497
+ // use capabilities.identity.extend(patch) \u2014 see the 'identity.extend' capability.
479
498
  useExtendIdentity((claims) => ({
480
- external_id: \`custom_\${claims.external_id}\`,
481
- loyalty_tier: 'gold',
499
+ external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
500
+ loyalty_tier: 'bronze', // custom \u2014 sync, known at login
501
+ verified: false, // custom \u2014 default; updated async post-verification
482
502
  }))`
483
503
  };
484
504
  var HOOK_SNIPPETS_MEMOIZED = {
@@ -490,13 +510,19 @@ const handlePostback = useCallback<MessagingEventHandler>((event) => {
490
510
  console.log('Postback:', event.data.actionName, event.data.conversationId)
491
511
  }, [])
492
512
  useMessagingEvent('postback:Buy Now', handlePostback)`,
493
- "extend.identity": `import { useCallback } from 'react'
513
+ "identity.extend": `import { useCallback } from 'react'
494
514
  import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
495
515
  import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
496
516
 
517
+ // manifest.json:
518
+ // {
519
+ // "permissions": ["identity:extend"],
520
+ // "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
521
+ // }
497
522
  const handleExtend = useCallback<ExtendIdentityHandler>((claims) => ({
498
- external_id: \`custom_\${claims.external_id}\`,
499
- loyalty_tier: 'gold',
523
+ external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
524
+ loyalty_tier: 'bronze', // custom \u2014 sync, known at login
525
+ verified: false, // custom \u2014 default; updated async post-verification
500
526
  }), [])
501
527
  useExtendIdentity(handleExtend)`
502
528
  };
@@ -508,7 +534,7 @@ var generateCapabilities = () => {
508
534
  const fm = frontmatter({
509
535
  root: false,
510
536
  targets: ["*"],
511
- description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke, extend:identity, events:identity, events:messaging, events:activity",
537
+ description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke, identity.extend, events:identity, events:messaging, events:activity",
512
538
  globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
513
539
  });
514
540
  return `${fm}
@@ -715,22 +741,89 @@ ${HOOK_SNIPPETS["events:activity"]}
715
741
 
716
742
  **Generic alternative:** \`useEvent('activity:product_view', handler)\` \u2014 a cross-domain hook that accepts fully-qualified event types. Domain wildcard (e.g., \`'activity'\`) receives all events in that domain.
717
743
 
718
- ## extend:identity \u2014 Identity Claim Enrichment
719
- Enrich identity JWT claims before signing. The framework sends base claims to your extension, and you return additional claims to merge into the token.
720
- - **Permission required:** \`extend:identity\`
721
- - **Hook:** \`useExtendIdentity(handler)\` \u2014 \`ExtendIdentityHandler\` type exported for use with \`useCallback\`
722
- - **Handler signature:** \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
723
- - **IdentityBaseClaims:** \`{ external_id: string, email?: string, name?: string, [key: string]: unknown }\`
744
+ ## identity.extend \u2014 Identity Claim Enrichment
745
+ Enrich identity JWT claims and \`identityState.user.metadata\` so the current user's signed token AND any sibling extension can react to them. Two complementary APIs:
746
+
747
+ 1. **\`useExtendIdentity(handler)\`** \u2014 synchronous hook that fires ONCE at initial login.
748
+ 2. **\`capabilities.identity.extend(patch)\`** \u2014 imperative call that fires at any time after login (post-verification, post-checkout, any user-triggered async flow). Re-signs the JWT, updates \`user.metadata\`, and broadcasts \`identity:refresh\`.
749
+
750
+ Both share the **\`identity:extend\`** permission and the **\`manifest.identityClaims\`** declaration gate.
751
+
752
+ ### Manifest contract
724
753
 
725
754
  \`\`\`json
726
755
  {
727
- "permissions": ["extend:identity"]
756
+ "permissions": ["identity:extend"],
757
+ "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
728
758
  }
729
759
  \`\`\`
730
760
 
761
+ - **Standard JWT claims (\`external_id\`, \`email\`, \`name\`) are exempt** \u2014 they're part of the signing contract and may be overridden without declaration.
762
+ - **Custom keys MUST be declared in \`identityClaims\`** or the host filter drops them with a \`console.warn\`.
763
+ - **Reserved JWT/Zendesk keys** (\`iss\`, \`sub\`, \`aud\`, \`exp\`, \`nbf\`, \`iat\`, \`jti\`, \`scope\`, \`email_verified\`, \`user_fields\`) **cannot** appear in \`identityClaims\` \u2014 the Lambda sanitizer always wins on collision.
764
+ - **Key format:** \`/^[a-z_][a-z0-9_]{0,63}$/\` (lowercase identifier, \u226464 chars).
765
+ - **Maximum 20 entries.**
766
+
767
+ ### Initial-login enrichment (handler-style)
768
+
769
+ - **Hook:** \`useExtendIdentity(handler)\` \u2014 \`ExtendIdentityHandler\` type exported for use with \`useCallback\`
770
+ - **Handler signature:** \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
771
+ - **IdentityBaseClaims:** \`{ external_id: string, email?: string, name?: string, [key: string]: unknown }\`
772
+
773
+ \`\`\`tsx
774
+ ${HOOK_SNIPPETS["identity.extend"]}
775
+ \`\`\`
776
+
777
+ ### Async post-login push (imperative)
778
+
779
+ \`\`\`tsx
780
+ const capabilities = useCapabilities()
781
+
782
+ // Anytime after login \u2014 webhook callback, user action, async verification, etc.
783
+ await capabilities.identity.extend({
784
+ verified: true,
785
+ verified_by: 'xyzProvider',
786
+ verified_at: new Date().toISOString(),
787
+ })
788
+ // \u2192 host filters against manifest.identityClaims
789
+ // \u2192 user.metadata updated
790
+ // \u2192 JWT re-signed, pushed to Zendesk loginUser
791
+ // \u2192 identity:refresh broadcast to all extensions with events:identity
792
+ \`\`\`
793
+
794
+ Per-extension calls are serialized to prevent concurrent Zendesk \`loginUser\` callbacks from orphaning each other (empirically verified in the loginUser re-auth spike).
795
+
796
+ ### Consuming enriched state from another extension
797
+
798
+ Any extension with \`events:identity\` permission can react to enrichment updates. Same minimal pattern as the \`events:identity\` section above \u2014 \`'login'\` covers the initial enriched state, \`'refresh'\` covers post-login pushes:
799
+
800
+ \`\`\`tsx
801
+ ${HOOK_SNIPPETS["events:identity"]}
802
+ \`\`\`
803
+
804
+ For a snapshot read instead of event-driven reaction (auto re-renders when context changes):
805
+
731
806
  \`\`\`tsx
732
- ${HOOK_SNIPPETS["extend.identity"]}
807
+ const { identity } = useContextData()
808
+ const verified = Boolean(identity?.user?.metadata?.verified)
733
809
  \`\`\`
810
+
811
+ ### Install-time enforcement
812
+
813
+ Two enabled extensions on the same instance **MUST NOT** declare overlapping \`identityClaims\` keys. The runtime merge is order-dependent (\`Object.assign\` across per-extension contributions), so one extension's value would silently overwrite the other's. The marketplace install API blocks the install with a 409 conflict, surfacing the specific overlapping key + conflicting extension name in the admin install dialog. Coordinate keys with downstream extensions or namespace them (e.g. \`<vendor>_loyalty_tier\`).
814
+
815
+ ### Bundle-scan findings at upload
816
+
817
+ The publisher-side bundle scan validates your declaration at submission time:
818
+
819
+ | Finding | Severity | Triggers when |
820
+ | --- | --- | --- |
821
+ | \`identityClaims_missing\` | warning | \`identity:extend\` declared, \`identityClaims\` empty (custom claims would be dropped) |
822
+ | \`identityClaims_no_permission\` | warning | \`identityClaims\` declared, \`identity:extend\` permission missing |
823
+ | \`identityClaims_invalid_key\` | error | Key fails the format regex |
824
+ | \`identityClaims_reserved_key\` | error | Collides with a reserved JWT / Zendesk claim |
825
+ | \`identityClaims_standard_key\` | warning | Redundant \u2014 standard claims (\`external_id\`, \`email\`, \`name\`) are exempt |
826
+ | \`identityClaims_too_many\` | error | More than 20 entries |
734
827
  `;
735
828
  };
736
829
 
@@ -968,35 +1061,94 @@ export function Content(): React.ReactElement {
968
1061
  </Surface>
969
1062
  )
970
1063
  }`,
971
- "extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
972
- import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
1064
+ "identity.extend": `import { useCapabilities, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
1065
+ import type { ContextData } from '@stackable-labs/sdk-extension-contracts'
973
1066
 
974
- // Enrich identity JWT claims before signing.
975
- // The host sends base claims (external_id, email, name),
976
- // and your handler returns additional claims to merge.
977
- useExtendIdentity((claims) => ({
978
- external_id: \`shopify_\${claims.external_id}\`,
979
- loyalty_tier: 'gold',
980
- }))`,
1067
+ // manifest.json:
1068
+ // {
1069
+ // "permissions": ["identity:extend"],
1070
+ // "identityClaims": ["verified", "verified_by", "verified_at"]
1071
+ // }
1072
+ //
1073
+ // PUSH side \u2014 capabilities.identity.extend(patch) sends new claims to the host
1074
+ // AFTER initial login. The host filters the patch against manifest.identityClaims,
1075
+ // merges into user.metadata, re-signs the JWT, and broadcasts identity:refresh.
1076
+ //
1077
+ // CONSUMER side \u2014 useContextData() already re-renders on every host-pushed
1078
+ // context update (login/logout/refresh/expired). For pure rendering, read the
1079
+ // enriched value directly from ctx.identity.user.metadata \u2014 no event listener
1080
+ // needed. Use useIdentityEvent only when you need a SIDE EFFECT (analytics,
1081
+ // cache invalidation, etc.) on a specific event.
1082
+ //
1083
+ // Standard JWT claims (external_id, email, name) are exempt from declaration.
1084
+ // Undeclared custom keys are dropped client-side with a console.warn.
1085
+ export function Content(): React.ReactElement {
1086
+ const capabilities = useCapabilities()
1087
+ const ctx = useContextData() as ContextData & { loading: boolean }
1088
+
1089
+ // Push: trigger after async verification (webhook, polling, user action)
1090
+ const runVerification = async () => {
1091
+ await new Promise(r => setTimeout(r, 1000)) // simulated async work
1092
+ await capabilities.identity.extend({
1093
+ verified: true,
1094
+ verified_by: 'xyzProvider',
1095
+ verified_at: new Date().toISOString(),
1096
+ })
1097
+ }
1098
+
1099
+ // Consume: read directly from ctx \u2014 re-renders automatically on identity:refresh.
1100
+ const verified = Boolean(ctx.identity?.user?.metadata?.verified)
1101
+
1102
+ return (
1103
+ <Surface id="slot.content">
1104
+ <ui.Stack direction="column" gap="2" className="p-3">
1105
+ <ui.Button onClick={runVerification}>Verify</ui.Button>
1106
+ <ui.Text>Status: {verified ? 'verified' : 'unverified'}</ui.Text>
1107
+ </ui.Stack>
1108
+ </Surface>
1109
+ )
1110
+ }`,
981
1111
  // ── Per-event example snippets ─────────────────────────────────────────────
982
- "events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
1112
+ "events:identity": `import { Surface, ui, useContextData, useIdentityEvent } from '@stackable-labs/sdk-extension-react'
983
1113
  import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
984
- import { useState } from 'react'
985
1114
 
1115
+ // useIdentityEvent \u2014 for SIDE EFFECTS on identity lifecycle events.
1116
+ // If you just want to RENDER based on identity state (e.g. show the current
1117
+ // user's email), use useContextData() \u2014 it re-renders reactively on every
1118
+ // host-pushed identity change. Use useIdentityEvent only when you need to
1119
+ // RUN imperative code on a specific event: analytics beacons, cookie writes,
1120
+ // downstream system notifications, cache invalidation, etc.
1121
+ //
1122
+ // manifest.json:
1123
+ // {
1124
+ // "permissions": ["context:read", "events:identity"],
1125
+ // "events": ["identity:login", "identity:logout"]
1126
+ // }
986
1127
  export function Header(): React.ReactElement {
987
- const [user, setUser] = useState<string | null>(null)
1128
+ const ctx = useContextData()
988
1129
 
1130
+ // Side effect: persist a "last login" hint to localStorage on every login.
1131
+ // Replace with your real-world side effect \u2014 analytics beacon, cookie write,
1132
+ // downstream system notification, etc.
989
1133
  useIdentityEvent('login', (event: IdentityEvent) => {
990
- setUser(event.data.state.user?.email ?? null)
1134
+ localStorage.setItem('last-login', JSON.stringify({
1135
+ userId: event.data.state.user?.id,
1136
+ timestamp: new Date().toISOString(),
1137
+ }))
991
1138
  })
992
1139
 
1140
+ // Clear the hint on logout / session expiry.
993
1141
  useIdentityEvent('logout', () => {
994
- setUser(null)
1142
+ localStorage.removeItem('last-login')
995
1143
  })
996
1144
 
1145
+ // Rendering: read directly from ctx \u2014 DON'T mirror identity into local
1146
+ // useState via the event listener; useContextData is already reactive.
1147
+ const email = ctx?.identity?.user?.email ?? null
1148
+
997
1149
  return (
998
1150
  <Surface id="slot.header">
999
- <ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
1151
+ <ui.Text className="text-xs">{email ?? 'Not logged in'}</ui.Text>
1000
1152
  </Surface>
1001
1153
  )
1002
1154
  }`,
@@ -1075,7 +1227,7 @@ const capabilities = useCapabilities()
1075
1227
  // capabilities.data.fetch(url, init?)
1076
1228
  // capabilities.actions.toast(payload)
1077
1229
  // capabilities.actions.invoke(action, payload?) \u2014 actions: newConversation, setConversationTags, setConversationFields, open, close, show, hide
1078
- // capabilities.extend.identity(payload) \u2014 enrich identity claims (prefer useExtendIdentity hook)
1230
+ // 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.
1079
1231
  \`\`\`
1080
1232
 
1081
1233
  ## useStore(store, selector?)
@@ -1173,17 +1325,17 @@ useEvent('activity', (event) => {
1173
1325
  \`\`\`
1174
1326
 
1175
1327
  ## useExtendIdentity(handler)
1176
- Register a handler to enrich identity JWT claims before signing. Requires \`extend:identity\` permission.
1328
+ Register a handler to enrich identity JWT claims before signing. Requires \`identity:extend\` permission.
1177
1329
  - \`handler: ExtendIdentityHandler\` \u2014 \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
1178
1330
  - \`IdentityBaseClaims: { external_id: string, email?: string, name?: string, [key: string]: unknown }\`
1179
1331
 
1180
1332
  \`\`\`tsx
1181
- ${stripImports(HOOK_SNIPPETS["extend.identity"])}
1333
+ ${stripImports(HOOK_SNIPPETS["identity.extend"])}
1182
1334
  \`\`\`
1183
1335
 
1184
1336
  With \`useCallback\` (for memoized handlers):
1185
1337
  \`\`\`tsx
1186
- ${HOOK_SNIPPETS_MEMOIZED["extend.identity"]}
1338
+ ${HOOK_SNIPPETS_MEMOIZED["identity.extend"]}
1187
1339
  \`\`\`
1188
1340
 
1189
1341
  ## Identity via context.read()
@@ -1890,15 +2042,19 @@ The dev command:
1890
2042
  The CLI outputs a query param like:
1891
2043
 
1892
2044
  \`\`\`
1893
- ?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
2045
+ ?_stackable_dev=ext-123:eyJ1cmwiOiJodHRwczovL2FiYy50cnljbG91ZGZsYXJlLmNvbSIsInRva2VuIjoiZXlKaGJHY2lPaUpJVXp...
1894
2046
  \`\`\`
1895
2047
 
1896
- Copy this and **append it to the host site's URL** (the site or product where your
1897
- extension is installed/authorized) to load your local extension instead of the
1898
- production bundle. For example:
2048
+ The value after the colon is a \`base64url\`-encoded JSON \`{url, token}\` blob (the
2049
+ default, when the CLI obtained a dev session token) or a plain URL (legacy
2050
+ fallback when no token was available).
2051
+
2052
+ Copy the full param and **append it to the host site's URL** (the site or product
2053
+ where your extension is installed/authorized) to load your local extension instead
2054
+ of the production bundle. For example:
1899
2055
 
1900
2056
  \`\`\`
1901
- https://your-host-site.com/dashboard?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
2057
+ https://your-host-site.com/dashboard?_stackable_dev=ext-123:eyJ1cmwi...
1902
2058
  \`\`\`
1903
2059
 
1904
2060
  This override is **browser-session only** \u2014 no database changes, no shared state.
@@ -2079,16 +2235,27 @@ ${CLI.dev}
2079
2235
 
2080
2236
  ### Host-Site Override
2081
2237
 
2082
- The CLI outputs a query param like:
2238
+ The CLI outputs a query param you append to your deployed host site's URL (the
2239
+ site or product where your extension is installed/authorized) to load your local
2240
+ extension instead of the production bundle. The override is browser-session only \u2014
2241
+ no DB changes, no shared state. Each developer gets isolated overrides.
2242
+
2243
+ **Blob format (default \u2014 when authenticated):** the value after the colon is a
2244
+ \`base64url\`-encoded JSON \`{url, token}\` blob; the token is verified by the host:
2083
2245
 
2084
2246
  \`\`\`
2085
- ?_stackable_dev=ext-123%3Ahttps%3A%2F%2Fabc.trycloudflare.com
2247
+ ?_stackable_dev=ext-123:eyJ1cmwiOiJodHRwczovL2FiYy50cnljbG91ZGZsYXJlLmNvbSIsInRva2VuIjoiZXlKaGJHY2lPaUpJVXp...
2086
2248
  \`\`\`
2087
2249
 
2088
- Append this to your deployed host site's URL (the site or product where your
2089
- extension is installed/authorized) to load your local extension instead of the production
2090
- bundle. The override is browser-session only \u2014 no DB changes, no shared state.
2091
- Each developer gets isolated overrides.
2250
+ **Legacy format (fallback \u2014 when no token):** plain URL after the colon:
2251
+
2252
+ \`\`\`
2253
+ ?_stackable_dev=ext-123:https://abc.trycloudflare.com
2254
+ \`\`\`
2255
+
2256
+ The CLI also surfaces a \`_stackable_staging=...\` variant (blob-only) for testing
2257
+ against staging-mode handling. For multiple extensions, comma-join entries in a
2258
+ single param \u2014 mixed blob + legacy is fine.
2092
2259
 
2093
2260
  ## validate *(coming soon)*
2094
2261
 
@@ -2484,7 +2651,7 @@ filtered out. Clicking a surface adds it to your manifest and inserts a
2484
2651
  ### Capabilities
2485
2652
 
2486
2653
  The SDK capabilities your extension can use: \`data.query\`, \`data.fetch\`,
2487
- \`context.read\`, \`actions.toast\`, \`actions.invoke\`, \`extend.identity\`,
2654
+ \`context.read\`, \`actions.toast\`, \`actions.invoke\`, \`identity.extend\`,
2488
2655
  \`events:identity\`, \`events:messaging\`, and \`events:activity\`. Clicking a
2489
2656
  capability adds the permission to your manifest and AI-inserts the hook usage.
2490
2657
 
@@ -3532,30 +3699,13 @@ var generateCookbookEvents = () => {
3532
3699
  });
3533
3700
  return `${fm}
3534
3701
 
3535
- # Events & Extensions
3536
-
3537
- Subscribe to real-time events pushed from the host via the framework, and extend identity claims.
3538
- Each event type has a dedicated hook \u2014 never use \`capabilities.events.*\` directly.
3539
-
3540
- ## Identity Events
3541
-
3542
- Subscribe to login, logout, refresh, and expired events. Useful for tracking
3543
- agent authentication state in your extension.
3544
-
3545
- **Permission:** \`events:identity\`
3546
- **Event types:** ${identityEventTypes2}
3547
-
3548
- ### Hook usage
3549
-
3550
- \`\`\`tsx
3551
- ${HOOK_SNIPPETS["events:identity"]}
3552
- \`\`\`
3702
+ # Events & Identity
3553
3703
 
3554
- ### Full component example
3555
-
3556
- \`\`\`tsx
3557
- ${EXAMPLE_SNIPPETS["events:identity"]}
3558
- \`\`\`
3704
+ Subscribe to real-time events pushed from the host via the framework, and enrich identity
3705
+ claims. Each event type has a dedicated hook (\`useIdentityEvent\` / \`useMessagingEvent\` /
3706
+ \`useActivityEvent\`) \u2014 never use \`capabilities.events.*\` directly. Identity enrichment
3707
+ supports both a login-time hook (\`useExtendIdentity\`) and an imperative post-login API
3708
+ (\`capabilities.identity.extend\`).
3559
3709
 
3560
3710
  ## Messaging Events
3561
3711
 
@@ -3597,24 +3747,54 @@ ${HOOK_SNIPPETS["events:activity"]}
3597
3747
  ${EXAMPLE_SNIPPETS["events:activity"]}
3598
3748
  \`\`\`
3599
3749
 
3600
- ## Extend Identity
3750
+ ## Identity Events
3601
3751
 
3602
- Enrich identity JWT claims before signing. The host sends base claims
3603
- (\`external_id\`, \`email\`, \`name\`) and your handler returns additional
3604
- claims to merge into the token.
3752
+ Subscribe to login, logout, refresh, and expired events. Useful for tracking
3753
+ agent authentication state and reacting to identity enrichment pushed from sibling
3754
+ extensions (via \`capabilities.identity.extend\` \u2014 see *Extend Identity* below).
3605
3755
 
3606
- **Permission:** \`extend:identity\`
3756
+ **Permission:** \`events:identity\`
3757
+ **Event types:** ${identityEventTypes2}
3607
3758
 
3608
3759
  ### Hook usage
3609
3760
 
3610
3761
  \`\`\`tsx
3611
- ${HOOK_SNIPPETS["extend.identity"]}
3762
+ ${HOOK_SNIPPETS["events:identity"]}
3612
3763
  \`\`\`
3613
3764
 
3614
3765
  ### Full component example
3615
3766
 
3616
3767
  \`\`\`tsx
3617
- ${EXAMPLE_SNIPPETS["extend.identity"]}
3768
+ ${EXAMPLE_SNIPPETS["events:identity"]}
3769
+ \`\`\`
3770
+
3771
+ ## Extend Identity
3772
+
3773
+ Enrich identity JWT claims and \`user.metadata\` so the signed token AND any sibling
3774
+ extension with \`events:identity\` can react. Two complementary paths:
3775
+
3776
+ - **\`useExtendIdentity(handler)\`** \u2014 synchronous hook, fires ONCE at initial login. Use
3777
+ for known-at-login enrichment.
3778
+ - **\`capabilities.identity.extend(patch)\`** \u2014 imperative call, fires post-login (after
3779
+ async verification, webhook callbacks, user-triggered flows). Re-signs the JWT and
3780
+ broadcasts \`identity:refresh\` to all extensions with \`events:identity\`.
3781
+
3782
+ Both paths share the **\`identity:extend\`** permission and the **\`manifest.identityClaims\`**
3783
+ declaration gate. Standard JWT claims (\`external_id\`, \`email\`, \`name\`) are exempt;
3784
+ custom keys must be declared or they're dropped by the host filter with a \`console.warn\`.
3785
+
3786
+ **Permission:** \`identity:extend\`
3787
+
3788
+ ### Login-time hook (useExtendIdentity)
3789
+
3790
+ \`\`\`tsx
3791
+ ${HOOK_SNIPPETS["identity.extend"]}
3792
+ \`\`\`
3793
+
3794
+ ### Imperative post-login (capabilities.identity.extend)
3795
+
3796
+ \`\`\`tsx
3797
+ ${EXAMPLE_SNIPPETS["identity.extend"]}
3618
3798
  \`\`\`
3619
3799
  `;
3620
3800
  };
@@ -3678,10 +3858,148 @@ Only add permissions that aren't already declared.
3678
3858
  `;
3679
3859
  };
3680
3860
 
3861
+ // ../../sdk/extension/ai-docs/src/snippets/capabilities.ts
3862
+ var EXTEND_IDENTITY_HOOK_EXAMPLE = `// Enrich identity JWT claims before signing.
3863
+ // The host sends base claims (external_id, email, name); your handler returns
3864
+ // additional claims to merge. Returned keys are filtered against manifest.identityClaims:
3865
+ // - Standard JWT claims (external_id, email, name) are exempt \u2014 always allowed
3866
+ // - Custom claims must be declared in manifest.identityClaims
3867
+ // - Undeclared keys are dropped with a console.warn
3868
+ // Merged claims land in BOTH identityState.user.metadata (for sibling extensions)
3869
+ // AND the signed JWT's custom_claims (for downstream JWT consumers).
3870
+ // Pattern: name the handler with useCallback<ExtendIdentityHandler> for stable
3871
+ // reference + clean separation; pass the named const to useExtendIdentity.
3872
+ //
3873
+ // useExtendIdentity fires ONCE at initial login \u2014 return what's known synchronously.
3874
+ // For post-login async updates (e.g., after verification completes via a webhook
3875
+ // or polling), use capabilities.identity.extend(patch) \u2014 see the 'identity.extend'
3876
+ // capability for the imperative path.
3877
+
3878
+ ${HOOK_SNIPPETS_MEMOIZED["identity.extend"]}`;
3879
+ var CAPABILITY_SNIPPETS = {
3880
+ "data.query": `
3881
+ const capabilities = useCapabilities()
3882
+ const result = await capabilities.data.query({ path: '/your-endpoint', method: 'GET' })
3883
+ `,
3884
+ "data.fetch": `
3885
+ const capabilities = useCapabilities()
3886
+ const response = await capabilities.data.fetch('https://api.example.com/endpoint')
3887
+ `,
3888
+ "context.read": `
3889
+ // Read host context + extension settings
3890
+ const { customerId, settings } = useContextData()
3891
+
3892
+ // Or use the convenience hook for settings only
3893
+ const settings = useSettings()
3894
+ `,
3895
+ "actions.toast": `
3896
+ const capabilities = useCapabilities()
3897
+ capabilities.actions.toast({ type: 'success', message: 'Done!' })
3898
+ `,
3899
+ "actions.invoke": `
3900
+ const capabilities = useCapabilities()
3901
+
3902
+ // New conversation with tags and fields
3903
+ await capabilities.actions.invoke('newConversation', {
3904
+ tags: ['stackable', 'order-lookup'],
3905
+ fields: [{ id: 'stackable_action', value: 'order_status' }],
3906
+ metadata: { orderId: '12345' },
3907
+ })
3908
+
3909
+ // Standalone: set tags on current/next conversation
3910
+ await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
3911
+ `,
3912
+ "events:identity": HOOK_SNIPPETS["events:identity"],
3913
+ "events:messaging": HOOK_SNIPPETS["events:messaging"],
3914
+ "events:activity": HOOK_SNIPPETS["events:activity"],
3915
+ "identity.extend": `${EXTEND_IDENTITY_HOOK_EXAMPLE}
3916
+
3917
+ // \u2500\u2500 For post-login async push (e.g., after async verification completes): \u2500\u2500\u2500\u2500\u2500
3918
+ const capabilities = useCapabilities()
3919
+ await capabilities.identity.extend({ verified: true })
3920
+
3921
+ // Consumer side \u2014 same or sibling extension reacts via identity:refresh
3922
+ useIdentityEvent('refresh', (event) => {
3923
+ console.log('verified =>', event.data.state.user?.metadata?.verified)
3924
+ })
3925
+ `
3926
+ };
3927
+ var EVENT_SNIPPETS = {
3928
+ "events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
3929
+ import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
3930
+ import { useState } from 'react'
3931
+
3932
+ // manifest events: ["identity:login", "identity:logout", "identity:refresh"]
3933
+ //
3934
+ // 'login' and 'refresh' both deliver the full IdentityState, including
3935
+ // state.user.metadata \u2014 populated by sibling extensions with identity:extend
3936
+ // (declared in their manifest.identityClaims). Subscribe to BOTH to cover the
3937
+ // initial login AND any post-login push via capabilities.identity.extend().
3938
+ export function Header(): React.ReactElement {
3939
+ const [user, setUser] = useState<string | null>(null)
3940
+ const [verified, setVerified] = useState(false)
3941
+
3942
+ useIdentityEvent('login', (event: IdentityEvent) => {
3943
+ setUser(event.data.state.user?.email ?? null)
3944
+ setVerified(Boolean(event.data.state.user?.metadata?.verified))
3945
+ })
3946
+
3947
+ useIdentityEvent('refresh', (event: IdentityEvent) => {
3948
+ setVerified(Boolean(event.data.state.user?.metadata?.verified))
3949
+ })
3950
+
3951
+ useIdentityEvent('logout', () => {
3952
+ setUser(null)
3953
+ setVerified(false)
3954
+ })
3955
+
3956
+ return (
3957
+ <Surface id="slot.header">
3958
+ <ui.Text className="text-xs">{user ?? 'Not logged in'} {verified && '\u2713'}</ui.Text>
3959
+ </Surface>
3960
+ )
3961
+ }`,
3962
+ "events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
3963
+ import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
3964
+ import { useState } from 'react'
3965
+
3966
+ export function Content(): React.ReactElement {
3967
+ const [lastPostback, setLastPostback] = useState<string | null>(null)
3968
+
3969
+ useMessagingEvent('postback', (event) => {
3970
+ setLastPostback(event.data.actionName)
3971
+ })
3972
+
3973
+ return (
3974
+ <Surface id="slot.content">
3975
+ <ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
3976
+ </Surface>
3977
+ )
3978
+ }`,
3979
+ "events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
3980
+ import type { ActivityEventHandler } from '@stackable-labs/sdk-extension-contracts'
3981
+ import { useState } from 'react'
3982
+
3983
+ export function Content(): React.ReactElement {
3984
+ const [lastEvent, setLastEvent] = useState<string | null>(null)
3985
+
3986
+ useActivityEvent('page_view', (event) => {
3987
+ setLastEvent(event.data.url as string)
3988
+ })
3989
+
3990
+ return (
3991
+ <Surface id="slot.content">
3992
+ <ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
3993
+ </Surface>
3994
+ )
3995
+ }`,
3996
+ "identity.extend": EXTEND_IDENTITY_HOOK_EXAMPLE
3997
+ };
3998
+
3681
3999
  // ../../sdk/extension/ai-docs/src/commands/add-capability.ts
3682
4000
  var generateAddCapabilityCommand = () => {
3683
4001
  const fm = frontmatter({
3684
- description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, extend:identity, events:identity, events:messaging, events:activity) in this extension",
4002
+ 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",
3685
4003
  targets: ["*"]
3686
4004
  });
3687
4005
  return `${fm}
@@ -3697,7 +4015,7 @@ Ask which capability to add. Valid capabilities:
3697
4015
  - \`context.read\` \u2014 read host-provided context (customerId, customerEmail, messaging.conversationId, etc.)
3698
4016
  - \`actions.toast\` \u2014 show toast notifications (success, error, info, warning)
3699
4017
  - \`actions.invoke\` \u2014 invoke host actions (e.g., open new conversation)
3700
- - \`extend:identity\` \u2014 enrich identity JWT claims before signing
4018
+ - \`identity.extend\` \u2014 enrich identity JWT claims before signing
3701
4019
  - \`events:identity\` \u2014 subscribe to identity events (login, logout, refresh, expired)
3702
4020
  - \`events:messaging\` \u2014 subscribe to messaging events (postback button clicks)
3703
4021
  - \`events:activity\` \u2014 subscribe to activity events (page views, clicks, purchases)
@@ -3709,7 +4027,7 @@ Add the corresponding permission to \`packages/extension/public/manifest.json\`:
3709
4027
  - \`context.read\` \u2192 \`"context:read"\`
3710
4028
  - \`actions.toast\` \u2192 \`"actions:toast"\`
3711
4029
  - \`actions.invoke\` \u2192 \`"actions:invoke"\`
3712
- - \`extend:identity\` \u2192 \`"extend:identity"\`
4030
+ - \`identity.extend\` \u2192 \`"identity:extend"\`
3713
4031
  - \`events:identity\` \u2192 \`"events:identity"\` (also add entries to \`events\` array, e.g. \`["identity:login", "identity:logout"]\`)
3714
4032
  - \`events:messaging\` \u2192 \`"events:messaging"\` (also add entries to \`events\` array, e.g. \`["messaging:postback:Buy Now"]\`)
3715
4033
  - \`events:activity\` \u2192 \`"events:activity"\` (also add entries to \`events\` array, e.g. \`["activity:product_view"]\`)
@@ -3751,8 +4069,14 @@ const capabilities = useCapabilities()
3751
4069
  // actions.invoke: capabilities.actions.invoke('newConversation', { tags: ['order'], fields: [{ id: 'field_id', value: 'val' }] })
3752
4070
  \`\`\`
3753
4071
 
3754
- ### For events and extend \u2014 ALWAYS use dedicated hooks (INSTEAD of useCapabilities direct):
3755
- **IMPORTANT:** Event and extend capabilities have their own React hooks. Never use \`capabilities.events.*\` or \`capabilities.extend.*\` \u2014 those do not exist.
4072
+ ### Special handling (events + identity.extend):
4073
+
4074
+ #### For events \u2014 ALWAYS use dedicated hooks (INSTEAD of useCapabilities direct):
4075
+ Events are subscribed via \`useIdentityEvent\` / \`useMessagingEvent\` / \`useActivityEvent\` \u2014 never use \`capabilities.events.*\` directly (events are not part of the \`capabilities\` object).
4076
+
4077
+ #### For identity.extend \u2014 choose the CORRECT option:
4078
+ - **\`useExtendIdentity(handler)\`** \u2014 synchronous hook, fires only once at initial login. ALWAYS use for known-at-login enrichment.
4079
+ - **\`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\`.
3756
4080
 
3757
4081
  \`\`\`tsx
3758
4082
  // events:identity \u2014 use useIdentityEvent hook
@@ -3770,8 +4094,8 @@ ${HOOK_SNIPPETS["events:activity"]}
3770
4094
  \`\`\`
3771
4095
 
3772
4096
  \`\`\`tsx
3773
- // extend:identity \u2014 use useExtendIdentity hook
3774
- ${HOOK_SNIPPETS["extend.identity"]}
4097
+ // identity.extend \u2014 login-time useExtendIdentity hook OR post-login imperative capabilities.identity.extend
4098
+ ${CAPABILITY_SNIPPETS["identity.extend"]}
3775
4099
  \`\`\`
3776
4100
 
3777
4101
  ## 6. Verify
@@ -3779,7 +4103,7 @@ ${HOOK_SNIPPETS["extend.identity"]}
3779
4103
  - If data.fetch, confirm the domain is in allowedDomains
3780
4104
  - For data/context/actions: confirm accessed via \`useCapabilities()\` hook
3781
4105
  - For events: confirm using \`useIdentityEvent\`, \`useMessagingEvent\`, or \`useActivityEvent\` hooks
3782
- - For extend: confirm using \`useExtendIdentity\` hook
4106
+ - For identity.extend: confirm using \`useExtendIdentity\` hook (login-time) and/or \`capabilities.identity.extend(patch)\` (imperative post-login)
3783
4107
  `;
3784
4108
  };
3785
4109
 
@@ -3870,12 +4194,13 @@ Scan all \`.tsx\` files in \`packages/extension/src/\` for capability usage:
3870
4194
  - \`capabilities.context.read\` or \`useContextData\` \u2192 needs \`context:read\` permission
3871
4195
  - \`capabilities.actions.toast\` \u2192 needs \`actions:toast\` permission
3872
4196
  - \`capabilities.actions.invoke\` \u2192 needs \`actions:invoke\` permission
3873
- - \`useExtendIdentity\` \u2192 needs \`extend:identity\` permission
4197
+ - \`useExtendIdentity\` or \`capabilities.identity.extend\` \u2192 needs \`identity:extend\` permission AND every custom key returned by the handler / passed to \`extend(patch)\` MUST be declared in \`manifest.identityClaims\` (standard JWT claims \`external_id\` / \`email\` / \`name\` are exempt). Undeclared keys are dropped by the host filter with a \`console.warn\`.
3874
4198
  ${eventHookBullets}
3875
4199
 
3876
4200
  Report:
3877
4201
  - **Missing permissions:** capabilities used in code but not declared in manifest
3878
4202
  - **Unused permissions:** permissions declared in manifest but not used in code
4203
+ - **Undeclared identityClaims:** keys returned from \`useExtendIdentity\` or passed to \`capabilities.identity.extend\` that are not in \`manifest.identityClaims\` (excluding the standard-claim exemption list)
3879
4204
 
3880
4205
  ## 3. Surface-to-target matching
3881
4206
  - Each \`.tsx\` file with a \`<Surface id="...">\` should have a matching target in manifest.json
@@ -4376,114 +4701,6 @@ var findRelevantSkills = (skills, query) => {
4376
4701
  });
4377
4702
  };
4378
4703
 
4379
- // ../../sdk/extension/ai-docs/src/snippets/capabilities.ts
4380
- var CAPABILITY_SNIPPETS = {
4381
- "data.query": `
4382
- const capabilities = useCapabilities()
4383
- const result = await capabilities.data.query({ path: '/your-endpoint', method: 'GET' })
4384
- `,
4385
- "data.fetch": `
4386
- const capabilities = useCapabilities()
4387
- const response = await capabilities.data.fetch('https://api.example.com/endpoint')
4388
- `,
4389
- "context.read": `
4390
- // Read host context + extension settings
4391
- const { customerId, settings } = useContextData()
4392
-
4393
- // Or use the convenience hook for settings only
4394
- const settings = useSettings()
4395
- `,
4396
- "actions.toast": `
4397
- const capabilities = useCapabilities()
4398
- capabilities.actions.toast({ type: 'success', message: 'Done!' })
4399
- `,
4400
- "actions.invoke": `
4401
- const capabilities = useCapabilities()
4402
-
4403
- // New conversation with tags and fields
4404
- await capabilities.actions.invoke('newConversation', {
4405
- tags: ['stackable', 'order-lookup'],
4406
- fields: [{ id: 'stackable_action', value: 'order_status' }],
4407
- metadata: { orderId: '12345' },
4408
- })
4409
-
4410
- // Standalone: set tags on current/next conversation
4411
- await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
4412
- `,
4413
- "events:identity": HOOK_SNIPPETS["events:identity"],
4414
- "events:messaging": HOOK_SNIPPETS["events:messaging"],
4415
- "events:activity": HOOK_SNIPPETS["events:activity"],
4416
- "extend.identity": HOOK_SNIPPETS["extend.identity"]
4417
- };
4418
- var EVENT_SNIPPETS = {
4419
- "events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
4420
- import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
4421
- import { useState } from 'react'
4422
-
4423
- export function Header(): React.ReactElement {
4424
- const [user, setUser] = useState<string | null>(null)
4425
-
4426
- useIdentityEvent('login', (event: IdentityEvent) => {
4427
- setUser(event.data.state.user?.email ?? null)
4428
- })
4429
-
4430
- useIdentityEvent('logout', () => {
4431
- setUser(null)
4432
- })
4433
-
4434
- return (
4435
- <Surface id="slot.header">
4436
- <ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
4437
- </Surface>
4438
- )
4439
- }`,
4440
- "events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
4441
- import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
4442
- import { useState } from 'react'
4443
-
4444
- export function Content(): React.ReactElement {
4445
- const [lastPostback, setLastPostback] = useState<string | null>(null)
4446
-
4447
- useMessagingEvent('postback', (event) => {
4448
- setLastPostback(event.data.actionName)
4449
- })
4450
-
4451
- return (
4452
- <Surface id="slot.content">
4453
- <ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
4454
- </Surface>
4455
- )
4456
- }`,
4457
- "events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
4458
- import type { ActivityEventHandler } from '@stackable-labs/sdk-extension-contracts'
4459
- import { useState } from 'react'
4460
-
4461
- export function Content(): React.ReactElement {
4462
- const [lastEvent, setLastEvent] = useState<string | null>(null)
4463
-
4464
- useActivityEvent('page_view', (event) => {
4465
- setLastEvent(event.data.url as string)
4466
- })
4467
-
4468
- return (
4469
- <Surface id="slot.content">
4470
- <ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
4471
- </Surface>
4472
- )
4473
- }`,
4474
- "extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
4475
- import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
4476
-
4477
- // Enrich identity JWT claims before signing.
4478
- // The host sends base claims (external_id, email, name),
4479
- // and your handler returns additional claims to merge.
4480
- // Use ExtendIdentityHandler type with useCallback for memoized handlers.
4481
- useExtendIdentity((claims) => ({
4482
- external_id: \`shopify_\${claims.external_id}\`,
4483
- loyalty_tier: 'gold',
4484
- }))`
4485
- };
4486
-
4487
4704
  // ../../sdk/extension/ai-docs/src/generated/example-snippets-jsx.ts
4488
4705
  var EXAMPLE_SNIPPETS2 = {
4489
4706
  "bootstrap": `import { createExtension } from '@stackable-labs/sdk-extension-react'
@@ -4708,32 +4925,91 @@ export function Content() {
4708
4925
  </Surface>
4709
4926
  )
4710
4927
  }`,
4711
- "extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
4928
+ "identity.extend": `import { useCapabilities, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
4712
4929
 
4713
- // Enrich identity JWT claims before signing.
4714
- // The host sends base claims (external_id, email, name),
4715
- // and your handler returns additional claims to merge.
4716
- useExtendIdentity((claims) => ({
4717
- external_id: \`shopify_\${claims.external_id}\`,
4718
- loyalty_tier: 'gold',
4719
- }))`,
4720
- "events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
4721
- import { useState } from 'react'
4930
+ // manifest.json:
4931
+ // {
4932
+ // "permissions": ["identity:extend"],
4933
+ // "identityClaims": ["verified", "verified_by", "verified_at"]
4934
+ // }
4935
+ //
4936
+ // PUSH side \u2014 capabilities.identity.extend(patch) sends new claims to the host
4937
+ // AFTER initial login. The host filters the patch against manifest.identityClaims,
4938
+ // merges into user.metadata, re-signs the JWT, and broadcasts identity:refresh.
4939
+ //
4940
+ // CONSUMER side \u2014 useContextData() already re-renders on every host-pushed
4941
+ // context update (login/logout/refresh/expired). For pure rendering, read the
4942
+ // enriched value directly from ctx.identity.user.metadata \u2014 no event listener
4943
+ // needed. Use useIdentityEvent only when you need a SIDE EFFECT (analytics,
4944
+ // cache invalidation, etc.) on a specific event.
4945
+ //
4946
+ // Standard JWT claims (external_id, email, name) are exempt from declaration.
4947
+ // Undeclared custom keys are dropped client-side with a console.warn.
4948
+ export function Content() {
4949
+ const capabilities = useCapabilities()
4950
+ const ctx = useContextData()
4951
+
4952
+ // Push: trigger after async verification (webhook, polling, user action)
4953
+ const runVerification = async () => {
4954
+ await new Promise(r => setTimeout(r, 1000)) // simulated async work
4955
+ await capabilities.identity.extend({
4956
+ verified: true,
4957
+ verified_by: 'xyzProvider',
4958
+ verified_at: new Date().toISOString(),
4959
+ })
4960
+ }
4961
+
4962
+ // Consume: read directly from ctx \u2014 re-renders automatically on identity:refresh.
4963
+ const verified = Boolean(ctx.identity?.user?.metadata?.verified)
4722
4964
 
4965
+ return (
4966
+ <Surface id="slot.content">
4967
+ <ui.Stack direction="column" gap="2" className="p-3">
4968
+ <ui.Button onClick={runVerification}>Verify</ui.Button>
4969
+ <ui.Text>Status: {verified ? 'verified' : 'unverified'}</ui.Text>
4970
+ </ui.Stack>
4971
+ </Surface>
4972
+ )
4973
+ }`,
4974
+ "events:identity": `import { Surface, ui, useContextData, useIdentityEvent } from '@stackable-labs/sdk-extension-react'
4975
+
4976
+ // useIdentityEvent \u2014 for SIDE EFFECTS on identity lifecycle events.
4977
+ // If you just want to RENDER based on identity state (e.g. show the current
4978
+ // user's email), use useContextData() \u2014 it re-renders reactively on every
4979
+ // host-pushed identity change. Use useIdentityEvent only when you need to
4980
+ // RUN imperative code on a specific event: analytics beacons, cookie writes,
4981
+ // downstream system notifications, cache invalidation, etc.
4982
+ //
4983
+ // manifest.json:
4984
+ // {
4985
+ // "permissions": ["context:read", "events:identity"],
4986
+ // "events": ["identity:login", "identity:logout"]
4987
+ // }
4723
4988
  export function Header() {
4724
- const [user, setUser] = useState(null)
4989
+ const ctx = useContextData()
4725
4990
 
4991
+ // Side effect: persist a "last login" hint to localStorage on every login.
4992
+ // Replace with your real-world side effect \u2014 analytics beacon, cookie write,
4993
+ // downstream system notification, etc.
4726
4994
  useIdentityEvent('login', (event) => {
4727
- setUser(event.data.state.user?.email ?? null)
4995
+ localStorage.setItem('last-login', JSON.stringify({
4996
+ userId: event.data.state.user?.id,
4997
+ timestamp: new Date().toISOString(),
4998
+ }))
4728
4999
  })
4729
5000
 
5001
+ // Clear the hint on logout / session expiry.
4730
5002
  useIdentityEvent('logout', () => {
4731
- setUser(null)
5003
+ localStorage.removeItem('last-login')
4732
5004
  })
4733
5005
 
5006
+ // Rendering: read directly from ctx \u2014 DON'T mirror identity into local
5007
+ // useState via the event listener; useContextData is already reactive.
5008
+ const email = ctx?.identity?.user?.email ?? null
5009
+
4734
5010
  return (
4735
5011
  <Surface id="slot.header">
4736
- <ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
5012
+ <ui.Text className="text-xs">{email ?? 'Not logged in'}</ui.Text>
4737
5013
  </Surface>
4738
5014
  )
4739
5015
  }`,
@@ -4797,11 +5073,19 @@ await capabilities.actions.invoke('newConversation', {
4797
5073
  await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])`,
4798
5074
  "events:identity": `import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
4799
5075
 
5076
+ // manifest events: ["identity:login", "identity:logout", "identity:refresh"]
4800
5077
  useIdentityEvent('login', (event) => {
4801
- console.log('User logged in:', event.data.state.user?.email)
5078
+ // event.data.state.user.metadata is populated with any enrichment from sibling
5079
+ // extensions with identity:extend (declared in their manifest.identityClaims)
5080
+ console.log('User logged in:', event.data.state.user?.email, event.data.state.user?.metadata)
4802
5081
  })
4803
5082
  useIdentityEvent('logout', () => {
4804
5083
  console.log('User logged out')
5084
+ })
5085
+ // identity:refresh fires after any extension calls capabilities.identity.extend({...}).
5086
+ // Listen here to react to post-login enrichment (verification, tier upgrades, etc.).
5087
+ useIdentityEvent('refresh', (event) => {
5088
+ console.log('Identity refreshed \u2014 metadata:', event.data.state.user?.metadata)
4805
5089
  })`,
4806
5090
  "events:messaging": `import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
4807
5091
 
@@ -4813,31 +5097,77 @@ useMessagingEvent('postback:Buy Now', (event) => {
4813
5097
  useActivityEvent('product_view', (event) => {
4814
5098
  console.log('Activity:', event.eventName, event.data)
4815
5099
  })`,
4816
- "extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
5100
+ "identity.extend": `// Enrich identity JWT claims before signing.
5101
+ // The host sends base claims (external_id, email, name); your handler returns
5102
+ // additional claims to merge. Returned keys are filtered against manifest.identityClaims:
5103
+ // - Standard JWT claims (external_id, email, name) are exempt \u2014 always allowed
5104
+ // - Custom claims must be declared in manifest.identityClaims
5105
+ // - Undeclared keys are dropped with a console.warn
5106
+ // Merged claims land in BOTH identityState.user.metadata (for sibling extensions)
5107
+ // AND the signed JWT's custom_claims (for downstream JWT consumers).
5108
+ // Pattern: name the handler with useCallback<ExtendIdentityHandler> for stable
5109
+ // reference + clean separation; pass the named const to useExtendIdentity.
5110
+ //
5111
+ // useExtendIdentity fires ONCE at initial login \u2014 return what's known synchronously.
5112
+ // For post-login async updates (e.g., after verification completes via a webhook
5113
+ // or polling), use capabilities.identity.extend(patch) \u2014 see the 'identity.extend'
5114
+ // capability for the imperative path.
4817
5115
 
4818
- useExtendIdentity((claims) => ({
4819
- external_id: \`custom_\${claims.external_id}\`,
4820
- loyalty_tier: 'gold',
4821
- }))`
5116
+ import { useCallback } from 'react'
5117
+ import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
5118
+
5119
+ // manifest.json:
5120
+ // {
5121
+ // "permissions": ["identity:extend"],
5122
+ // "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
5123
+ // }
5124
+ const handleExtend = useCallback((claims) => ({
5125
+ external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
5126
+ loyalty_tier: 'bronze', // custom \u2014 sync, known at login
5127
+ verified: false, // custom \u2014 default; updated async post-verification
5128
+ }), [])
5129
+ useExtendIdentity(handleExtend)
5130
+
5131
+ // \u2500\u2500 For post-login async push (e.g., after async verification completes): \u2500\u2500\u2500\u2500\u2500
5132
+ const capabilities = useCapabilities()
5133
+ await capabilities.identity.extend({ verified: true })
5134
+
5135
+ // Consumer side \u2014 same or sibling extension reacts via identity:refresh
5136
+ useIdentityEvent('refresh', (event) => {
5137
+ console.log('verified =>', event.data.state.user?.metadata?.verified)
5138
+ })`
4822
5139
  };
4823
5140
  var EVENT_SNIPPETS2 = {
4824
5141
  "events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
4825
5142
  import { useState } from 'react'
4826
5143
 
5144
+ // manifest events: ["identity:login", "identity:logout", "identity:refresh"]
5145
+ //
5146
+ // 'login' and 'refresh' both deliver the full IdentityState, including
5147
+ // state.user.metadata \u2014 populated by sibling extensions with identity:extend
5148
+ // (declared in their manifest.identityClaims). Subscribe to BOTH to cover the
5149
+ // initial login AND any post-login push via capabilities.identity.extend().
4827
5150
  export function Header() {
4828
5151
  const [user, setUser] = useState(null)
5152
+ const [verified, setVerified] = useState(false)
4829
5153
 
4830
5154
  useIdentityEvent('login', (event) => {
4831
5155
  setUser(event.data.state.user?.email ?? null)
5156
+ setVerified(Boolean(event.data.state.user?.metadata?.verified))
5157
+ })
5158
+
5159
+ useIdentityEvent('refresh', (event) => {
5160
+ setVerified(Boolean(event.data.state.user?.metadata?.verified))
4832
5161
  })
4833
5162
 
4834
5163
  useIdentityEvent('logout', () => {
4835
5164
  setUser(null)
5165
+ setVerified(false)
4836
5166
  })
4837
5167
 
4838
5168
  return (
4839
5169
  <Surface id="slot.header">
4840
- <ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
5170
+ <ui.Text className="text-xs">{user ?? 'Not logged in'} {verified && '\u2713'}</ui.Text>
4841
5171
  </Surface>
4842
5172
  )
4843
5173
  }`,
@@ -4873,16 +5203,36 @@ export function Content() {
4873
5203
  </Surface>
4874
5204
  )
4875
5205
  }`,
4876
- "extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
5206
+ "identity.extend": `// Enrich identity JWT claims before signing.
5207
+ // The host sends base claims (external_id, email, name); your handler returns
5208
+ // additional claims to merge. Returned keys are filtered against manifest.identityClaims:
5209
+ // - Standard JWT claims (external_id, email, name) are exempt \u2014 always allowed
5210
+ // - Custom claims must be declared in manifest.identityClaims
5211
+ // - Undeclared keys are dropped with a console.warn
5212
+ // Merged claims land in BOTH identityState.user.metadata (for sibling extensions)
5213
+ // AND the signed JWT's custom_claims (for downstream JWT consumers).
5214
+ // Pattern: name the handler with useCallback<ExtendIdentityHandler> for stable
5215
+ // reference + clean separation; pass the named const to useExtendIdentity.
5216
+ //
5217
+ // useExtendIdentity fires ONCE at initial login \u2014 return what's known synchronously.
5218
+ // For post-login async updates (e.g., after verification completes via a webhook
5219
+ // or polling), use capabilities.identity.extend(patch) \u2014 see the 'identity.extend'
5220
+ // capability for the imperative path.
4877
5221
 
4878
- // Enrich identity JWT claims before signing.
4879
- // The host sends base claims (external_id, email, name),
4880
- // and your handler returns additional claims to merge.
4881
- // Use ExtendIdentityHandler type with useCallback for memoized handlers.
4882
- useExtendIdentity((claims) => ({
4883
- external_id: \`shopify_\${claims.external_id}\`,
4884
- loyalty_tier: 'gold',
4885
- }))`
5222
+ import { useCallback } from 'react'
5223
+ import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
5224
+
5225
+ // manifest.json:
5226
+ // {
5227
+ // "permissions": ["identity:extend"],
5228
+ // "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
5229
+ // }
5230
+ const handleExtend = useCallback((claims) => ({
5231
+ external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
5232
+ loyalty_tier: 'bronze', // custom \u2014 sync, known at login
5233
+ verified: false, // custom \u2014 default; updated async post-verification
5234
+ }), [])
5235
+ useExtendIdentity(handleExtend)`
4886
5236
  };
4887
5237
 
4888
5238
  // ../../sdk/extension/ai-docs/src/index.ts