name: capabilities description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke. Use when wiring up platform-mediated APIs or direct HTTP requests."
Capabilities
Access capabilities via the useCapabilities() hook:
const capabilities = useCapabilities()
data.query — Platform-Mediated Requests
The Stackable platform handles the API call. Extension sends an action name + params, the platform returns data.
- Permission required:
data:query - Usage:
capabilities.data.query<T>(payload: ApiRequest): Promise<T> - ApiRequest shape:
{ action: string; [key: string]: unknown } - When to use: When the platform handles the API integration
const result = await capabilities.data.query<Customer>({
action: 'getCustomer',
customerId: '123',
})
data.fetch — HTTP Requests to External APIs
Make HTTP requests to external APIs. Domain must be in allowedDomains in manifest. Requests are proxied through the Stackable platform server.
- Permission required:
data:fetch - Usage:
capabilities.data.fetch(url: string, init?: FetchRequestInit): Promise<FetchResponse> - FetchRequestInit:
{ method?: 'GET'|'POST'|'PUT'|'PATCH'|'DELETE', headers?: Record<string,string>, body?: unknown } - FetchResponse:
{ status: number, ok: boolean, data: unknown } - When to use: When the extension calls external APIs directly
const result = await capabilities.data.fetch('https://api.example.com/data', {
method: 'GET',
headers: { 'Authorization': 'Bearer token' },
})
if (!result.ok) throw new Error(`Request failed: ${result.status}`)
const data = result.data as MyType
Secret injection via {{settings.xxx}} placeholders
For API keys and tokens stored as secret: true fields in settingsSchema, use template placeholders in header values. The proxy resolves them server-side — the real secret never enters extension code.
const result = await capabilities.data.fetch('https://api.example.com/orders', {
method: 'GET',
headers: {
'X-API-Key': '{{settings.apiKey}}',
'Authorization': 'Bearer {{settings.token}}',
},
})
- Placeholders are only allowed in header values (not URLs, header names, or body)
- For
required: truesecret fields, the proxy returns 400 if the value is not configured - For optional secret fields, the entire header is omitted if the value is not configured
- Declare secret fields in your
manifest.jsonsettingsSchemawith"secret": true
See Instance Settings for the full schema-declaration + storage-mode story, including which field types accept
secret: true.
context.read — Read Platform Context
Read framework-provided context (customer ID, email, messaging conversation, extension settings, etc.).
- Permission required:
context:read - Usage:
capabilities.context.read(): Promise<ContextData> - ContextData shape:
{ customerId?: string, customerEmail?: string, messaging?: { conversationId?: string | null, appId?: string | null }, settings?: Record<string, unknown>, [key: string]: unknown } - Convenience hooks:
useContextData()returnsContextData & { loading: boolean }useSettings()returnsRecord<string, unknown>— shorthand forcontextData.settings ?? {}
// Read all context (customer + messaging + settings)
const { loading, customerId, customerEmail, messaging, settings } = useContextData()
const conversationId = messaging?.conversationId
// Read only extension settings (convenience)
const settings = useSettings()
const apiBaseUrl = settings.baseUrl as string
// Alternative: use the capability directly
const context = await capabilities.context.read()
Messaging context
messaging.conversationId is the active Messaging conversation ID, or null until the widget has an open conversation. Use this when you need the ID for an API call from a non-event surface. If you're already reacting to a postback button click, prefer reading event.data.conversationId from useMessagingEvent — it doesn't require context:read.
Extension settings in context
Non-secret settings declared in settingsSchema are automatically available via contextData.settings. Values are scoped to the calling extension on the current instance — an extension never sees other extensions' settings.
- Secret fields are never included in context — use
{{settings.xxx}}placeholders indata.fetchheaders instead - Settings propagate on page load — changes made in the admin dashboard take effect on the next page reload, not mid-session
- No new permission needed —
context:readis the only gate
actions.toast — Show Toast Notifications
Display a toast notification in the framework widget's UI.
- Permission required:
actions:toast - Usage:
capabilities.actions.toast(payload: ToastPayload): Promise<void> - ToastPayload:
{ message: string, type?: 'success'|'error'|'info'|'warning', duration?: number }
capabilities.actions.toast({ message: 'Saved!', type: 'success' })
actions.invoke — Invoke Platform Actions
Trigger framework-defined actions (e.g., open a new conversation, set conversation tags/fields).
- Permission required:
actions:invoke - Usage:
capabilities.actions.invoke<T>(action: string, payload?: Record<string, unknown>): Promise<T> - Available actions:
'newConversation'— start a new Messaging conversation (optionally with tags/fields)'setConversationTags'— set tags on the current/next conversation'setConversationFields'— set custom fields on the current/next conversation'open'/'close'/'show'/'hide'— control the Zendesk messenger widget
// New conversation with tags and fields
await capabilities.actions.invoke('newConversation', {
tags: ['stackable', 'order-lookup'],
fields: [{ id: 'stackable_action', value: 'order_status' }],
metadata: { orderId: '12345' },
})
// Standalone: set tags on current/next conversation
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
// Standalone: set custom fields
await capabilities.actions.invoke('setConversationFields', [
{ id: 'order_status', value: 'shipped' },
])
Zendesk constraints: Tags max 20, auto-lowercased/sanitized. Fields require web_widget_conversation_ticket_metadata feature flag. Both conversationTags and conversationFields replace on each call (not additive).
events:identity — Identity Event Subscription
Subscribe to real-time identity events (login, logout, refresh, expired) pushed from the host via the framework.
- Permission required:
events:identity - Manifest events array: Declare specific events to listen for (e.g.
["identity:login", "identity:logout"]) - Hook:
useIdentityEvent(eventType, handler) - Event types:
'login' | 'logout' | 'refresh' | 'expired'
{
"permissions": ["events:identity"],
"events": ["identity:login", "identity:logout"]
}
import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
// manifest events: ["identity:login", "identity:logout", "identity:refresh"]
useIdentityEvent('login', (event) => {
// event.data.state.user.metadata is populated with any enrichment from sibling
// extensions with identity:extend (declared in their manifest.identityClaims)
console.log('User logged in:', event.data.state.user?.email, event.data.state.user?.metadata)
})
useIdentityEvent('logout', () => {
console.log('User logged out')
})
// identity:refresh fires after any extension calls capabilities.identity.extend({...}).
// Listen here to react to post-login enrichment (verification, tier upgrades, etc.).
useIdentityEvent('refresh', (event) => {
console.log('Identity refreshed — metadata:', event.data.state.user?.metadata)
})
Note: Identity state is also available via context.read() → identity field (requires context:read, no separate permission needed).
events:messaging — Messaging Event Subscription
Subscribe to messaging events (e.g. postback button clicks) pushed from the host widget.
- Permission required:
events:messaging - Manifest events array: Declare specific events to listen for (e.g.
["messaging:postback:Buy Now"]) or"messaging:postback"for all postbacks (requires elevated marketplace review) - Hook:
useMessagingEvent(eventType, handler)—MessagingEventHandlertype exported for use withuseCallback - Event types:
'postback'(all postbacks) or'postback:<actionName>'(specific postback) - Important: Only
postback-type buttons fire this event. The Zendesk bot builder's "Present options" createsreply-type buttons (no event). Use the Sunshine Conversations API with{ "type": "postback", "text": "Button Label", "payload": "..." }actions to create postback buttons. - actionName caveat: The
actionNamein the event is the button's display text (e.g."Add to cart"), NOT the postbackpayloadstring. The payload is not exposed by the Zendesk Web Widget. Design manifesteventsentries to match button text:"messaging:postback:Add to cart".
{
"permissions": ["events:messaging"],
"events": ["messaging:postback:Add to cart", "messaging:postback:Check order"]
}
import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
useMessagingEvent('postback:Buy Now', (event) => {
console.log('Postback:', event.data.actionName, event.data.conversationId)
})
events:activity — Activity Event Subscription
Subscribe to activity events (e.g. page views, clicks, purchases) pushed from the host via the framework.
- Permission required:
events:activity - Manifest events array: Declare specific events to listen for (e.g.
["activity:product_view", "activity:add_to_cart"]) — manifest uses fully-qualified strings - Hook:
useActivityEvent(eventType, handler)—ActivityEventHandlertype exported for use withuseCallback - Event types (domain-stripped):
'click' | 'page_view' | 'form_submit' | 'product_view' | 'add_to_cart' | 'purchase' | 'search' | '*' - Well-known event names:
| Event | Example payload fields |
|---|---|
page_view |
{ url, title, referrer } |
click |
{ elementId, elementText, url } |
product_view |
{ productId, productName, price } |
add_to_cart |
{ productId, quantity, price } |
purchase |
{ orderId, total, currency, items } |
search |
{ query, resultCount } |
form_submit |
{ formId, formName, fields } |
'*'receives ALL activity events
{
"permissions": ["events:activity"],
"events": ["activity:product_view", "activity:add_to_cart"]
}
import { useActivityEvent } from '@stackable-labs/sdk-extension-react'
useActivityEvent('product_view', (event) => {
console.log('Activity:', event.eventName, event.data)
})
Generic alternative: useEvent('activity:product_view', handler) — a cross-domain hook that accepts fully-qualified event types. Domain wildcard (e.g., 'activity') receives all events in that domain.
identity.extend — Identity Claim Enrichment
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:
useExtendIdentity(handler)— synchronous hook that fires ONCE at initial login.capabilities.identity.extend(patch)— imperative call that fires at any time after login (post-verification, post-checkout, any user-triggered async flow). Re-signs the JWT, updatesuser.metadata, and broadcastsidentity:refresh.
Both share the identity:extend permission and the manifest.identityClaims declaration gate.
Manifest contract
{
"permissions": ["identity:extend"],
"identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
}
- Standard JWT claims (
external_id,email,name) are exempt — they're part of the signing contract and may be overridden without declaration. - Custom keys MUST be declared in
identityClaimsor the host filter drops them with aconsole.warn. - Reserved JWT/Zendesk keys (
iss,sub,aud,exp,nbf,iat,jti,scope,email_verified,user_fields) cannot appear inidentityClaims— the Lambda sanitizer always wins on collision. - Key format:
/^[a-z_][a-z0-9_]{0,63}$/(lowercase identifier, ≤64 chars). - Maximum 20 entries.
Initial-login enrichment (handler-style)
- Hook:
useExtendIdentity(handler)—ExtendIdentityHandlertype exported for use withuseCallback - Handler signature:
(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>> - IdentityBaseClaims:
{ external_id: string, email?: string, name?: string, [key: string]: unknown }
import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
// manifest.json:
// {
// "permissions": ["identity:extend"],
// "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
// }
// Standard JWT claims (external_id, email, name) are exempt from declaration.
// Custom claims must be in manifest.identityClaims or they're dropped with a warn.
//
// Fires ONCE at initial login — return what's known synchronously. For post-login
// async updates (e.g., after verification completes via a webhook or polling),
// use capabilities.identity.extend(patch) — see the 'identity.extend' capability.
useExtendIdentity((claims) => ({
external_id: `custom_${claims.external_id}`, // standard claim override (exempt)
loyalty_tier: 'bronze', // custom — sync, known at login
verified: false, // custom — default; updated async post-verification
}))
Async post-login push (imperative)
const capabilities = useCapabilities()
// Anytime after login — webhook callback, user action, async verification, etc.
await capabilities.identity.extend({
verified: true,
verified_by: 'xyzProvider',
verified_at: new Date().toISOString(),
})
// → host filters against manifest.identityClaims
// → user.metadata updated
// → JWT re-signed, pushed to Zendesk loginUser
// → identity:refresh broadcast to all extensions with events:identity
Per-extension calls are serialized to prevent concurrent loginUser callbacks from orphaning each other.
Consuming enriched state from another extension
Any extension with events:identity permission can react to enrichment updates. Same minimal pattern as the events:identity section above — 'login' covers the initial enriched state, 'refresh' covers post-login pushes:
import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
// manifest events: ["identity:login", "identity:logout", "identity:refresh"]
useIdentityEvent('login', (event) => {
// event.data.state.user.metadata is populated with any enrichment from sibling
// extensions with identity:extend (declared in their manifest.identityClaims)
console.log('User logged in:', event.data.state.user?.email, event.data.state.user?.metadata)
})
useIdentityEvent('logout', () => {
console.log('User logged out')
})
// identity:refresh fires after any extension calls capabilities.identity.extend({...}).
// Listen here to react to post-login enrichment (verification, tier upgrades, etc.).
useIdentityEvent('refresh', (event) => {
console.log('Identity refreshed — metadata:', event.data.state.user?.metadata)
})
For a snapshot read instead of event-driven reaction (auto re-renders when context changes):
const { identity } = useContextData()
const verified = Boolean(identity?.user?.metadata?.verified)
Install-time enforcement
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).
Bundle-scan findings at upload
The publisher-side bundle scan validates your declaration at submission time:
| Finding | Severity | Triggers when |
|---|---|---|
identityClaims_missing |
warning | identity:extend declared, identityClaims empty (custom claims would be dropped) |
identityClaims_no_permission |
warning | identityClaims declared, identity:extend permission missing |
identityClaims_invalid_key |
error | Key fails the format regex |
identityClaims_reserved_key |
error | Collides with a reserved JWT / Zendesk claim |
identityClaims_standard_key |
warning | Redundant — standard claims (external_id, email, name) are exempt |
identityClaims_too_many |
error | More than 20 entries |
messaging.send — Send Messages to Conversations
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:
useMessaging()— React hook returning tuple[send, { enabled, loading, error, data }]— lets callers renamesendper-instance when used multiple times in one component. Preferred for UI components; narrows the error surface to actionable codes only. Useenabledto pre-empt the silentno_conversationcase without declaringcontext:read.messagingSendCapability(payload)/capabilities.messaging.send(payload)— imperative; useful outside React render. Exposes the full wire-level error taxonomy.
Manifest contract
{
"permissions": ["messaging:send"]
}
messaging:sendpermission — without it the host gate rejects the call before any network request.
Author label (host-resolved, per-Instance)
Extensions do not set the author label. The host resolves it from:
instance.config.settings.messagingDisplayName— admin sets via the dashboard Instance form (visible only after OAuth completes; gated onmessagingAppIdbeing populated)- Fallback:
extension.manifest.name— used when admin hasn't set a label
This prevents impersonation (extension A can't author as extension B) and gives admins per-deployment branding control.
Runtime requirements
- Instance connected to a messaging provider — bearer token stored, ready to post
- An active conversation when
send()is called (else host-handledno_conversation—send()returnsnull) - Instance not disconnected from the messaging provider (else host-handled
reauth_required— admin reconnect surfaced in dashboard)
Payload — discriminated by kind
Four kinds:
| kind | Required fields | Optional |
|---|---|---|
'text' |
body: string |
actions?: MessageItemAction[], metadata?, htmlText?, markdownText?, disableUserInput? |
'image' |
url: string, altText: string |
body?, actions?, metadata? |
'file' |
url: string, altText: string |
body?, metadata? (no actions — file kind doesn't accept item-level actions) |
'carousel' |
items: [MessageItem, ...MessageItem[]] (1–10 items, each with title, description?, imageUrl?, actions?) |
displaySettings?: { imageAspectRatio?: 'square' | 'horizontal' } |
Action types (per-item or per-message): reply (quick-reply button — emits postback to useMessagingEvent), link (opens URL), postback (custom payload to your handler), locationRequest (asks user for location).
Hook usage
import { useMessaging } from '@stackable-labs/sdk-extension-react'
const [send, { enabled, loading, error }] = useMessaging()
const onApprove = async () => {
try {
await send({
kind: 'text',
body: 'Order approved ✓',
actions: [{ type: 'reply', label: 'Got it', payload: 'ACK' }],
})
} catch {
// error holds the typed SendMessageActionableErrorCode
}
}
return (
<button disabled={!enabled || loading} onClick={onApprove}>
{error === 'rate_limited' ? 'Slow down…' : 'Approve'}
</button>
)
The enabled flag is a permission-free, host-pushed signal — 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.
Imperative usage
const capabilities = useCapabilities()
const result = await capabilities.messaging.send({ kind: 'text', body: 'Hello' })
// result is either { messageId, receivedAt } OR { error: SendMessageErrorCode }
Typed error codes — split by who acts
The wire taxonomy has 6 codes. The hook (useMessaging) narrows them into two groups:
Actionable (extension catches + renders UI) — send() throws and state.error is populated:
| Code | When |
|---|---|
invalid_message |
Payload failed validation (e.g. empty body for text, carousel >10 items, list kind sent) |
rate_limited |
Upstream rate limit hit — back off + retry |
upstream_error |
Provider returned 5xx — transient; retry with backoff |
Host-handled (extension ignores) — send() resolves to null, state.error stays null, SDK logs a breadcrumb:
| Code | What the framework does |
|---|---|
no_conversation |
console.info — pre-empt via the enabled flag from useMessaging() (no permission needed) |
reauth_required |
console.warn — admin sees a "Reconnect" CTA in the dashboard (server flips messagingDisconnected: true) |
forbidden |
console.warn — should not reach in production with correct manifest |
The imperative messagingSendCapability path returns the full taxonomy without the actionable/host-handled split — useful when you need every code (e.g. dynamic dispatch).
Defense-in-depth gates
Three gates fire in series, surfacing the same forbidden error if any blocks:
- Embeddable host gate (
CapabilityRPCHandler) — checkssandbox.manifest.permissions.includes('messaging:send')before the call leaves the sandbox. - Backend handler gate — checks
claims.permissions?.includes('messaging:send')on the proxy-token JWT. - Server-side instance check —
instance.config.messagingDisconnectedshort-circuits withreauth_requiredbefore any upstream call.
Receiving replies — pair with events:messaging
reply and postback action clicks fire on extensions with the events:messaging permission via useMessagingEvent — see the events:messaging section above.
Note: the postback
payloadfield is currently not surfaced by the Web Widget (only the button textactionName). Until that gap is closed, design action labels to be self-describing or pair sends with a follow-updata.fetchlookup.