diff --git a/examples/html-widgets/README.md b/examples/html-widgets/README.md
new file mode 100644
index 000000000..5b42ad77a
--- /dev/null
+++ b/examples/html-widgets/README.md
@@ -0,0 +1,62 @@
+# HTML Widgets Example
+
+This example bot demonstrates the HTML widget contract for Teams bots using the Teams SDK.
+
+## What it tests
+
+| Command | Purpose | Widget callbacks |
+|---------|---------|-----------------|
+| `/simple` | Static widget rendering | None |
+| `/calltool` | Widget calling bot tools | `htmlwidget/calltool` invoke |
+| `/messageback` | Widget sending messageBack | `onMessage` |
+| `/fullscreen` | Widget requesting display mode change | `onRequestDisplayMode` (client-side) |
+| `/multi` | Multiple tool dispatch | `htmlwidget/calltool` with different tool names |
+| `/raw` | Direct markdown helper usage | None |
+| `/permissions` | Widget requesting permissions | None (host-enforced) |
+
+## Architecture
+
+```
+Bot sends message:
+ textFormat: 'extendedmarkdown'
+ text: "...\n```html-widget\n{JSON payload}\n```"
+
+Teams client:
+ McpWidgetRenderer loads widget HTML in sandboxed iframe
+ @modelcontextprotocol/ext-apps SDK provides callServerTool API
+
+Widget calls tool:
+ ext-apps SDK -> postMessage -> McpWidgetRenderer -> htmlwidget/calltool invoke -> Bot
+
+Bot returns:
+ { status: 200, body: { content: [...], structuredContent: {...}, isError: false } }
+```
+
+## Running
+
+1. Copy credentials:
+ ```
+ cp ../../../bots/.env .env
+ ```
+
+2. Start a devtunnel and update the Azure Bot endpoint
+
+3. Run the bot:
+ ```
+ npm run dev
+ ```
+
+4. In Teams, message the bot with `/help` to see available commands
+
+## Note on widget HTML
+
+The widget HTML in `src/widgets/` is static HTML for rendering verification.
+Interactive behavior (callTool, messageBack, displayMode) requires the
+`@modelcontextprotocol/ext-apps` SDK which is provided by the Teams widget host
+page (`mcpwidget.html` on `widget-renderer.usercontent.microsoft`).
+
+The bot-side code (`widget.callTool` handler in `src/index.ts`) is the primary
+test target. It verifies that:
+- The SDK's invoke route alias correctly matches `htmlwidget/calltool`
+- The typed handler receives `{ name, arguments }` in `activity.value`
+- The response body format (`McpUiCallToolResult`) is accepted by the client
diff --git a/examples/html-widgets/eslint.config.js b/examples/html-widgets/eslint.config.js
new file mode 100644
index 000000000..5ccf8112f
--- /dev/null
+++ b/examples/html-widgets/eslint.config.js
@@ -0,0 +1 @@
+module.exports = require('@microsoft/teams.config/eslint.config').default;
diff --git a/examples/html-widgets/package.json b/examples/html-widgets/package.json
new file mode 100644
index 000000000..30685e433
--- /dev/null
+++ b/examples/html-widgets/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@examples/html-widgets",
+ "version": "0.0.1",
+ "private": true,
+ "license": "MIT",
+ "main": "dist/index",
+ "types": "dist/index",
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "scripts": {
+ "clean": "npx rimraf ./dist",
+ "lint": "npx eslint",
+ "lint:fix": "npx eslint --fix",
+ "build": "npx tsc",
+ "start": "node .",
+ "dev": "tsx watch -r dotenv/config src/index.ts"
+ },
+ "dependencies": {
+ "@microsoft/teams.apps": "*"
+ },
+ "devDependencies": {
+ "@microsoft/teams.config": "*",
+ "@types/node": "^22.5.4",
+ "dotenv": "^16.4.5",
+ "rimraf": "^6.0.1",
+ "tsx": "^4.20.6",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/examples/html-widgets/src/index.ts b/examples/html-widgets/src/index.ts
new file mode 100644
index 000000000..08e9edae9
--- /dev/null
+++ b/examples/html-widgets/src/index.ts
@@ -0,0 +1,358 @@
+/**
+ * HTML Widgets Example Bot
+ *
+ * This example demonstrates the full HTML widget capabilities for Teams bots.
+ * Each command shows a different widget feature that developers can use as
+ * a reference for building their own widget-enabled bots.
+ */
+
+import { IMcpUiCallToolResult } from '@microsoft/teams.api';
+import { App, buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, validateSecurityPolicy } from '@microsoft/teams.apps';
+import { ConsoleLogger } from '@microsoft/teams.common';
+
+import { CALLTOOL_WIDGET_HTML } from './widgets/calltool';
+import { FULLSCREEN_WIDGET_HTML } from './widgets/fullscreen';
+import { HOST_CONTEXT_WIDGET_HTML } from './widgets/host-context';
+import { MESSAGEBACK_WIDGET_HTML } from './widgets/messageback';
+import { MULTI_WIDGET_HTML } from './widgets/multi-tool';
+import { OPEN_LINK_WIDGET_HTML } from './widgets/open-link';
+
+import { SIMPLE_WIDGET_HTML } from './widgets/simple';
+import { UPDATE_CONTEXT_WIDGET_HTML } from './widgets/update-context';
+
+const app = new App({
+ logger: new ConsoleLogger('@examples/html-widgets', { level: 'debug' }),
+});
+
+// ---------------------------------------------------------------------------
+// Simple static widget - no callbacks
+// Shows the minimal code to send an HTML widget.
+// ---------------------------------------------------------------------------
+app.on('message', async ({ send, activity }) => {
+ if (!activity.text) return;
+ const text = activity.text.trim().toLowerCase();
+
+ if (text === '/simple') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'Simple Widget',
+ description: 'A static HTML widget with no callbacks.',
+ html: SIMPLE_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ securityPolicy: {
+ connectDomains: [],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ },
+ permissions: {},
+ },
+ { before: 'Here is a simple static widget:', after: 'No callbacks needed for static content.' }
+ );
+ await send(message);
+
+ // Alternative: use buildHtmlWidgetMarkdown for more control over the activity
+ // const markdown = buildHtmlWidgetMarkdown(payload, { before: '...' });
+ // await send({ type: 'message', text: markdown, textFormat: 'extendedmarkdown' });
+ return;
+ }
+
+ // Widget with onCallTool callback
+ // The widget calls tools on the bot and re-renders with the result.
+ if (text === '/calltool') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'CallTool Widget',
+ description: 'Widget that calls tools on the bot.',
+ html: CALLTOOL_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ securityPolicy: {
+ connectDomains: ['https://teams.microsoft.com', 'https://teams.cloud.microsoft.com'],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ },
+ toolInput: { demo: true },
+ toolOutput: {
+ content: [{ type: 'text', text: 'Initial data loaded.' }],
+ structuredContent: { counter: 0, lastAction: 'init' },
+ isError: false,
+ },
+ permissions: {},
+ },
+ { before: 'Here is a widget with callTool support (click Refresh):' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Widget with onMessage (messageBack) callback
+ // Tests that the widget can send messageBack to the bot.
+ if (text === '/messageback') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'MessageBack Widget',
+ description: 'Widget that sends messageBack to the bot.',
+ html: MESSAGEBACK_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ securityPolicy: {
+ connectDomains: [],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ },
+ permissions: {},
+ },
+ { before: 'This widget tests the onMessage (messageBack) callback:' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Widget requesting fullscreen display mode
+ // Tests onRequestDisplayMode with "fullscreen" value.
+ if (text === '/fullscreen') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'Fullscreen Widget',
+ description: 'Widget that requests fullscreen mode.',
+ html: FULLSCREEN_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ securityPolicy: {
+ connectDomains: [],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ },
+ permissions: {},
+ },
+ { before: 'This widget will request fullscreen mode:' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Widget with multiple tools
+ // Tests that calltool dispatches correctly by tool name.
+ if (text === '/multi') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'Multi-Tool Widget',
+ description: 'Widget that calls multiple different tools.',
+ html: MULTI_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ securityPolicy: {
+ connectDomains: ['https://teams.microsoft.com'],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ },
+ toolInput: {},
+ toolOutput: {
+ content: [{ type: 'text', text: 'Ready.' }],
+ structuredContent: { tools: ['getTime', 'roll', 'echo'] },
+ isError: false,
+ },
+ permissions: {},
+ },
+ { before: 'This widget has multiple tools to test dispatch:' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Widget using ui/open-link
+ if (text === '/openlink') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'open-link-test',
+ html: OPEN_LINK_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ },
+ { before: 'Widget with ui/open-link support (click a button to open a URL):' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Widget using ui/update-model-context
+ if (text === '/context') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'update-context-test',
+ html: UPDATE_CONTEXT_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ },
+ { before: 'Widget with ui/update-model-context support:' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Host context inspector - shows hostContext from ui/initialize
+ if (text === '/hostcontext') {
+ const message = buildHtmlWidgetMessage(
+ {
+ type: 'widget/mcp-ui',
+ name: 'host-context-inspector',
+ html: HOST_CONTEXT_WIDGET_HTML,
+ domain: 'https://teams.microsoft.com',
+ },
+ { before: 'Widget that inspects hostContext from ui/initialize:' }
+ );
+ await send(message);
+ return;
+ }
+
+ // Security policy validation
+ // Demonstrates validateSecurityPolicy catching mismatched references.
+ // This is a dev-time audit tool, not a security boundary - the browser's
+ // CSP enforcement is the real protection. Use debugCspViolations for runtime.
+ if (text === '/validate') {
+ const htmlWithExternalRefs = `
+
+
+
Validation Demo
+
This widget was validated before sending.
+
`;
+
+ // Step 1: validate against a restrictive policy to catch issues
+ const strictPolicy = {
+ connectDomains: [],
+ resourceDomains: ['\'self\'', 'data:'],
+ frameDomains: [],
+ baseUriDomains: [],
+ };
+ const warnings = validateSecurityPolicy(htmlWithExternalRefs, strictPolicy);
+
+ // Step 2: fix the policy based on warnings, then build the widget
+ const correctedPolicy = {
+ ...strictPolicy,
+ resourceDomains: [...strictPolicy.resourceDomains, 'https://fonts.googleapis.com'],
+ };
+ const warningText = warnings
+ .map((w) => `- **${w.source}**: \`${w.url}\` not in \`${w.policyField}\``)
+ .join('\n');
+ const markdown = buildHtmlWidgetMarkdown(
+ {
+ type: 'widget/mcp-ui',
+ name: 'Validated Widget',
+ description: 'Widget built after security policy validation.',
+ html: htmlWithExternalRefs,
+ domain: 'https://teams.microsoft.com',
+ securityPolicy: correctedPolicy,
+ },
+ {
+ before:
+ `**Validation found ${warnings.length} warning(s):**\n\n` +
+ warningText + '\n\n' +
+ 'Policy was corrected before sending:',
+ }
+ );
+ await send({ type: 'message', text: markdown, textFormat: 'extendedmarkdown' });
+ return;
+ }
+
+ // Default: show help
+ if (text === '/help' || text === 'help') {
+ await send({
+ type: 'message',
+ textFormat: 'markdown',
+ text:
+ '**HTML Widget Test Commands:**\n\n' +
+ '- `/simple` - Static widget (no callbacks)\n' +
+ '- `/calltool` - Widget with onCallTool\n' +
+ '- `/messageback` - Widget with onMessage\n' +
+ '- `/fullscreen` - Widget requesting fullscreen\n' +
+ '- `/multi` - Widget with multiple tools\n' +
+ '- `/openlink` - Widget with ui/open-link\n' +
+ '- `/context` - Widget with ui/update-model-context\n' +
+ '- `/hostcontext` - Inspect hostContext from initialize\n' +
+ '- `/validate` - Security policy validation demo\n' +
+ '- `/help` - This message',
+ });
+ return;
+ }
+
+ // Handle messageBack values from the messageback widget
+ if (activity.value) {
+ await send(`Received messageBack value: ${JSON.stringify(activity.value)}`);
+ return;
+ }
+
+ await send('Send `/help` for available widget test commands.');
+});
+
+// ---------------------------------------------------------------------------
+// Handle htmlwidget/calltool invoke
+// This is the typed handler for when a widget calls a tool on the bot.
+// ---------------------------------------------------------------------------
+app.on('widget.callTool', async ({ activity }) => {
+ const { name, arguments: args } = activity.value;
+ console.log(`[widget.callTool] tool="${name}" args=${JSON.stringify(args)}`);
+
+ let callToolResult: IMcpUiCallToolResult;
+ switch (name) {
+ case 'refresh':
+ callToolResult = {
+ content: [{ type: 'text', text: 'Refreshed!' }],
+ structuredContent: {
+ counter: ((args as any)?.counter ?? 0) + 1,
+ lastAction: 'refresh',
+ timestamp: new Date().toISOString(),
+ },
+ isError: false,
+ };
+ break;
+
+ case 'getTime':
+ callToolResult = {
+ content: [{ type: 'text', text: new Date().toLocaleTimeString() }],
+ structuredContent: { time: new Date().toISOString() },
+ isError: false,
+ };
+ break;
+
+ case 'roll': {
+ const sides = (args as any)?.sides ?? 6;
+ const result = Math.floor(Math.random() * sides) + 1;
+ callToolResult = {
+ content: [{ type: 'text', text: `Rolled a ${result} (d${sides})` }],
+ structuredContent: { result, sides },
+ isError: false,
+ };
+ break;
+ }
+
+ case 'echo':
+ callToolResult = {
+ content: [{ type: 'text', text: JSON.stringify(args) }],
+ structuredContent: args,
+ isError: false,
+ };
+ break;
+
+ default:
+ callToolResult = {
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
+ isError: true,
+ };
+ break;
+ }
+
+ console.log('[widget.callTool] result=', JSON.stringify(callToolResult));
+
+ return {
+ responseType: 'htmlwidget/calltoolresult',
+ callToolResult,
+ };
+});
+
+app.start(process.env.PORT || 3978).catch(console.error);
diff --git a/examples/html-widgets/src/widgets/calltool.ts b/examples/html-widgets/src/widgets/calltool.ts
new file mode 100644
index 000000000..d423f904f
--- /dev/null
+++ b/examples/html-widgets/src/widgets/calltool.ts
@@ -0,0 +1,8 @@
+/**
+ * CallTool widget - calls a "refresh" tool on the bot and displays the result.
+ *
+ * This HTML includes the interactive calltool behavior (tools/call) since
+ * that is widget-specific logic, not boilerplate. The example bot uses
+ * injectWidgetProtocol() automatically via the builders.
+ */
+export const CALLTOOL_WIDGET_HTML = '
CallTool Widget
Click Refresh to call the bot\'s "refresh" tool.
Waiting for action...
';
diff --git a/examples/html-widgets/src/widgets/fullscreen.ts b/examples/html-widgets/src/widgets/fullscreen.ts
new file mode 100644
index 000000000..8d6627d87
--- /dev/null
+++ b/examples/html-widgets/src/widgets/fullscreen.ts
@@ -0,0 +1,7 @@
+/**
+ * Fullscreen widget - requests fullscreen display mode from the host.
+ *
+ * Uses requestDisplayMode to ask the Teams host for fullscreen mode.
+ * The example bot uses injectWidgetProtocol() automatically via the builders.
+ */
+export const FULLSCREEN_WIDGET_HTML = '
Fullscreen Widget
Click the button to request fullscreen mode from Teams.
In fullscreen mode, this widget will expand to fill the available space.
Current mode: inline
';
diff --git a/examples/html-widgets/src/widgets/host-context.ts b/examples/html-widgets/src/widgets/host-context.ts
new file mode 100644
index 000000000..35bfa9e99
--- /dev/null
+++ b/examples/html-widgets/src/widgets/host-context.ts
@@ -0,0 +1,92 @@
+/**
+ * Host Context widget - displays the hostContext received during ui/initialize.
+ * Shows theme, display mode, container dimensions, locale, etc.
+ * Also listens for ui/notifications/host-context-changed.
+ */
+export const HOST_CONTEXT_WIDGET_HTML = `
+
+
+
Host Context Inspector
+
Displays the hostContext from ui/initialize response and listens for changes.
+
+
Initialize Result
+
Waiting for initialize...
+
+
+
Host Context
+
-
+
+
+
Host Capabilities
+
-
+
+
+
+`;
diff --git a/examples/html-widgets/src/widgets/messageback.ts b/examples/html-widgets/src/widgets/messageback.ts
new file mode 100644
index 000000000..ac56f6063
--- /dev/null
+++ b/examples/html-widgets/src/widgets/messageback.ts
@@ -0,0 +1,8 @@
+/**
+ * MessageBack widget - sends a messageBack action to the bot.
+ *
+ * Uses the ui/message method to send a message to the conversation,
+ * similar to messageBack in Adaptive Cards. The example bot uses
+ * injectWidgetProtocol() automatically via the builders.
+ */
+export const MESSAGEBACK_WIDGET_HTML = '
MessageBack Widget
Click the button to send a messageBack to the bot.
';
diff --git a/examples/html-widgets/src/widgets/multi-tool.ts b/examples/html-widgets/src/widgets/multi-tool.ts
new file mode 100644
index 000000000..0a954429c
--- /dev/null
+++ b/examples/html-widgets/src/widgets/multi-tool.ts
@@ -0,0 +1,8 @@
+/**
+ * Multi-tool widget - calls multiple different tools on the bot.
+ *
+ * Each button calls tools/call with a different tool name. Teams routes
+ * each as an `htmlwidget/calltool` invoke activity to the bot. The example
+ * bot uses injectWidgetProtocol() automatically via the builders.
+ */
+export const MULTI_WIDGET_HTML = '
Multi-Tool Widget
Each button calls a different tool on the bot.
Available tools: getTime, roll, echo, unknownTool
';
diff --git a/examples/html-widgets/src/widgets/open-link.ts b/examples/html-widgets/src/widgets/open-link.ts
new file mode 100644
index 000000000..0057ac89b
--- /dev/null
+++ b/examples/html-widgets/src/widgets/open-link.ts
@@ -0,0 +1,58 @@
+/**
+ * Open Link widget - tests ui/open-link method.
+ * Clicking a button asks the host to open a URL in the user's browser.
+ */
+export const OPEN_LINK_WIDGET_HTML = `
+
+
+
Open Link Widget
+
Tests the ui/open-link method (host opens a URL).
+
+
+
+
+
+
Waiting...
+
+`;
diff --git a/examples/html-widgets/src/widgets/simple.ts b/examples/html-widgets/src/widgets/simple.ts
new file mode 100644
index 000000000..8510d9b66
--- /dev/null
+++ b/examples/html-widgets/src/widgets/simple.ts
@@ -0,0 +1,8 @@
+/**
+ * Simple static widget - no callbacks, no interactivity.
+ * Verifies that the host renders the HTML correctly.
+ *
+ * This is raw HTML without the MCP Apps protocol. The example bot uses
+ * injectWidgetProtocol() automatically via the builders.
+ */
+export const SIMPLE_WIDGET_HTML = '
Simple HTML Widget
This is a static HTML widget rendered inside a Teams message. No callbacks are needed.
Status: Rendered successfully
';
diff --git a/examples/html-widgets/src/widgets/update-context.ts b/examples/html-widgets/src/widgets/update-context.ts
new file mode 100644
index 000000000..331ab0389
--- /dev/null
+++ b/examples/html-widgets/src/widgets/update-context.ts
@@ -0,0 +1,91 @@
+/**
+ * Update Model Context widget - tests ui/update-model-context method.
+ * Sends structured context to the host that can be used by AI in future turns.
+ */
+export const UPDATE_CONTEXT_WIDGET_HTML = `
+
+
+
Update Model Context Widget
+
Tests ui/update-model-context - sends context for AI to use in future turns.
+
+
+
+
+
+
+
Waiting...
+
+`;
diff --git a/examples/html-widgets/tsconfig.json b/examples/html-widgets/tsconfig.json
new file mode 100644
index 000000000..9a42fe553
--- /dev/null
+++ b/examples/html-widgets/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@microsoft/teams.config/tsconfig.node.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/package-lock.json b/package-lock.json
index 0036bab51..7c167fe2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -215,6 +215,22 @@
"typescript": "^5.4.5"
}
},
+ "examples/html-widgets": {
+ "name": "@examples/html-widgets",
+ "version": "0.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/teams.apps": "*"
+ },
+ "devDependencies": {
+ "@microsoft/teams.config": "*",
+ "@types/node": "^22.5.4",
+ "dotenv": "^16.4.5",
+ "rimraf": "^6.0.1",
+ "tsx": "^4.20.6",
+ "typescript": "^5.4.5"
+ }
+ },
"examples/http-adapters": {
"name": "@examples/http-adapters",
"version": "0.0.1",
@@ -2213,6 +2229,10 @@
"resolved": "examples/formatted-messaging",
"link": true
},
+ "node_modules/@examples/html-widgets": {
+ "resolved": "examples/html-widgets",
+ "link": true
+ },
"node_modules/@examples/http-adapters": {
"resolved": "examples/http-adapters",
"link": true
@@ -5912,6 +5932,9 @@
"arm"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -5926,6 +5949,9 @@
"arm"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -5940,6 +5966,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -5954,6 +5983,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -5968,6 +6000,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -5982,6 +6017,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -5996,6 +6034,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6010,6 +6051,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6024,6 +6068,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6038,6 +6085,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6052,6 +6102,9 @@
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6065,6 +6118,9 @@
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6079,6 +6135,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -11770,9 +11829,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.25",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
- "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
+ "version": "4.12.26",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
+ "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -16553,6 +16612,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -17661,6 +17723,9 @@
"cpu": [
"x64"
],
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
diff --git a/packages/api/src/activities/invoke/html-widget/call-tool.ts b/packages/api/src/activities/invoke/html-widget/call-tool.ts
new file mode 100644
index 000000000..275be4229
--- /dev/null
+++ b/packages/api/src/activities/invoke/html-widget/call-tool.ts
@@ -0,0 +1,14 @@
+import { ICallToolRequest } from '../../../models';
+import { IActivity } from '../../activity';
+
+export interface IHtmlWidgetCallToolInvokeActivity extends IActivity<'invoke'> {
+ /**
+ * The name of the operation associated with an invoke or event activity.
+ */
+ name: 'htmlwidget/calltool';
+
+ /**
+ * A value that is associated with the activity.
+ */
+ value: ICallToolRequest;
+}
diff --git a/packages/api/src/activities/invoke/html-widget/index.ts b/packages/api/src/activities/invoke/html-widget/index.ts
new file mode 100644
index 000000000..0271fdc65
--- /dev/null
+++ b/packages/api/src/activities/invoke/html-widget/index.ts
@@ -0,0 +1 @@
+export * from './call-tool';
diff --git a/packages/api/src/activities/invoke/index.ts b/packages/api/src/activities/invoke/index.ts
index 8411469c5..d1349163c 100644
--- a/packages/api/src/activities/invoke/index.ts
+++ b/packages/api/src/activities/invoke/index.ts
@@ -3,6 +3,7 @@ import { ConfigInvokeActivity } from './config';
import { IExecuteActionInvokeActivity } from './execute-action';
import { IFileConsentInvokeActivity } from './file-consent';
import { IHandoffActionInvokeActivity } from './handoff-action';
+import { IHtmlWidgetCallToolInvokeActivity } from './html-widget';
import { MessageInvokeActivity } from './message';
import { MessageExtensionInvokeActivity } from './message-extension';
import { SignInInvokeActivity } from './sign-in';
@@ -21,7 +22,8 @@ export type InvokeActivity =
| IHandoffActionInvokeActivity
| SignInInvokeActivity
| AdaptiveCardInvokeActivity
- | ISuggestedActionSubmitInvokeActivity;
+ | ISuggestedActionSubmitInvokeActivity
+ | IHtmlWidgetCallToolInvokeActivity;
export * from './file-consent';
export * from './execute-action';
@@ -34,3 +36,4 @@ export * from './handoff-action';
export * from './sign-in';
export * from './suggested-action-submit';
export * from './adaptive-card';
+export * from './html-widget';
diff --git a/packages/api/src/models/html-widget/call-tool-request.ts b/packages/api/src/models/html-widget/call-tool-request.ts
new file mode 100644
index 000000000..54a79a73b
--- /dev/null
+++ b/packages/api/src/models/html-widget/call-tool-request.ts
@@ -0,0 +1,15 @@
+/**
+ * A request from a widget to call a tool on the bot.
+ * Sent as the value of an `htmlwidget/calltool` invoke activity.
+ */
+export interface ICallToolRequest {
+ /**
+ * The name of the tool to call.
+ */
+ name: string;
+
+ /**
+ * The arguments to pass to the tool.
+ */
+ arguments?: unknown;
+}
diff --git a/packages/api/src/models/html-widget/call-tool-result.ts b/packages/api/src/models/html-widget/call-tool-result.ts
new file mode 100644
index 000000000..e0c8ce326
--- /dev/null
+++ b/packages/api/src/models/html-widget/call-tool-result.ts
@@ -0,0 +1,58 @@
+/**
+ * A content item in an MCP UI call tool result.
+ */
+export interface IMcpUiCallToolResultContent {
+ /**
+ * The type of content (e.g. "text").
+ */
+ type: string;
+
+ /**
+ * The text content.
+ */
+ text: string;
+}
+
+/**
+ * The result of a widget's `tools/call` request, returned by the bot
+ * in response to an `htmlwidget/calltool` invoke activity.
+ *
+ * @experimental This API is in preview and may change in the future.
+ * Diagnostic: ExperimentalTeamsHtmlWidget
+ */
+export interface IMcpUiCallToolResult {
+ /**
+ * An array of content items to return to the widget.
+ */
+ content?: IMcpUiCallToolResultContent[];
+
+ /**
+ * Structured data that the widget can render from.
+ */
+ structuredContent?: unknown;
+
+ /**
+ * Whether the tool call resulted in an error.
+ */
+ isError?: boolean;
+}
+
+/**
+ * The wire-format response body for an `htmlwidget/calltool` invoke.
+ * Teams expects this shape (with `responseType` discriminator) rather than
+ * a bare {@link IMcpUiCallToolResult}.
+ *
+ * @experimental This API is in preview and may change in the future.
+ * Diagnostic: ExperimentalTeamsHtmlWidget
+ */
+export interface IHtmlWidgetCallToolResponse {
+ /**
+ * Discriminator that tells Teams how to interpret the response.
+ */
+ responseType: 'htmlwidget/calltoolresult';
+
+ /**
+ * The tool call result payload.
+ */
+ callToolResult: IMcpUiCallToolResult;
+}
diff --git a/packages/api/src/models/html-widget/html-widget-payload.ts b/packages/api/src/models/html-widget/html-widget-payload.ts
new file mode 100644
index 000000000..047316d48
--- /dev/null
+++ b/packages/api/src/models/html-widget/html-widget-payload.ts
@@ -0,0 +1,113 @@
+/**
+ * The security policy for an HTML widget, controlling allowed origins
+ * for network requests, static resources, nested iframes, and base URIs.
+ *
+ * @experimental This API is in preview and may change in the future.
+ * Diagnostic: ExperimentalTeamsHtmlWidget
+ */
+export interface IHtmlWidgetSecurityPolicy {
+ /**
+ * Allowed origins for network requests.
+ */
+ connectDomains?: string[];
+
+ /**
+ * Allowed origins for static resources.
+ */
+ resourceDomains?: string[];
+
+ /**
+ * Allowed origins for nested iframes.
+ */
+ frameDomains?: string[];
+
+ /**
+ * Allowed base URIs for the document.
+ */
+ baseUriDomains?: string[];
+}
+
+/**
+ * Permissions that the widget may request from the host.
+ *
+ * @experimental This API is in preview and may change in the future.
+ * Diagnostic: ExperimentalTeamsHtmlWidget
+ */
+export interface IHtmlWidgetPermissions {
+ /**
+ * Request camera access.
+ */
+ camera?: unknown;
+
+ /**
+ * Request microphone access.
+ */
+ microphone?: unknown;
+
+ /**
+ * Request geolocation access.
+ */
+ geolocation?: unknown;
+
+ /**
+ * Request clipboard write access.
+ */
+ clipboardWrite?: unknown;
+}
+
+/**
+ * The JSON payload for an HTML widget, sent inside a ```html-widget code block
+ * within a Markdown message.
+ *
+ * @experimental This API is in preview and may change in the future.
+ * Diagnostic: ExperimentalTeamsHtmlWidget
+ */
+export interface IHtmlWidgetPayload {
+ /**
+ * The widget type identifier. Currently only "widget/mcp-ui" is supported.
+ */
+ type: 'widget/mcp-ui';
+
+ /**
+ * The display name of the MCP app.
+ */
+ name: string;
+
+ /**
+ * A description of the MCP app.
+ */
+ description?: string;
+
+ /**
+ * The HTML content that makes up the widget.
+ */
+ html: string;
+
+ /**
+ * The domain associated with the widget, applied to sandbox metadata.
+ * Must be a valid domain URL (e.g. 'https://example.com'). The domain
+ * does not need to resolve or serve content, but must be non-empty.
+ * This value is available to the rendering MCP App as informational context.
+ */
+ domain: string;
+
+ /**
+ * Optional security policy controlling allowed origins.
+ */
+ securityPolicy?: IHtmlWidgetSecurityPolicy;
+
+ /**
+ * Optional data that was passed as input to the tool that produced this widget.
+ */
+ toolInput?: unknown;
+
+ /**
+ * Optional data that the tool produced alongside this widget.
+ */
+ toolOutput?: unknown;
+
+ /**
+ * Optional permissions the widget requests from the host.
+ */
+ permissions?: IHtmlWidgetPermissions;
+}
diff --git a/packages/api/src/models/html-widget/index.ts b/packages/api/src/models/html-widget/index.ts
new file mode 100644
index 000000000..ca1e46e3e
--- /dev/null
+++ b/packages/api/src/models/html-widget/index.ts
@@ -0,0 +1,3 @@
+export * from './html-widget-payload';
+export * from './call-tool-request';
+export * from './call-tool-result';
diff --git a/packages/api/src/models/index.ts b/packages/api/src/models/index.ts
index 83dbdface..11346644f 100644
--- a/packages/api/src/models/index.ts
+++ b/packages/api/src/models/index.ts
@@ -35,4 +35,5 @@ export * from './team-details';
export * from './meeting';
export * from './channel-id';
export * from './activity-like';
+export * from './html-widget';
diff --git a/packages/api/src/models/invoke-response.ts b/packages/api/src/models/invoke-response.ts
index 14cbda234..8c992465a 100644
--- a/packages/api/src/models/invoke-response.ts
+++ b/packages/api/src/models/invoke-response.ts
@@ -1,6 +1,7 @@
import {
AdaptiveCardActionResponse,
ConfigResponse,
+ IHtmlWidgetCallToolResponse,
MessagingExtensionActionResponse,
MessagingExtensionResponse,
TabResponse,
@@ -63,4 +64,5 @@ type InvokeResponseBody = {
'signin/verifyState': void;
'signin/failure': void;
'adaptiveCard/action': AdaptiveCardActionResponse;
+ 'htmlwidget/calltool': IHtmlWidgetCallToolResponse;
};
diff --git a/packages/apps/src/index.ts b/packages/apps/src/index.ts
index 98918b252..b629ba63b 100644
--- a/packages/apps/src/index.ts
+++ b/packages/apps/src/index.ts
@@ -10,3 +10,7 @@ export * from './http';
// Threading utilities
export { toThreadedConversationId } from './utils/thread';
+
+// HTML Widget utilities
+export { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, injectWidgetProtocol, validateSecurityPolicy } from './utils/html-widget';
+export type { IHtmlWidgetMarkdownOptions, IInjectWidgetProtocolOptions, ISecurityPolicyWarning } from './utils/html-widget';
diff --git a/packages/apps/src/routes/invoke/index.ts b/packages/apps/src/routes/invoke/index.ts
index 0a1db3d50..eb43f2df5 100644
--- a/packages/apps/src/routes/invoke/index.ts
+++ b/packages/apps/src/routes/invoke/index.ts
@@ -9,6 +9,7 @@ import { DialogSubmitSubRoutes } from './dialog-submit';
import { FileConsentActivityRoutes } from './file-consent';
import { MessageExtensionSubmitActivityRoutes } from './message-extension-submit';
import { MessageSubmitActivityRoutes } from './message-submit';
+import { WidgetCallToolRoutes } from './widget-calltool';
export type InvokeActivityRoutes = Record> = {
[K in InvokeActivity['name']as InvokeAliases[K]]?: RouteHandler<
@@ -20,7 +21,8 @@ export type InvokeActivityRoutes = Record<
MessageSubmitActivityRoutes &
DialogOpenSubRoutes &
DialogSubmitSubRoutes &
- CardActionSubRoutes;
+ CardActionSubRoutes &
+ WidgetCallToolRoutes;
type InvokeAliases = {
'config/fetch': 'config.open';
@@ -48,6 +50,7 @@ type InvokeAliases = {
'signin/verifyState': 'signin.verify-state';
'signin/failure': 'signin.failure';
'adaptiveCard/action': 'card.action';
+ 'htmlwidget/calltool': 'widget.callTool';
};
export const INVOKE_ALIASES: InvokeAliases = {
@@ -76,6 +79,7 @@ export const INVOKE_ALIASES: InvokeAliases = {
'signin/verifyState': 'signin.verify-state',
'signin/failure': 'signin.failure',
'adaptiveCard/action': 'card.action',
+ 'htmlwidget/calltool': 'widget.callTool',
};
export * from './card-action';
@@ -84,4 +88,5 @@ export * from './dialog-submit';
export * from './file-consent';
export * from './message-extension-submit';
export * from './message-submit';
+export * from './widget-calltool';
diff --git a/packages/apps/src/routes/invoke/widget-calltool.ts b/packages/apps/src/routes/invoke/widget-calltool.ts
new file mode 100644
index 000000000..e59411c87
--- /dev/null
+++ b/packages/apps/src/routes/invoke/widget-calltool.ts
@@ -0,0 +1,15 @@
+import { IHtmlWidgetCallToolInvokeActivity, InvokeResponse } from '@microsoft/teams.api';
+
+import { IActivityContext } from '../../contexts';
+import { RouteHandler } from '../../types';
+
+/**
+ * @experimental This API is in preview and may change in the future.
+ * Diagnostic: ExperimentalTeamsHtmlWidget
+ */
+export type WidgetCallToolRoutes = Record> = {
+ 'widget.callTool'?: RouteHandler<
+ IActivityContext,
+ InvokeResponse<'htmlwidget/calltool'> | InvokeResponse<'htmlwidget/calltool'>['body']
+ >;
+};
diff --git a/packages/apps/src/utils/html-widget.spec.ts b/packages/apps/src/utils/html-widget.spec.ts
new file mode 100644
index 000000000..1b4f607ba
--- /dev/null
+++ b/packages/apps/src/utils/html-widget.spec.ts
@@ -0,0 +1,723 @@
+import { IHtmlWidgetPayload, IHtmlWidgetSecurityPolicy } from '@microsoft/teams.api';
+
+import { buildHtmlWidgetMarkdown, buildHtmlWidgetMessage, injectWidgetProtocol, validateSecurityPolicy } from './html-widget';
+
+const MINIMAL_PAYLOAD: IHtmlWidgetPayload = {
+ type: 'widget/mcp-ui',
+ name: 'Test Widget',
+ html: '