capabilities

star 0

Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke. Use when wiring up platform-mediated APIs or direct HTTP requests.

stackable-labs By stackable-labs schedule Updated 6/6/2026

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: true secret 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.json settingsSchema with "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() returns ContextData & { loading: boolean }
    • useSettings() returns Record<string, unknown> — shorthand for contextData.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 in data.fetch headers 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:read is 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)MessagingEventHandler type exported for use with useCallback
  • 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" creates reply-type buttons (no event). Use the Sunshine Conversations API with { "type": "postback", "text": "Button Label", "payload": "..." } actions to create postback buttons.
  • actionName caveat: The actionName in the event is the button's display text (e.g. "Add to cart"), NOT the postback payload string. The payload is not exposed by the Zendesk Web Widget. Design manifest events entries 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)ActivityEventHandler type exported for use with useCallback
  • 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:

  1. useExtendIdentity(handler) — synchronous hook that fires ONCE at initial login.
  2. 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, updates user.metadata, and broadcasts identity: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 identityClaims or the host filter drops them with a console.warn.
  • Reserved JWT/Zendesk keys (iss, sub, aud, exp, nbf, iat, jti, scope, email_verified, user_fields) cannot appear in identityClaims — 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)ExtendIdentityHandler type exported for use with useCallback
  • 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:

  1. useMessaging() — React hook returning tuple [send, { enabled, loading, error, data }] — 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.
  2. messagingSendCapability(payload) / capabilities.messaging.send(payload) — imperative; useful outside React render. Exposes the full wire-level error taxonomy.

Manifest contract

{
  "permissions": ["messaging:send"]
}
  • messaging:send permission — 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:

  1. instance.config.settings.messagingDisplayName — admin sets via the dashboard Instance form (visible only after OAuth completes; gated on messagingAppId being populated)
  2. 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

  1. Instance connected to a messaging provider — bearer token stored, ready to post
  2. An active conversation when send() is called (else host-handled no_conversationsend() returns null)
  3. 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:

  1. Embeddable host gate (CapabilityRPCHandler) — checks sandbox.manifest.permissions.includes('messaging:send') before the call leaves the sandbox.
  2. Backend handler gate — checks claims.permissions?.includes('messaging:send') on the proxy-token JWT.
  3. Server-side instance checkinstance.config.messagingDisconnected short-circuits with reauth_required before 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 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.

Install via CLI
npx skills add https://github.com/stackable-labs/claude-plugins --skill capabilities
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
stackable-labs
stackable-labs Explore all skills →