diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff5da43478..56b2fcf5ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -149,6 +149,7 @@ Breaking changes in this release:
- Breakpoint: open F12, select the subject in Element pane, type `$0.webChat.breakpoint.incomingActivity`
- The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), in PR [#5677](https://github.com/microsoft/BotFramework-WebChat/pull/5677) by [@OEvgeny](https://github.com/OEvgeny)
- 👷🏻 Added `npm run build-browser` script for building test harness package only, in PR [#5667](https://github.com/microsoft/BotFramework-WebChat/pull/5667), by [@compulim](https://github.com/compulim)
+- Added pull-based capabilities system for dynamically discovering adapter capabilities at runtime, in PR [#5679](https://github.com/microsoft/BotFramework-WebChat/pull/5679), by [@pranavjoshi001](https://github.com/pranavjoshi001)
### Changed
diff --git a/__tests__/html2/hooks/useCapabilities.html b/__tests__/html2/hooks/useCapabilities.html
new file mode 100644
index 0000000000..2955275abe
--- /dev/null
+++ b/__tests__/html2/hooks/useCapabilities.html
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md
new file mode 100644
index 0000000000..99f9d6c60a
--- /dev/null
+++ b/docs/CAPABILITIES.md
@@ -0,0 +1,85 @@
+# Capabilities
+
+Web Chat supports dynamic capability discovery from adapters. Capabilities allow adapters to expose configuration values that Web Chat components can consume and react to changes.
+
+## Using the hook
+
+Use the `useCapabilities` hook with a selector to access specific capabilities:
+
+```js
+import { useCapabilities } from 'botframework-webchat/hook';
+
+// Get voice configuration
+const voiceConfig = useCapabilities(caps => caps.voiceConfiguration);
+
+if (voiceConfig) {
+ console.log(`Sample rate: ${voiceConfig.sampleRate}`);
+ console.log(`Chunk interval: ${voiceConfig.chunkIntervalMs}ms`);
+}
+```
+
+> **Note:** A selector function is required. This ensures components only re-render when their specific capability changes.
+
+## Available capabilities
+
+| Capability | Type | Description |
+| -------------------- | -------------------------------------------------- | ----------------------------------- |
+| `voiceConfiguration` | `{ chunkIntervalMs: number, sampleRate: number }` | Audio settings for Speech-to-Speech |
+
+## How it works
+
+1. **Initial fetch** - When WebChat mounts, it checks if the adapter exposes capability getter functions and retrieves initial values
+2. **Event-driven updates** - When the adapter emits a `capabilitiesChanged` event, WebChat re-fetches all capabilities from the adapter
+3. **Optimized re-renders** - Only components consuming changed capabilities will re-render
+
+## For adapter implementers
+
+To expose capabilities from your adapter:
+
+### 1. Implement getter functions
+
+```js
+const adapter = {
+ // ... other adapter methods
+
+ getVoiceConfiguration() {
+ return {
+ sampleRate: 16000,
+ chunkIntervalMs: 100
+ };
+ }
+};
+```
+
+### 2. Emit change events
+
+When capability values change, emit a `capabilitiesChanged` event activity:
+
+```js
+// When configuration changes, emit the nudge event
+adapter.activity$.next({
+ type: 'event',
+ name: 'capabilitiesChanged',
+ from: { id: 'bot', role: 'bot' }
+});
+```
+
+WebChat will then call all capability getter functions and update consumers if values changed.
+
+## Adding new capabilities
+
+To add a new capability:
+
+1. Add the type to `Capabilities` in `packages/api/src/providers/Capabilities/types/Capabilities.ts`
+2. Add the registry entry in `packages/api/src/providers/Capabilities/private/capabilityRegistry.ts`
+3. Implement the getter in your adapter (e.g., `getMyCapability()`)
+
+The registry maps capability keys to getter function names:
+
+```js
+// capabilityRegistry.ts
+{
+ key: 'voiceConfiguration',
+ getterName: 'getVoiceConfiguration'
+}
+```
diff --git a/package-lock.json b/package-lock.json
index 2dc6203429..2bea880d81 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20403,6 +20403,7 @@
"react-redux": "7.2.9",
"redux": "5.0.1",
"simple-update-in": "2.2.0",
+ "use-reduce-memo": "0.1.0",
"use-ref-from": "0.1.0",
"valibot": "1.2.0"
},
diff --git a/packages/api/package.json b/packages/api/package.json
index 6e6fea218c..cc222f2439 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -155,6 +155,7 @@
"react-redux": "7.2.9",
"redux": "5.0.1",
"simple-update-in": "2.2.0",
+ "use-reduce-memo": "0.1.0",
"use-ref-from": "0.1.0",
"valibot": "1.2.0"
},
diff --git a/packages/api/src/boot/hook.ts b/packages/api/src/boot/hook.ts
index 8d6df8447f..cd0cbff82b 100644
--- a/packages/api/src/boot/hook.ts
+++ b/packages/api/src/boot/hook.ts
@@ -7,6 +7,7 @@ export {
useAvatarForUser,
useBuildRenderActivityCallback,
useByteFormatter,
+ useCapabilities,
useConnectivityStatus,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx
index abe6b47501..046b24218d 100644
--- a/packages/api/src/hooks/Composer.tsx
+++ b/packages/api/src/hooks/Composer.tsx
@@ -60,6 +60,7 @@ import ActivityListenerComposer from '../providers/ActivityListener/ActivityList
import ActivitySendStatusComposer from '../providers/ActivitySendStatus/ActivitySendStatusComposer';
import ActivitySendStatusTelemetryComposer from '../providers/ActivitySendStatusTelemetry/ActivitySendStatusTelemetryComposer';
import ActivityTypingComposer from '../providers/ActivityTyping/ActivityTypingComposer';
+import CapabilitiesComposer from '../providers/Capabilities/CapabilitiesComposer';
import GroupActivitiesComposer from '../providers/GroupActivities/GroupActivitiesComposer';
import PonyfillComposer from '../providers/Ponyfill/PonyfillComposer';
import StyleOptionsComposer from '../providers/StyleOptions/StyleOptionsComposer';
@@ -592,22 +593,24 @@ const ComposerCore = ({
return (
-
-
-
-
-
-
-
- {typeof children === 'function' ? children(context) : children}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {typeof children === 'function' ? children(context) : children}
+
+
+
+
+
+
+
+
+
{onTelemetry && }
);
diff --git a/packages/api/src/hooks/index.ts b/packages/api/src/hooks/index.ts
index f5a1a959d7..da6e0151a4 100644
--- a/packages/api/src/hooks/index.ts
+++ b/packages/api/src/hooks/index.ts
@@ -1,3 +1,4 @@
+import useCapabilities from '../providers/Capabilities/useCapabilities';
import useGroupActivities from '../providers/GroupActivities/useGroupActivities';
import useGroupActivitiesByName from '../providers/GroupActivities/useGroupActivitiesByName';
import useActiveTyping from './useActiveTyping';
@@ -83,6 +84,7 @@ export {
useAvatarForBot,
useAvatarForUser,
useByteFormatter,
+ useCapabilities,
useConnectivityStatus,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
diff --git a/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx
new file mode 100644
index 0000000000..71b500cf1a
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx
@@ -0,0 +1,79 @@
+import React, { memo, useCallback, useMemo, type ReactNode } from 'react';
+import { useReduceMemo } from 'use-reduce-memo';
+import type { WebChatActivity } from 'botframework-webchat-core';
+import { literal, object, safeParse } from 'valibot';
+
+import useActivities from '../../hooks/useActivities';
+import useWebChatAPIContext from '../../hooks/internal/useWebChatAPIContext';
+import CapabilitiesContext from './private/Context';
+import fetchCapabilitiesFromAdapter from './private/fetchCapabilitiesFromAdapter';
+import type { Capabilities } from './types/Capabilities';
+
+type Props = Readonly<{ children?: ReactNode | undefined }>;
+
+const EMPTY_CAPABILITIES: Capabilities = Object.freeze({});
+
+// Synthetic marker to trigger initial fetch - must be a stable reference
+const INIT_MARKER = Object.freeze({ type: 'capabilities:init' as const });
+type InitMarker = typeof INIT_MARKER;
+type ReducerInput = WebChatActivity | InitMarker;
+
+const CapabilitiesChangedEventSchema = object({
+ type: literal('event'),
+ name: literal('capabilitiesChanged')
+});
+
+const isInitMarker = (item: ReducerInput): item is InitMarker => item === INIT_MARKER;
+
+const isCapabilitiesChangedEvent = (activity: ReducerInput): boolean =>
+ safeParse(CapabilitiesChangedEventSchema, activity).success;
+
+/**
+ * Composer that derives capabilities from the adapter using a pure derivation pattern.
+ *
+ * Design principles:
+ * 1. Initial fetch: Pulls capabilities from adapter on mount via synthetic init marker
+ * 2. Event-driven updates: Re-fetches only when 'capabilitiesChanged' event is detected
+ * 3. Stable references: Individual capability objects maintain reference equality if unchanged
+ * - This ensures consumers using selectors only re-render when their capability changes
+ */
+const CapabilitiesComposer = memo(({ children }: Props) => {
+ const [activities] = useActivities();
+ const { directLine } = useWebChatAPIContext();
+
+ const activitiesWithInit = useMemo(
+ () => Object.freeze([INIT_MARKER, ...activities]),
+ [activities]
+ );
+
+ // TODO: [P1] update to use EventTarget than activity$.
+ const capabilities = useReduceMemo(
+ activitiesWithInit,
+ useCallback(
+ (prevCapabilities: Capabilities, item: ReducerInput): Capabilities => {
+ const shouldFetch = isInitMarker(item) || isCapabilitiesChangedEvent(item);
+
+ if (!shouldFetch) {
+ return prevCapabilities;
+ }
+
+ const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter(
+ directLine,
+ prevCapabilities
+ );
+
+ return hasChanged ? newCapabilities : prevCapabilities;
+ },
+ [directLine]
+ ),
+ EMPTY_CAPABILITIES
+ );
+
+ const contextValue = useMemo(() => Object.freeze({ capabilities }), [capabilities]);
+
+ return {children};
+});
+
+CapabilitiesComposer.displayName = 'CapabilitiesComposer';
+
+export default CapabilitiesComposer;
diff --git a/packages/api/src/providers/Capabilities/private/Context.ts b/packages/api/src/providers/Capabilities/private/Context.ts
new file mode 100644
index 0000000000..b3fc2195d2
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/private/Context.ts
@@ -0,0 +1,13 @@
+import { createContext } from 'react';
+import type { Capabilities } from '../types/Capabilities';
+
+type CapabilitiesContextType = Readonly<{
+ capabilities: Capabilities;
+}>;
+
+const CapabilitiesContext = createContext(undefined);
+
+CapabilitiesContext.displayName = 'CapabilitiesContext';
+
+export default CapabilitiesContext;
+export type { CapabilitiesContextType };
diff --git a/packages/api/src/providers/Capabilities/private/capabilityRegistry.ts b/packages/api/src/providers/Capabilities/private/capabilityRegistry.ts
new file mode 100644
index 0000000000..c0c6bdc3f6
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/private/capabilityRegistry.ts
@@ -0,0 +1,45 @@
+import type { Capabilities } from '../types/Capabilities';
+
+/**
+ * Descriptor for a capability that can be fetched from the adapter.
+ */
+export type CapabilityDescriptor = {
+ /** The key in the Capabilities object */
+ readonly key: K;
+ /** The getter function name on the adapter (e.g., 'getVoiceConfiguration') */
+ readonly getterName: string;
+ /**
+ * Custom equality comparator for this capability.
+ * If not provided, uses shallow object comparison.
+ * Return true if values are equal (should reuse previous reference).
+ */
+ readonly isEqual?: (a: Capabilities[K], b: Capabilities[K]) => boolean;
+};
+
+/**
+ * Registry of all capabilities.
+ *
+ * To add a new capability:
+ * 1. Add type to Capabilities interface in types/Capabilities.ts
+ * 2. Add entry here with key, getterName, and optional custom isEqual
+ *
+ * @example
+ * // Simple capability (uses default shallowEqual)
+ * { key: 'voiceConfiguration', getterName: 'getVoiceConfiguration' }
+ *
+ * @example
+ * // Capability with custom equality check
+ * {
+ * key: 'complexConfig',
+ * getterName: 'getComplexConfig',
+ * isEqual: (a, b) => a?.nested?.value === b?.nested?.value
+ * }
+ */
+const CAPABILITY_REGISTRY: readonly CapabilityDescriptor[] = Object.freeze([
+ {
+ key: 'voiceConfiguration',
+ getterName: 'getVoiceConfiguration'
+ }
+]);
+
+export default CAPABILITY_REGISTRY;
diff --git a/packages/api/src/providers/Capabilities/private/fetchCapabilitiesFromAdapter.ts b/packages/api/src/providers/Capabilities/private/fetchCapabilitiesFromAdapter.ts
new file mode 100644
index 0000000000..56f6e6285c
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/private/fetchCapabilitiesFromAdapter.ts
@@ -0,0 +1,59 @@
+import { DirectLineJSBotConnection, isForbiddenPropertyName } from 'botframework-webchat-core';
+import type { Capabilities } from '../types/Capabilities';
+import CAPABILITY_REGISTRY from './capabilityRegistry';
+import shallowEqual from './shallowEqual';
+
+type FetchResult = {
+ capabilities: Capabilities;
+ hasChanged: boolean;
+};
+
+/**
+ * Fetches all capabilities from the adapter based on the registry.
+ * Returns a new capabilities object with values fetched from the adapter.
+ */
+export default function fetchCapabilitiesFromAdapter(
+ directLine: DirectLineJSBotConnection,
+ prevCapabilities: Capabilities
+): FetchResult {
+ let hasChanged = false;
+
+ const entries: [string, unknown][] = [];
+
+ for (const descriptor of CAPABILITY_REGISTRY) {
+ const { key, getterName, isEqual = shallowEqual } = descriptor;
+
+ if (isForbiddenPropertyName(key) || isForbiddenPropertyName(getterName)) {
+ continue;
+ }
+
+ // eslint-disable-next-line security/detect-object-injection
+ const getter = directLine[getterName];
+
+ if (typeof getter === 'function') {
+ try {
+ const fetchedValue = getter.call(directLine);
+ // eslint-disable-next-line security/detect-object-injection
+ const prevValue = prevCapabilities[key];
+
+ if (fetchedValue) {
+ if (typeof prevValue !== 'undefined' && isEqual(prevValue, fetchedValue)) {
+ entries.push([key, prevValue]);
+ } else {
+ entries.push([key, Object.freeze({ ...fetchedValue })]);
+ hasChanged = true;
+ }
+ } else if (typeof prevValue !== 'undefined') {
+ hasChanged = true;
+ }
+ } catch (error) {
+ console.warn(`botframework-webchat: Error calling capability ${getterName}:`, error);
+ }
+ }
+ }
+
+ return {
+ capabilities: Object.freeze(Object.fromEntries(entries)) as Capabilities,
+ hasChanged
+ };
+}
diff --git a/packages/api/src/providers/Capabilities/private/shallowEqual.ts b/packages/api/src/providers/Capabilities/private/shallowEqual.ts
new file mode 100644
index 0000000000..5feaf22da4
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/private/shallowEqual.ts
@@ -0,0 +1,18 @@
+import { isForbiddenPropertyName } from 'botframework-webchat-core';
+
+// TODO: [P2] Move this file to `base` package.
+export default function shallowEqual(x, y) {
+ if (x === y) {
+ return true;
+ }
+
+ const xKeys = Object.keys(x);
+ const yKeys = Object.keys(y);
+
+ return (
+ xKeys.length === yKeys.length &&
+ // Mitigated through denylisting.
+ // eslint-disable-next-line security/detect-object-injection
+ xKeys.every(key => !isForbiddenPropertyName(key) && yKeys.includes(key) && x[key] === y[key])
+ );
+}
diff --git a/packages/api/src/providers/Capabilities/private/useContext.ts b/packages/api/src/providers/Capabilities/private/useContext.ts
new file mode 100644
index 0000000000..ccdd81e7dd
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/private/useContext.ts
@@ -0,0 +1,12 @@
+import { useContext } from 'react';
+import CapabilitiesContext, { type CapabilitiesContextType } from './Context';
+
+export default function useCapabilitiesContext(): CapabilitiesContextType {
+ const context = useContext(CapabilitiesContext);
+
+ if (!context) {
+ throw new Error('botframework-webchat internal: This hook can only be used under .');
+ }
+
+ return context;
+}
diff --git a/packages/api/src/providers/Capabilities/types/Capabilities.ts b/packages/api/src/providers/Capabilities/types/Capabilities.ts
new file mode 100644
index 0000000000..c72d96bd12
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/types/Capabilities.ts
@@ -0,0 +1,16 @@
+/**
+ * All capabilities are optional as they depend on adapter/server support.
+ */
+type Capabilities = Readonly<{
+ voiceConfiguration?: VoiceConfiguration | undefined;
+}>;
+
+/**
+ * Optional for adapter/server to provide these configs for speech-to-speech.
+ */
+type VoiceConfiguration = Readonly<{
+ chunkIntervalMs: number;
+ sampleRate: number;
+}>;
+
+export type { Capabilities, VoiceConfiguration };
diff --git a/packages/api/src/providers/Capabilities/useCapabilities.ts b/packages/api/src/providers/Capabilities/useCapabilities.ts
new file mode 100644
index 0000000000..3787843dd7
--- /dev/null
+++ b/packages/api/src/providers/Capabilities/useCapabilities.ts
@@ -0,0 +1,25 @@
+import { useMemo, useRef } from 'react';
+import useCapabilitiesContext from './private/useContext';
+import type { Capabilities } from './types/Capabilities';
+
+/**
+ * Hook to access adapter capabilities with a selector function.
+ * Only triggers re-render when the selected value changes (shallow comparison).
+ *
+ * @example
+ * // Get voice configuration only
+ * const voiceConfig = useCapabilities(caps => caps.voiceConfiguration);
+ */
+export default function useCapabilities(selector: (capabilities: Capabilities) => T): T {
+ const { capabilities } = useCapabilitiesContext();
+ const selectedValue = selector(capabilities);
+ const prevSelectedValueRef = useRef(selectedValue);
+
+ return useMemo(() => {
+ if (Object.is(prevSelectedValueRef.current, selectedValue)) {
+ return prevSelectedValueRef.current;
+ }
+ prevSelectedValueRef.current = selectedValue;
+ return selectedValue;
+ }, [selectedValue]);
+}
diff --git a/packages/bundle/src/boot/actual/hook/minimal.ts b/packages/bundle/src/boot/actual/hook/minimal.ts
index 69eadf421d..616f8e59ef 100644
--- a/packages/bundle/src/boot/actual/hook/minimal.ts
+++ b/packages/bundle/src/boot/actual/hook/minimal.ts
@@ -7,6 +7,7 @@ export {
useAvatarForUser,
useBuildRenderActivityCallback,
useByteFormatter,
+ useCapabilities,
useConnectivityStatus,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
diff --git a/packages/component/src/boot/hook.ts b/packages/component/src/boot/hook.ts
index 95dd2ec761..91976e66fb 100644
--- a/packages/component/src/boot/hook.ts
+++ b/packages/component/src/boot/hook.ts
@@ -7,6 +7,7 @@ export {
useAvatarForUser,
useBuildRenderActivityCallback,
useByteFormatter,
+ useCapabilities,
useConnectivityStatus,
useCreateActivityRenderer,
useCreateActivityStatusRenderer,
diff --git a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js
index 5ff5cca2b0..3c81bfac9e 100644
--- a/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js
+++ b/packages/test/page-object/src/globals/testHelpers/createDirectLineEmulator.js
@@ -103,10 +103,43 @@ export default function createDirectLineEmulator({ autoConnect = true, ponyfill
autoConnect && connectedWithResolvers.resolve();
+ // Generic capabilities storage
+ const capabilities = new Map();
+
+ // Helper to emit capabilitiesChanged event
+ const emitCapabilitiesChangedEvent = () => {
+ activityDeferredObservable.next({
+ from: { id: 'bot', role: 'bot' },
+ id: uniqueId(),
+ name: 'capabilitiesChanged',
+ timestamp: getTimestamp(),
+ type: 'event'
+ });
+ };
+
const directLine = {
activity$: shareObservable(activityDeferredObservable.observable),
actPostActivity,
connectionStatus$: shareObservable(connectionStatusDeferredObservable.observable),
+
+ /**
+ * Generic capability setter - dynamically creates getter on directLine object.
+ *
+ * @example
+ * directLine.setCapability('getVoiceConfiguration', { voice: 'en-US', speed: 1.0 });
+ * directLine.setCapability('getSessionInfo', { sessionId: '123' }, { emitEvent: false });
+ */
+ setCapability: (getterName, value, { emitEvent = true } = {}) => {
+ capabilities.set(getterName, value);
+
+ // Dynamically add/update getter on directLine object
+ // eslint-disable-next-line security/detect-object-injection
+ directLine[getterName] = () => capabilities.get(getterName);
+
+ if (emitEvent) {
+ emitCapabilitiesChangedEvent();
+ }
+ },
end: () => {
// This is a mock and will no-op on dispatch().
},