diff --git a/examples/html-widgets/README.md b/examples/html-widgets/README.md
new file mode 100644
index 000000000..da46be6b7
--- /dev/null
+++ b/examples/html-widgets/README.md
@@ -0,0 +1,73 @@
+# HTML Widgets Example
+
+This example demonstrates the full HTML widget capabilities for Teams bots using the Python SDK.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `/simple` | Static widget (no callbacks) |
+| `/calltool` | Widget with onCallTool (tools/call) |
+| `/messageback` | Widget with onMessage (messageBack) |
+| `/fullscreen` | Widget requesting fullscreen display mode |
+| `/multi` | Widget with multiple tools (getTime, roll, echo) |
+| `/openlink` | Widget with ui/open-link |
+| `/context` | Widget with ui/update-model-context |
+| `/hostcontext` | Inspect hostContext from ui/initialize |
+| `/validate` | Security policy validation demo |
+| `/help` | List available commands |
+
+## Architecture
+
+```
+src/
+ main.py # Bot entry point, command routing, callTool handler
+ widgets/
+ __init__.py # Re-exports all widget HTML constants
+ simple.py # Static widget HTML
+ calltool.py # CallTool interactive widget HTML
+ fullscreen.py # Fullscreen request widget HTML
+ messageback.py # MessageBack widget HTML
+ multi_tool.py # Multi-tool dispatch widget HTML
+ open_link.py # Open link widget HTML
+ update_context.py # Update model context widget HTML
+ host_context.py # Host context inspector widget HTML
+```
+
+## Running
+
+```bash
+# From the repo root
+cd examples/html-widgets
+cp ../../.env .env # Or create .env with CLIENT_ID, CLIENT_SECRET, TENANT_ID
+
+# Install dependencies (uses workspace)
+uv sync
+
+# Start the bot
+uv run python src/main.py
+```
+
+## Widget Response Format
+
+The `on_widget_call_tool` handler returns an `HtmlWidgetCallToolResponse`:
+
+```python
+@app.on_widget_call_tool
+async def handle(ctx):
+ return HtmlWidgetCallToolResponse(
+ response_type="htmlwidget/calltoolresult",
+ call_tool_result=McpUiCallToolResult(
+ content=[{"type": "text", "text": "Result"}],
+ structured_content={"key": "value"},
+ is_error=False,
+ ),
+ )
+```
+
+## Key Concepts
+
+- **`build_html_widget_message`**: Builds a complete message activity with the widget, including protocol injection and security policy defaults.
+- **`build_html_widget_markdown`**: Lower-level helper that returns the markdown string (for embedding in custom activities).
+- **`validate_security_policy`**: Dev-time audit tool that checks HTML for external references not covered by the security policy.
+- **`inject_widget_protocol`**: Auto-injected by the builders. Handles the ui/initialize handshake, size reporting, and optional notification hooks.
diff --git a/examples/html-widgets/pyproject.toml b/examples/html-widgets/pyproject.toml
new file mode 100644
index 000000000..d4bbeee96
--- /dev/null
+++ b/examples/html-widgets/pyproject.toml
@@ -0,0 +1,14 @@
+[project]
+name = "html-widgets"
+version = "0.1.0"
+description = "HTML Widgets example bot"
+readme = "README.md"
+requires-python = ">=3.11,<4.0"
+dependencies = [
+ "dotenv>=0.9.9",
+ "microsoft-teams-apps",
+ "microsoft-teams-api",
+]
+
+[tool.uv.sources]
+microsoft-teams-apps = { workspace = true }
diff --git a/examples/html-widgets/src/main.py b/examples/html-widgets/src/main.py
new file mode 100644
index 000000000..812e5cdeb
--- /dev/null
+++ b/examples/html-widgets/src/main.py
@@ -0,0 +1,370 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+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 asyncio
+import json
+import logging
+import random
+from datetime import datetime, timezone
+from typing import Any
+
+from microsoft_teams.api import HtmlWidgetCallToolInvokeActivity, MessageActivity
+from microsoft_teams.api.activities.message import MessageActivityInput
+from microsoft_teams.api.models.html_widget import (
+ HtmlWidgetCallToolResponse,
+ HtmlWidgetPayload,
+ HtmlWidgetSecurityPolicy,
+ McpUiCallToolResult,
+ McpUiCallToolResultContent,
+)
+from microsoft_teams.apps import ActivityContext, App
+from microsoft_teams.apps.utils.html_widget import (
+ HtmlWidgetMarkdownOptions,
+ build_html_widget_markdown,
+ build_html_widget_message,
+ validate_security_policy,
+)
+from widgets import (
+ CALLTOOL_WIDGET_HTML,
+ FULLSCREEN_WIDGET_HTML,
+ HOST_CONTEXT_WIDGET_HTML,
+ MESSAGEBACK_WIDGET_HTML,
+ MULTI_WIDGET_HTML,
+ OPEN_LINK_WIDGET_HTML,
+ SIMPLE_WIDGET_HTML,
+ UPDATE_CONTEXT_WIDGET_HTML,
+)
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+app = App()
+
+
+# ---------------------------------------------------------------------------
+# Message commands
+# ---------------------------------------------------------------------------
+
+
+@app.on_message
+async def handle_message(ctx: ActivityContext[MessageActivity]) -> None:
+ """Route message commands to the appropriate widget demo."""
+ if not ctx.activity.text:
+ return
+ text = ctx.activity.text.strip().lower()
+
+ # Simple static widget - no callbacks
+ if text == "/simple":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="Simple Widget",
+ description="A static HTML widget with no callbacks.",
+ html=SIMPLE_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ security_policy=HtmlWidgetSecurityPolicy(
+ connect_domains=[],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+ ),
+ ),
+ HtmlWidgetMarkdownOptions(
+ before="Here is a simple static widget:",
+ after="No callbacks needed for static content.",
+ ),
+ )
+ await ctx.send(message)
+ return
+
+ # Widget with onCallTool callback
+ if text == "/calltool":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="CallTool Widget",
+ description="Widget that calls tools on the bot.",
+ html=CALLTOOL_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ security_policy=HtmlWidgetSecurityPolicy(
+ connect_domains=[
+ "https://teams.microsoft.com",
+ "https://teams.cloud.microsoft.com",
+ ],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+ ),
+ tool_input={"demo": True},
+ tool_output={
+ "content": [{"type": "text", "text": "Initial data loaded."}],
+ "structuredContent": {"counter": 0, "lastAction": "init"},
+ "isError": False,
+ },
+ ),
+ HtmlWidgetMarkdownOptions(before="Here is a widget with callTool support (click Refresh):"),
+ )
+ await ctx.send(message)
+ return
+
+ # Widget with onMessage (messageBack) callback
+ if text == "/messageback":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="MessageBack Widget",
+ description="Widget that sends messageBack to the bot.",
+ html=MESSAGEBACK_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ security_policy=HtmlWidgetSecurityPolicy(
+ connect_domains=[],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+ ),
+ ),
+ HtmlWidgetMarkdownOptions(before="This widget tests the onMessage (messageBack) callback:"),
+ )
+ await ctx.send(message)
+ return
+
+ # Widget requesting fullscreen display mode
+ if text == "/fullscreen":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="Fullscreen Widget",
+ description="Widget that requests fullscreen mode.",
+ html=FULLSCREEN_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ security_policy=HtmlWidgetSecurityPolicy(
+ connect_domains=[],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+ ),
+ ),
+ HtmlWidgetMarkdownOptions(before="This widget will request fullscreen mode:"),
+ )
+ await ctx.send(message)
+ return
+
+ # Widget with multiple tools
+ if text == "/multi":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="Multi-Tool Widget",
+ description="Widget that calls multiple different tools.",
+ html=MULTI_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ security_policy=HtmlWidgetSecurityPolicy(
+ connect_domains=["https://teams.microsoft.com"],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+ ),
+ tool_input={},
+ tool_output={
+ "content": [{"type": "text", "text": "Ready."}],
+ "structuredContent": {"tools": ["getTime", "roll", "echo"]},
+ "isError": False,
+ },
+ ),
+ HtmlWidgetMarkdownOptions(before="This widget has multiple tools to test dispatch:"),
+ )
+ await ctx.send(message)
+ return
+
+ # Widget using ui/open-link
+ if text == "/openlink":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="open-link-test",
+ html=OPEN_LINK_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ ),
+ HtmlWidgetMarkdownOptions(before="Widget with ui/open-link support (click a button to open a URL):"),
+ )
+ await ctx.send(message)
+ return
+
+ # Widget using ui/update-model-context
+ if text == "/context":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="update-context-test",
+ html=UPDATE_CONTEXT_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ ),
+ HtmlWidgetMarkdownOptions(before="Widget with ui/update-model-context support:"),
+ )
+ await ctx.send(message)
+ return
+
+ # Host context inspector
+ if text == "/hostcontext":
+ message = build_html_widget_message(
+ HtmlWidgetPayload(
+ name="host-context-inspector",
+ html=HOST_CONTEXT_WIDGET_HTML,
+ domain="https://teams.microsoft.com",
+ ),
+ HtmlWidgetMarkdownOptions(before="Widget that inspects hostContext from ui/initialize:"),
+ )
+ await ctx.send(message)
+ return
+
+ # Security policy validation demo
+ if text == "/validate":
+ html_with_external_refs = (
+ ''
+ '
'
+ "
Validation Demo
"
+ "
This widget was validated before sending.
"
+ "
"
+ )
+
+ # Step 1: validate against a restrictive policy to catch issues
+ strict_policy = HtmlWidgetSecurityPolicy(
+ connect_domains=[],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+ )
+ warnings = validate_security_policy(html_with_external_refs, strict_policy)
+
+ # Step 2: fix the policy based on warnings, then build the widget
+ corrected_policy = HtmlWidgetSecurityPolicy(
+ connect_domains=[],
+ resource_domains=[
+ "'self'",
+ "data:",
+ "https://fonts.googleapis.com",
+ ],
+ frame_domains=[],
+ base_uri_domains=[],
+ )
+ warning_text = "\n".join(f"- **{w.source}**: `{w.url}` not in `{w.policy_field}`" for w in warnings)
+ markdown = build_html_widget_markdown(
+ HtmlWidgetPayload(
+ name="Validated Widget",
+ description="Widget built after security policy validation.",
+ html=html_with_external_refs,
+ domain="https://teams.microsoft.com",
+ security_policy=corrected_policy,
+ ),
+ HtmlWidgetMarkdownOptions(
+ before=(
+ f"**Validation found {len(warnings)} warning(s):**\n\n"
+ + warning_text
+ + "\n\nPolicy was corrected before sending:"
+ ),
+ ),
+ )
+ await ctx.send(MessageActivityInput(text=markdown, text_format="extendedmarkdown"))
+ return
+
+ # Help
+ if text in ("/help", "help"):
+ await ctx.send(
+ MessageActivityInput(
+ text_format="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 ctx.activity.value:
+ await ctx.send(f"Received messageBack value: {json.dumps(ctx.activity.value)}")
+ return
+
+ await ctx.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_call_tool
+async def handle_widget_call_tool(
+ ctx: ActivityContext[HtmlWidgetCallToolInvokeActivity],
+) -> HtmlWidgetCallToolResponse:
+ """Handle widget tool calls."""
+ tool_name = ctx.activity.value.name
+ args: dict[str, Any] = ctx.activity.value.arguments or {}
+ logger.info(f"[widget.callTool] tool={tool_name!r} args={json.dumps(args)}")
+
+ call_tool_result: McpUiCallToolResult
+
+ if tool_name == "refresh":
+ counter = int(args.get("counter", 0) or 0) + 1
+ call_tool_result = McpUiCallToolResult(
+ content=[McpUiCallToolResultContent(type="text", text="Refreshed!")],
+ structured_content={
+ "counter": counter,
+ "lastAction": "refresh",
+ "timestamp": datetime.now(tz=timezone.utc).isoformat(),
+ },
+ is_error=False,
+ )
+
+ elif tool_name == "getTime":
+ now = datetime.now(tz=timezone.utc)
+ call_tool_result = McpUiCallToolResult(
+ content=[McpUiCallToolResultContent(type="text", text=now.strftime("%H:%M:%S"))],
+ structured_content={"time": now.isoformat()},
+ is_error=False,
+ )
+
+ elif tool_name == "roll":
+ sides = int(args.get("sides", 6) or 6)
+ result = random.randint(1, sides) # noqa: S311
+ call_tool_result = McpUiCallToolResult(
+ content=[McpUiCallToolResultContent(type="text", text=f"Rolled a {result} (d{sides})")],
+ structured_content={"result": result, "sides": sides},
+ is_error=False,
+ )
+
+ elif tool_name == "echo":
+ call_tool_result = McpUiCallToolResult(
+ content=[McpUiCallToolResultContent(type="text", text=json.dumps(args))],
+ structured_content=args,
+ is_error=False,
+ )
+
+ else:
+ call_tool_result = McpUiCallToolResult(
+ content=[McpUiCallToolResultContent(type="text", text=f"Unknown tool: {tool_name}")],
+ is_error=True,
+ )
+
+ logger.info(f"[widget.callTool] result={call_tool_result}")
+
+ return HtmlWidgetCallToolResponse(
+ response_type="htmlwidget/calltoolresult",
+ call_tool_result=call_tool_result,
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(app.start())
diff --git a/examples/html-widgets/src/widgets/__init__.py b/examples/html-widgets/src/widgets/__init__.py
new file mode 100644
index 000000000..c0357b403
--- /dev/null
+++ b/examples/html-widgets/src/widgets/__init__.py
@@ -0,0 +1,26 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants package.
+"""
+
+from .calltool import CALLTOOL_WIDGET_HTML
+from .fullscreen import FULLSCREEN_WIDGET_HTML
+from .host_context import HOST_CONTEXT_WIDGET_HTML
+from .messageback import MESSAGEBACK_WIDGET_HTML
+from .multi_tool import MULTI_WIDGET_HTML
+from .open_link import OPEN_LINK_WIDGET_HTML
+from .simple import SIMPLE_WIDGET_HTML
+from .update_context import UPDATE_CONTEXT_WIDGET_HTML
+
+__all__ = [
+ "CALLTOOL_WIDGET_HTML",
+ "FULLSCREEN_WIDGET_HTML",
+ "HOST_CONTEXT_WIDGET_HTML",
+ "MESSAGEBACK_WIDGET_HTML",
+ "MULTI_WIDGET_HTML",
+ "OPEN_LINK_WIDGET_HTML",
+ "SIMPLE_WIDGET_HTML",
+ "UPDATE_CONTEXT_WIDGET_HTML",
+]
diff --git a/examples/html-widgets/src/widgets/calltool.py b/examples/html-widgets/src/widgets/calltool.py
new file mode 100644
index 000000000..f8d7d02bc
--- /dev/null
+++ b/examples/html-widgets/src/widgets/calltool.py
@@ -0,0 +1,44 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - calltool widget.
+"""
+
+# 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
+# inject_widget_protocol() automatically via the builders.
+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.py b/examples/html-widgets/src/widgets/fullscreen.py
new file mode 100644
index 000000000..9c853ac65
--- /dev/null
+++ b/examples/html-widgets/src/widgets/fullscreen.py
@@ -0,0 +1,45 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - fullscreen widget.
+"""
+
+# Fullscreen widget - requests fullscreen display mode from the host.
+#
+# Uses requestDisplayMode to ask the Teams host for fullscreen mode.
+# The example bot uses inject_widget_protocol() automatically via the builders.
+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.py b/examples/html-widgets/src/widgets/host_context.py
new file mode 100644
index 000000000..6d99d7d15
--- /dev/null
+++ b/examples/html-widgets/src/widgets/host_context.py
@@ -0,0 +1,98 @@
+# ruff: noqa: E501
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - host context inspector widget.
+"""
+
+# 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.
+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.py b/examples/html-widgets/src/widgets/messageback.py
new file mode 100644
index 000000000..6247fe716
--- /dev/null
+++ b/examples/html-widgets/src/widgets/messageback.py
@@ -0,0 +1,38 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - messageback widget.
+"""
+
+# 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
+# inject_widget_protocol() automatically via the builders.
+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.py b/examples/html-widgets/src/widgets/multi_tool.py
new file mode 100644
index 000000000..b2a5d6f1e
--- /dev/null
+++ b/examples/html-widgets/src/widgets/multi_tool.py
@@ -0,0 +1,53 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - multi-tool widget.
+"""
+
+# 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 inject_widget_protocol() automatically via the builders.
+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.py b/examples/html-widgets/src/widgets/open_link.py
new file mode 100644
index 000000000..18f30dda5
--- /dev/null
+++ b/examples/html-widgets/src/widgets/open_link.py
@@ -0,0 +1,64 @@
+# ruff: noqa: E501
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - open link widget.
+"""
+
+# Open Link widget - tests ui/open-link method.
+# Clicking a button asks the host to open a URL in the user's browser.
+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.py b/examples/html-widgets/src/widgets/simple.py
new file mode 100644
index 000000000..996215b88
--- /dev/null
+++ b/examples/html-widgets/src/widgets/simple.py
@@ -0,0 +1,27 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - simple static widget.
+"""
+
+# 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
+# inject_widget_protocol() automatically via the builders.
+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.py b/examples/html-widgets/src/widgets/update_context.py
new file mode 100644
index 000000000..e5327baef
--- /dev/null
+++ b/examples/html-widgets/src/widgets/update_context.py
@@ -0,0 +1,97 @@
+# ruff: noqa: E501
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+Widget HTML constants - update model context widget.
+"""
+
+# 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.
+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/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py b/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py
index 439c9bccd..7f3df4a8f 100644
--- a/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py
+++ b/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py
@@ -7,13 +7,14 @@
from pydantic import Field
-from . import config, message_extension, sign_in, tab, task
+from . import config, html_widget, message_extension, sign_in, tab, task
from .adaptive_card import AdaptiveCardInvokeActivity
from .config import * # noqa: F403
from .config import ConfigInvokeActivity
from .execute_action import ExecuteActionInvokeActivity
from .file_consent import FileConsentInvokeActivity
from .handoff_action import HandoffActionInvokeActivity
+from .html_widget import HtmlWidgetCallToolInvokeActivity
from .message import (
MessageFetchTaskActionValue,
MessageFetchTaskData,
@@ -45,6 +46,7 @@
SignInInvokeActivity,
AdaptiveCardInvokeActivity,
SuggestedActionSubmitInvokeActivity,
+ HtmlWidgetCallToolInvokeActivity,
],
Field(discriminator="name"),
]
@@ -66,9 +68,11 @@
"SignInInvokeActivity",
"AdaptiveCardInvokeActivity",
"SuggestedActionSubmitInvokeActivity",
+ "HtmlWidgetCallToolInvokeActivity",
]
__all__.extend(config.__all__)
+__all__.extend(html_widget.__all__)
__all__.extend(message_extension.__all__)
__all__.extend(sign_in.__all__)
__all__.extend(tab.__all__)
diff --git a/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py
new file mode 100644
index 000000000..723500da3
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py
@@ -0,0 +1,8 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from .call_tool import HtmlWidgetCallToolInvokeActivity
+
+__all__ = ["HtmlWidgetCallToolInvokeActivity"]
diff --git a/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py
new file mode 100644
index 000000000..a61783f5d
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py
@@ -0,0 +1,28 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Literal
+
+from microsoft_teams.common.experimental import experimental
+
+from ....models.html_widget.call_tool_request import CallToolRequest
+from ...invoke_activity import InvokeActivity
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class HtmlWidgetCallToolInvokeActivity(InvokeActivity):
+ """
+ Represents an activity that is sent when a widget calls a tool on the bot.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+
+ type: Literal["invoke"] = "invoke"
+
+ name: Literal["htmlwidget/calltool"] = "htmlwidget/calltool"
+ """The name of the operation associated with the invoke activity."""
+
+ value: CallToolRequest
+ """The tool call request from the widget."""
diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py
index df7e229fd..6f330417d 100644
--- a/packages/api/src/microsoft_teams/api/models/__init__.py
+++ b/packages/api/src/microsoft_teams/api/models/__init__.py
@@ -12,6 +12,7 @@
conversation,
entity,
file,
+ html_widget,
meetings,
message,
messaging_extension,
@@ -40,6 +41,7 @@
from .entity import * # noqa: F403
from .error import ErrorResponse, HttpError, InnerHttpError
from .file import * # noqa: F403
+from .html_widget import * # noqa: F403
from .importance import Importance
from .input_hint import InputHint
from .invoke_response import (
@@ -111,6 +113,7 @@
__all__.extend(conversation.__all__)
__all__.extend(entity.__all__)
__all__.extend(file.__all__)
+__all__.extend(html_widget.__all__)
__all__.extend(meetings.__all__)
__all__.extend(message.__all__)
__all__.extend(messaging_extension.__all__)
diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/__init__.py b/packages/api/src/microsoft_teams/api/models/html_widget/__init__.py
new file mode 100644
index 000000000..8f2928ad2
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/html_widget/__init__.py
@@ -0,0 +1,18 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from .call_tool_request import CallToolRequest
+from .call_tool_result import HtmlWidgetCallToolResponse, McpUiCallToolResult, McpUiCallToolResultContent
+from .html_widget_payload import HtmlWidgetPayload, HtmlWidgetPermissions, HtmlWidgetSecurityPolicy
+
+__all__ = [
+ "CallToolRequest",
+ "HtmlWidgetCallToolResponse",
+ "HtmlWidgetPayload",
+ "HtmlWidgetPermissions",
+ "HtmlWidgetSecurityPolicy",
+ "McpUiCallToolResult",
+ "McpUiCallToolResultContent",
+]
diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py
new file mode 100644
index 000000000..eb3b00b67
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py
@@ -0,0 +1,26 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Any, Optional
+
+from microsoft_teams.common.experimental import experimental
+
+from ..custom_base_model import CustomBaseModel
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class CallToolRequest(CustomBaseModel):
+ """
+ A request from a widget to call a tool on the bot.
+ Sent as the value of an htmlwidget/calltool invoke activity.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+
+ name: str
+ """The name of the tool to call."""
+
+ arguments: Optional[Any] = None
+ """The arguments to pass to the tool."""
diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py
new file mode 100644
index 000000000..39d719fe3
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py
@@ -0,0 +1,56 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Any, Literal, Optional
+
+from microsoft_teams.common.experimental import experimental
+
+from ..custom_base_model import CustomBaseModel
+
+
+class McpUiCallToolResultContent(CustomBaseModel):
+ """A content item in an MCP UI call tool result."""
+
+ type: str
+ """The type of content (e.g. "text")."""
+
+ text: str
+ """The text content."""
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class McpUiCallToolResult(CustomBaseModel):
+ """
+ The result of a widget's tools/call request, returned by the bot
+ in response to an htmlwidget/calltool invoke activity.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+
+ content: Optional[list[McpUiCallToolResultContent]] = None
+ """An array of content items to return to the widget."""
+
+ structured_content: Optional[Any] = None
+ """Structured data that the widget can render from."""
+
+ is_error: Optional[bool] = None
+ """Whether the tool call resulted in an error."""
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class HtmlWidgetCallToolResponse(CustomBaseModel):
+ """
+ The wire-format response body for an htmlwidget/calltool invoke.
+ Teams expects this shape (with responseType discriminator) rather than
+ a bare McpUiCallToolResult.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+
+ response_type: Literal["htmlwidget/calltoolresult"] = "htmlwidget/calltoolresult"
+ """Discriminator that tells Teams how to interpret the response."""
+
+ call_tool_result: McpUiCallToolResult
+ """The tool call result payload."""
diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py b/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py
new file mode 100644
index 000000000..402f36b6c
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py
@@ -0,0 +1,79 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Any, Literal, Optional
+
+from microsoft_teams.common.experimental import experimental
+
+from ..custom_base_model import CustomBaseModel
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class HtmlWidgetSecurityPolicy(CustomBaseModel):
+ """
+ The security policy for an HTML widget, controlling allowed origins
+ for network requests, static resources, nested iframes, and base URIs.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+
+ connect_domains: Optional[list[str]] = None
+ """Allowed origins for network requests."""
+
+ resource_domains: Optional[list[str]] = None
+ """Allowed origins for static resources."""
+
+ frame_domains: Optional[list[str]] = None
+ """Allowed origins for nested iframes."""
+
+ base_uri_domains: Optional[list[str]] = None
+ """Allowed base URIs for the document."""
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class HtmlWidgetPermissions(CustomBaseModel):
+
+ camera: Optional[Any] = None
+ """Request camera access."""
+
+ microphone: Optional[Any] = None
+ """Request microphone access."""
+
+ geolocation: Optional[Any] = None
+ """Request geolocation access."""
+
+ clipboard_write: Optional[Any] = None
+ """Request clipboard write access."""
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+class HtmlWidgetPayload(CustomBaseModel):
+
+ type: Literal["widget/mcp-ui"] = "widget/mcp-ui"
+ """The widget type identifier. Currently only "widget/mcp-ui" is supported."""
+
+ name: str
+ """The display name of the MCP app."""
+
+ description: Optional[str] = None
+ """A description of the MCP app."""
+
+ html: str
+ """The HTML content that makes up the widget."""
+
+ domain: str
+ """The domain associated with the widget. Must start with 'https://'."""
+
+ security_policy: Optional[HtmlWidgetSecurityPolicy] = None
+ """Optional security policy controlling allowed origins."""
+
+ tool_input: Optional[Any] = None
+ """Optional data that was passed as input to the tool that produced this widget."""
+
+ tool_output: Optional[Any] = None
+ """Optional data that the tool produced alongside this widget."""
+
+ permissions: Optional[HtmlWidgetPermissions] = None
+ """Optional permissions the widget requests from the host."""
diff --git a/packages/api/src/microsoft_teams/api/models/invoke_response.py b/packages/api/src/microsoft_teams/api/models/invoke_response.py
index 1579b3a74..74c02d1e3 100644
--- a/packages/api/src/microsoft_teams/api/models/invoke_response.py
+++ b/packages/api/src/microsoft_teams/api/models/invoke_response.py
@@ -8,6 +8,7 @@
from .adaptive_card.adaptive_card_action_response import AdaptiveCardActionResponse
from .config.config_response import ConfigResponse
from .custom_base_model import CustomBaseModel
+from .html_widget.call_tool_result import HtmlWidgetCallToolResponse
from .messaging_extension.messaging_extension_action_response import MessagingExtensionActionResponse
from .messaging_extension.messaging_extension_response import MessagingExtensionResponse
from .tab.tab_response import TabResponse
@@ -27,6 +28,7 @@
TabResponse, # tab/fetch, tab/submit
AdaptiveCardActionResponse, # adaptiveCard/action
TokenExchangeInvokeResponse, # signin/tokenExchange
+ HtmlWidgetCallToolResponse, # htmlwidget/calltool
] # Type variable for generic invoke response
T = TypeVar("T", bound=InvokeResponseBody)
diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py
index 54ad5b922..c5c01e3a6 100644
--- a/packages/apps/src/microsoft_teams/apps/__init__.py
+++ b/packages/apps/src/microsoft_teams/apps/__init__.py
@@ -15,6 +15,15 @@
from .options import AppOptions
from .plugins import * # noqa: F401, F403
from .routing import ActivityContext
+from .utils.html_widget import (
+ HtmlWidgetMarkdownOptions,
+ InjectWidgetProtocolOptions,
+ SecurityPolicyWarning,
+ build_html_widget_markdown,
+ build_html_widget_message,
+ inject_widget_protocol,
+ validate_security_policy,
+)
from .utils.thread import to_threaded_conversation_id
logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -29,6 +38,13 @@
"HttpStream",
"ActivityContext",
"to_threaded_conversation_id",
+ "build_html_widget_markdown",
+ "build_html_widget_message",
+ "inject_widget_protocol",
+ "validate_security_policy",
+ "HtmlWidgetMarkdownOptions",
+ "InjectWidgetProtocolOptions",
+ "SecurityPolicyWarning",
]
__all__.extend(auth.__all__)
__all__.extend(events.__all__)
diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py
index 3be9917d5..c24c7579e 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py
@@ -531,6 +531,15 @@ class ActivityConfig:
output_type_name="AdaptiveCardInvokeResponse",
is_invoke=True,
),
+ "widget.call_tool": ActivityConfig(
+ name="widget.call_tool",
+ method_name="on_widget_call_tool",
+ input_model="HtmlWidgetCallToolInvokeActivity",
+ selector=lambda activity: activity.type == "invoke"
+ and cast(InvokeActivity, activity).name == "htmlwidget/calltool",
+ output_type_name="HtmlWidgetCallToolResponse",
+ is_invoke=True,
+ ),
# Generic invoke handler (fallback for any invoke not matching specific aliases)
"invoke": ActivityConfig(
name="invoke",
diff --git a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py
index 2636969b2..4bdec3509 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py
@@ -24,6 +24,7 @@
ExecuteActionInvokeActivity,
FileConsentInvokeActivity,
HandoffActionInvokeActivity,
+ HtmlWidgetCallToolInvokeActivity,
InstalledActivity,
InstallUpdateActivity,
InvokeActivity,
@@ -59,6 +60,7 @@
from microsoft_teams.api.models.invoke_response import (
AdaptiveCardInvokeResponse,
ConfigInvokeResponse,
+ HtmlWidgetCallToolResponse,
MessagingExtensionActionInvokeResponse,
MessagingExtensionInvokeResponse,
TabInvokeResponse,
@@ -1502,6 +1504,42 @@ def decorator(
return decorator(handler)
return decorator
+ @overload
+ def on_widget_call_tool(
+ self, handler: InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]
+ ) -> InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]: ...
+
+ @overload
+ def on_widget_call_tool(
+ self,
+ ) -> Callable[
+ [InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]],
+ InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse],
+ ]: ...
+
+ def on_widget_call_tool(
+ self,
+ handler: Optional[InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]] = None,
+ ) -> InvokeHandlerUnion[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]:
+ """Register a widget.call_tool activity handler."""
+
+ def decorator(
+ func: InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse],
+ ) -> InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]:
+ validate_handler_type(
+ func,
+ HtmlWidgetCallToolInvokeActivity,
+ "on_widget_call_tool",
+ "HtmlWidgetCallToolInvokeActivity",
+ )
+ config = ACTIVITY_ROUTES["widget.call_tool"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
@overload
def on_card_action(
self, handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]
diff --git a/packages/apps/src/microsoft_teams/apps/utils/html_widget.py b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py
new file mode 100644
index 000000000..04184c2af
--- /dev/null
+++ b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py
@@ -0,0 +1,475 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+
+HTML widget utilities for building and validating widget messages.
+
+Diagnostic: ExperimentalTeamsHtmlWidget
+"""
+
+import json
+import re
+from dataclasses import dataclass, replace
+from typing import Optional
+from urllib.parse import urlparse
+
+from microsoft_teams.api.activities.message import MessageActivityInput
+from microsoft_teams.api.models.html_widget import HtmlWidgetPayload, HtmlWidgetSecurityPolicy
+from microsoft_teams.common.experimental import experimental
+
+# The MCP Apps protocol version used for the widget init handshake.
+MCP_PROTOCOL_VERSION = "2026-01-26"
+
+# Explicit mapping of notification names to their window callback names.
+# Only notifications in this map will have hooks injected.
+#
+# Supported in the Teams app bridge: tool-result, tool-input
+# Not supported in the Teams app bridge: tool-input-partial, tool-cancelled
+# Not yet available in Teams: host-context-changed, resource-teardown
+NOTIFICATION_CALLBACKS: dict[str, str] = {
+ "tool-result": "onToolResult",
+ "tool-input": "onToolInput",
+ "tool-input-partial": "onToolInputPartial",
+ "tool-cancelled": "onToolCancelled",
+ "host-context-changed": "onHostContextChanged",
+ "resource-teardown": "onResourceTeardown",
+}
+
+# Default security policy applied when none is specified.
+DEFAULT_SECURITY_POLICY = HtmlWidgetSecurityPolicy(
+ connect_domains=[],
+ resource_domains=["'self'", "data:"],
+ frame_domains=[],
+ base_uri_domains=[],
+)
+
+
+# ---------------------------------------------------------------------------
+# Types
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class InjectWidgetProtocolOptions:
+ """Options for injecting the MCP Apps protocol into widget HTML."""
+
+ name: Optional[str] = None
+ """The widget app name sent during ui/initialize. Defaults to 'widget'."""
+
+ version: Optional[str] = None
+ """The widget app version sent during ui/initialize. Defaults to '1.0.0'."""
+
+ available_display_modes: Optional[list[str]] = None
+ """Display modes this widget supports (e.g. ['inline', 'fullscreen'])."""
+
+ notifications: Optional[list[str]] = None
+ """Host notifications to listen for (e.g. ['tool-result', 'tool-input'])."""
+
+ debug_csp_violations: bool = False
+ """When true, injects a CSP violation listener for dev-time debugging."""
+
+
+@dataclass
+class HtmlWidgetMarkdownOptions:
+ """Options for building an HTML widget markdown string."""
+
+ before: Optional[str] = None
+ """Text to include before the widget code block."""
+
+ after: Optional[str] = None
+ """Text to include after the widget code block."""
+
+ protocol_options: Optional[InjectWidgetProtocolOptions] = None
+ """Options forwarded to inject_widget_protocol."""
+
+
+@dataclass
+class SecurityPolicyWarning:
+ """
+ A warning produced by validate_security_policy when the widget HTML
+ references an external origin not present in the declared security policy.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+
+ url: str
+ """The URL or origin found in the HTML."""
+
+ source: str
+ """The HTML element or API where the reference was found."""
+
+ policy_field: str
+ """The security_policy field that should include this origin."""
+
+ message: str
+ """A human-readable description of the issue."""
+
+
+# ---------------------------------------------------------------------------
+# Validation
+# ---------------------------------------------------------------------------
+
+
+def _validate_html_widget_payload(payload: HtmlWidgetPayload) -> None:
+ """Validates an HTML widget payload, raising if required fields are missing."""
+ if not payload.name or not payload.name.strip():
+ raise ValueError('HTML widget payload requires a non-empty "name" field.')
+
+ if not payload.html or not payload.html.strip():
+ raise ValueError('HTML widget payload requires a non-empty "html" field.')
+
+ if not payload.domain or not payload.domain.strip() or not payload.domain.startswith("https://"):
+ raise ValueError('HTML widget payload requires "domain" to be a valid URL starting with "https://".')
+
+
+# ---------------------------------------------------------------------------
+# Protocol injection
+# ---------------------------------------------------------------------------
+
+
+def _escape_for_inline_script(value: str) -> str:
+ """Escape a string for safe embedding in a single-quoted JS literal inside breakout, and newlines.
+ """
+ return (
+ value.replace("\\", "\\\\").replace("'", "\\'").replace("", "<\\/").replace("\n", "\\n").replace("\r", "\\r")
+ )
+
+
+@experimental("ExperimentalTeamsHtmlWidget")
+def inject_widget_protocol(html: str, options: Optional[InjectWidgetProtocolOptions] = None) -> str:
+ """
+ Injects the MCP Apps protocol script into widget HTML.
+
+ This sets up:
+ - The ui/initialize handshake (required for rendering)
+ - Size reporting via ui/notifications/size-changed
+ - Optional notification hooks (opt-in via notifications option)
+
+ If the HTML already contains the protocol (detected by 'ui/initialize'), it is returned unchanged.
+
+ Diagnostic: ExperimentalTeamsHtmlWidget
+ """
+ if "ui/initialize" in html:
+ return html
+
+ opts = options or InjectWidgetProtocolOptions()
+ name = _escape_for_inline_script(opts.name or "widget")
+ version = _escape_for_inline_script(opts.version or "1.0.0")
+ caps_json = "{}"
+ if opts.available_display_modes:
+ caps_json = f"{{availableDisplayModes:{json.dumps(opts.available_display_modes)}}}"
+
+ # Build notification hook lines
+ notifications = opts.notifications or []
+ hook_lines = ""
+ for n in notifications:
+ if n in NOTIFICATION_CALLBACKS:
+ method = f"ui/notifications/{n}"
+ cb = NOTIFICATION_CALLBACKS[n]
+ hook_lines += f"if(d.method==='{method}'&&window.{cb}){{window.{cb}(d.params);}}"
+
+ # CSP violation listener (dev-only, opt-in)
+ csp_debug = ""
+ if opts.debug_csp_violations:
+ csp_debug = (
+ "document.addEventListener('securitypolicyviolation',function(e){"
+ "console.warn('[widget CSP violation]',{"
+ "blockedURI:e.blockedURI,"
+ "violatedDirective:e.violatedDirective,"
+ "originalPolicy:e.originalPolicy"
+ "});});"
+ )
+
+ # Script template parts (long JS strings, cannot break mid-expression)
+ notify_size = ( # noqa: E501
+ "function notifySize(){window.parent.postMessage("
+ "{jsonrpc:'2.0',method:'ui/notifications/size-changed',"
+ "params:{height:document.body.scrollHeight}},'*');}"
+ )
+ on_init = (
+ "if(d.id===id&&d.result){window.parent.postMessage("
+ "{jsonrpc:'2.0',method:'ui/notifications/initialized'},'*');"
+ "setTimeout(notifySize,100);}"
+ )
+ post_init = (
+ "window.parent.postMessage({jsonrpc:'2.0',id:id,"
+ f"method:'ui/initialize',params:{{protocolVersion:'{MCP_PROTOCOL_VERSION}',"
+ f"appInfo:{{name:'{name}',version:'{version}'}},"
+ f"appCapabilities:{caps_json}}}}},'*');"
+ )
+
+ script = (
+ ""
+ )
+
+ # Inject before