diff --git a/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md
new file mode 100644
index 000000000000..3611c24a4576
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Release History
+
+## 1.0.0b1 (2026-06-09)
+
+### Features Added
+
+- Initial preview release of `azure-ai-agentserver-activity`.
+- `ActivityAgentServerHost` — Starlette-based host for Activity Protocol traffic.
+- `POST /activity/messages` and `POST /api/messages` endpoints with Foundry platform header contract.
+- Decorator API: `@app.activity(type)` and `@app.error` for zero-config handler registration.
+- Custom handler support: `ActivityAgentServerHost(handler=fn)` for full M365 SDK control.
+- Auto-initialization of M365 Agents SDK from environment variables (decorator mode).
+- MSAL auth patches for Foundry container MAIB auth (`apply_msal_patches()`).
+- Session ID resolution (query param → header → config → UUID fallback).
+- Activity ID and session ID sanitization for header injection defense.
+- OpenTelemetry distributed tracing and W3C Baggage propagation.
+- Error-source classification (`x-platform-error-source`) on all error responses.
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/LICENSE b/sdk/agentserver/azure-ai-agentserver-activity/LICENSE
new file mode 100644
index 000000000000..4c3581d3b052
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/LICENSE
@@ -0,0 +1,21 @@
+Copyright (c) Microsoft Corporation.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in b/sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in
new file mode 100644
index 000000000000..61da8b18f0e1
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/MANIFEST.in
@@ -0,0 +1,8 @@
+include *.md
+include LICENSE
+recursive-include tests *.py
+recursive-include samples *.py *.md
+include azure/__init__.py
+include azure/ai/__init__.py
+include azure/ai/agentserver/__init__.py
+include azure/ai/agentserver/activity/py.typed
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/README.md b/sdk/agentserver/azure-ai-agentserver-activity/README.md
new file mode 100644
index 000000000000..b741145bad35
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/README.md
@@ -0,0 +1,117 @@
+# Azure AI Agent Server Activity client library for Python
+
+The `azure-ai-agentserver-activity` package provides the Foundry container integration host for Activity Protocol traffic in Azure AI Hosted Agent containers. It plugs into [`azure-ai-agentserver-core`](https://pypi.org/project/azure-ai-agentserver-core/) and exposes a protocol endpoint with Foundry-required header, tracing, and error behavior.
+
+## Getting started
+
+### Install the package
+
+```bash
+pip install azure-ai-agentserver-activity
+```
+
+### Prerequisites
+
+- Python 3.10 or later
+
+## Key concepts
+
+### ActivityAgentServerHost
+
+`ActivityAgentServerHost` is an `AgentServerHost` subclass for Activity Protocol traffic. It provides:
+
+- `POST /activity/messages` for inbound activities.
+
+### Usage patterns
+
+**Decorator-based (recommended)** — zero SDK wiring:
+
+```python
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+app = ActivityAgentServerHost()
+
+@app.activity("message")
+async def on_message(context, state):
+ await context.send_activity(f"Echo: {context.activity.text}")
+
+@app.error
+async def on_error(context, error):
+ await context.send_activity(f"Error: {error}")
+
+app.run()
+```
+
+**Custom handler** — full control over the M365 SDK pipeline:
+
+```python
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+async def handle(request):
+ activity = request.state.activity # parsed dict
+ # Custom processing...
+ return Response(status_code=202)
+
+app = ActivityAgentServerHost(handler=handle)
+app.run()
+```
+
+### Request header contract
+
+`POST /activity/messages` consumes:
+
+- `x-agent-session-id` (preferred session source)
+- `x-agent-conversation-id`
+- `x-agent-user-isolation-key` and `x-agent-chat-isolation-key`
+- `traceparent`, `tracestate`, and `baggage`
+
+### Public API
+
+- `ActivityAgentServerHost` — the host class
+- `apply_msal_patches()` — patches M365 SDK MSAL auth for Foundry containers (UserManagedIdentity with fmi_path)
+
+## Examples
+
+See the [samples directory](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/agentserver/azure-ai-agentserver-activity/samples) for runnable scenarios:
+
+- `simple_activity_agent` — echo bot with welcome, invoke, installation events
+- `streaming_activity_agent` — Azure OpenAI streaming via `context.streaming_response`
+- `cards_activity_agent` — Adaptive Cards, Hero, Thumbnail, Receipt cards
+- `auto_signin_activity_agent` — OAuth auto sign-in with Graph and GitHub
+- `semantic_kernel_activity_agent` — Semantic Kernel agent with tools and multi-turn
+- `suggested_actions_activity_agent` — quick-reply buttons
+
+## Troubleshooting
+
+### 403 Forbidden from Teams Developer Portal
+
+When configuring blueprint backend, ensure you have the correct Azure authentication scope:
+
+```bash
+az login --scope https://dev.teams.microsoft.com/.default
+```
+
+If using `configure-blueprint-backend.ps1`, load environment variables from your `.azure` directory before running the script.
+
+### Missing environment variables
+
+Ensure all required azd environment variables are set before running scripts:
+
+```bash
+azd env get-values
+```
+
+## Next steps
+
+- Review the [Azure AI Hosted Agent documentation](https://aka.ms/azsdk/foundry/hosted-agents)
+- Explore the [Activity Protocol specification](https://aka.ms/azsdk/foundry/activity-protocol)
+- Check the [`azure-ai-agentserver-core`](https://pypi.org/project/azure-ai-agentserver-core/) package for base host functionality
+- Learn about deployment patterns in [Foundry quickstarts](https://aka.ms/azsdk/foundry/quickstarts)
+
+## Contributing
+
+This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit [https://cla.microsoft.com](https://cla.microsoft.com).
+
+When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.
+
+This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py
new file mode 100644
index 000000000000..8db66d3d0f0f
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/__init__.py
@@ -0,0 +1 @@
+__path__ = __import__("pkgutil").extend_path(__path__, __name__)
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py
new file mode 100644
index 000000000000..8db66d3d0f0f
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/__init__.py
@@ -0,0 +1 @@
+__path__ = __import__("pkgutil").extend_path(__path__, __name__)
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py
new file mode 100644
index 000000000000..8db66d3d0f0f
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/__init__.py
@@ -0,0 +1 @@
+__path__ = __import__("pkgutil").extend_path(__path__, __name__)
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py
new file mode 100644
index 000000000000..eceab9b77da9
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/__init__.py
@@ -0,0 +1,40 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""Activity protocol host for Azure AI Hosted Agents.
+
+This package provides an activity protocol host as a subclass of
+:class:`~azure.ai.agentserver.core.AgentServerHost`.
+
+Decorator-based usage (recommended)::
+
+ from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+ app = ActivityAgentServerHost()
+
+ @app.activity("message")
+ async def on_message(context, state):
+ await context.send_activity(f"Echo: {context.activity.text}")
+
+ app.run()
+
+Custom handler usage::
+
+ from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+ async def handle(request):
+ activity = request.state.activity
+ # Custom processing...
+ return Response(status_code=202)
+
+ app = ActivityAgentServerHost(handler=handle)
+ app.run()
+"""
+__path__ = __import__("pkgutil").extend_path(__path__, __name__)
+
+from ._activity import ActivityAgentServerHost
+from ._m365_bridge import _apply_msal_patches as apply_msal_patches
+from ._version import VERSION
+
+__all__ = ["ActivityAgentServerHost", "apply_msal_patches"]
+__version__ = VERSION
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py
new file mode 100644
index 000000000000..8582ccbd7cd8
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_activity.py
@@ -0,0 +1,621 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""Activity protocol host for Azure AI Hosted Agents.
+
+Provides the activity protocol endpoint as a
+:class:`~azure.ai.agentserver.core.AgentServerHost` subclass.
+"""
+
+import contextvars
+import inspect
+import logging
+import os
+import re as _re
+import threading
+import uuid
+from collections.abc import Awaitable, Callable
+from typing import Any, Optional
+
+from opentelemetry import baggage as _otel_baggage
+from opentelemetry import context as _otel_context
+from opentelemetry import trace as _otel_trace
+from opentelemetry.context import Token
+from starlette.requests import Request
+from starlette.responses import Response
+from starlette.routing import Route
+
+from azure.ai.agentserver.core import AgentServerHost, create_error_response
+from azure.ai.agentserver.core._platform_headers import (
+ CHAT_ISOLATION_KEY,
+ ERROR_DETAIL,
+ ERROR_SOURCE,
+ MAX_ERROR_DETAIL_LENGTH,
+ PLATFORM_ERROR_TAG,
+ USER_ISOLATION_KEY,
+)
+
+from ._constants import ActivityConstants
+
+logger = logging.getLogger("azure.ai.agentserver")
+
+_ERROR_SOURCE_UPSTREAM: str = "upstream"
+_ERROR_SOURCE_PLATFORM: str = "platform"
+
+
+_session_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_session_id", default="")
+_user_isolation_key_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_user_isolation_key", default="")
+_chat_isolation_key_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_chat_isolation_key", default="")
+_protocol_var: contextvars.ContextVar[str] = contextvars.ContextVar("activity_protocol", default=ActivityConstants.PROTOCOL)
+
+
+def _enrich_record(record: logging.LogRecord) -> None:
+ """Populate activity scope fields on a log record from the current context.
+
+ :param record: The log record to enrich.
+ """
+ if not hasattr(record, "SessionId"):
+ record.SessionId = _session_id_var.get("") # type: ignore[attr-defined]
+ if not hasattr(record, "UserIsolationKey"):
+ record.UserIsolationKey = _user_isolation_key_var.get("") # type: ignore[attr-defined]
+ if not hasattr(record, "ChatIsolationKey"):
+ record.ChatIsolationKey = _chat_isolation_key_var.get("") # type: ignore[attr-defined]
+ if not hasattr(record, "Protocol"):
+ record.Protocol = _protocol_var.get(ActivityConstants.PROTOCOL) # type: ignore[attr-defined]
+
+
+class _ActivityLogFilter(logging.Filter):
+ """Attach per-turn structured scope fields to a log record (legacy filter).
+
+ Retained for backwards compatibility. The primary enrichment mechanism is
+ the global log-record factory installed by :func:`_ensure_log_enrichment`,
+ which guarantees that records emitted by *any* logger (not just this
+ package's logger) carry the activity scope fields.
+ """
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ _enrich_record(record)
+ return True
+
+
+_log_enrichment_lock = threading.Lock()
+_log_enrichment_installed = False
+_base_record_factory: Optional[Callable[..., logging.LogRecord]] = None
+
+
+def _ensure_log_enrichment() -> None:
+ """Install a global log-record factory once.
+
+ Ensures every log record (regardless of which logger emits it) carries the
+ activity scope fields read from the current context. This provides session /
+ isolation / protocol correlation across the app logger, the M365 SDK
+ loggers, azure.identity, connector clients, etc. — not just this package's
+ own logger.
+ """
+ global _log_enrichment_installed, _base_record_factory # pylint: disable=global-statement
+ if _log_enrichment_installed:
+ return
+ with _log_enrichment_lock:
+ if _log_enrichment_installed:
+ return
+ _base_record_factory = logging.getLogRecordFactory()
+
+ def _factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
+ record = _base_record_factory(*args, **kwargs) # type: ignore[misc]
+ _enrich_record(record)
+ return record
+
+ logging.setLogRecordFactory(_factory)
+ _log_enrichment_installed = True
+
+
+try: # SDK SpanProcessor provides the full interface (incl. _on_ending) the SDK calls.
+ from opentelemetry.sdk.trace import SpanProcessor as _OtelSpanProcessor
+except Exception: # pylint: disable=broad-exception-caught
+ _OtelSpanProcessor = object # type: ignore[assignment, misc]
+
+
+class _BaggageSpanProcessor(_OtelSpanProcessor): # type: ignore[valid-type, misc]
+ """SpanProcessor that copies OTel baggage entries onto every span at start.
+
+ Baggage propagates request-scoped correlation values (session_id,
+ conversation_id, activity_id, isolation keys, x_request_id, plus the
+ platform-provided user / agent / tenant ids) through the context, but those
+ values are *not* automatically recorded as span attributes. This processor
+ promotes them so every child span produced during a turn (auth, connector,
+ send-activity, GenAI, etc.) is filterable by the same correlation keys.
+
+ Subclasses the SDK ``SpanProcessor`` so the full processor interface
+ (``on_start``, ``on_end``, ``_on_ending``, ``shutdown``, ``force_flush``)
+ is satisfied; the SDK invokes ``_on_ending`` on every registered processor
+ during ``span.end()``.
+ """
+
+ def on_start(self, span: Any, parent_context: Optional[Any] = None) -> None:
+ try:
+ ctx = parent_context if parent_context is not None else _otel_context.get_current()
+ for key, value in _otel_baggage.get_all(ctx).items():
+ if value is not None:
+ span.set_attribute(key, value)
+ except Exception: # pylint: disable=broad-exception-caught
+ pass
+
+ def on_end(self, span: Any) -> None:
+ pass
+
+ def shutdown(self) -> None:
+ pass
+
+ def force_flush(self, timeout_millis: int = 30000) -> bool: # pylint: disable=unused-argument
+ return True
+
+
+_baggage_processor_lock = threading.Lock()
+_baggage_processor_installed = False
+
+
+def _ensure_baggage_span_processor() -> None:
+ """Register the baggage->span-attribute processor on the tracer provider once.
+
+ Safe to call repeatedly; if the provider is not yet an SDK provider (e.g.
+ still the API default at first request), installation is retried on a later
+ call.
+ """
+ global _baggage_processor_installed # pylint: disable=global-statement
+ if _baggage_processor_installed:
+ return
+ with _baggage_processor_lock:
+ if _baggage_processor_installed:
+ return
+ try:
+ provider = _otel_trace.get_tracer_provider()
+ add_span_processor = getattr(provider, "add_span_processor", None)
+ if callable(add_span_processor):
+ add_span_processor(_BaggageSpanProcessor())
+ _baggage_processor_installed = True
+ except Exception: # pylint: disable=broad-exception-caught
+ pass
+
+
+def _apply_error_source_headers(
+ headers: dict[str, str],
+ error_source: str,
+ error_detail: Optional[str] = None,
+) -> dict[str, str]:
+ """Return a new dict with error source classification headers merged in.
+
+ :param headers: Base headers to merge into.
+ :param error_source: The error source value (user/platform/upstream).
+ :param error_detail: Optional detail string for platform errors.
+ :return: A new dict containing the original headers plus error source headers.
+ """
+ merged = {**headers, ERROR_SOURCE: error_source}
+ if error_detail:
+ merged[ERROR_DETAIL] = error_detail
+ return merged
+
+
+_SAFE_ID_PATTERN = _re.compile(r"^[a-zA-Z0-9\-_.:]+$")
+_MAX_ID_LENGTH = 256
+
+
+def _sanitize_id(value: str) -> str:
+ """Validate an ID for safe use in HTTP headers and logs.
+
+ Accepts alphanumeric characters plus ``-_.:`` up to 256 characters.
+ Returns a fallback UUID for invalid or oversized values.
+
+ :param value: The ID value to sanitize.
+ :return: A sanitized ID or a UUID string.
+ :rtype: str
+ """
+ if not value or len(value) > _MAX_ID_LENGTH or not _SAFE_ID_PATTERN.match(value):
+ return str(uuid.uuid4())
+ return value
+
+
+def _classify_error(exc: BaseException) -> tuple[str, Optional[str]]:
+ """Classify an exception: platform-tagged -> (platform, detail), else -> (upstream, None).
+
+ :param exc: The exception to classify.
+ :return: A tuple of (source, detail) where source is 'platform' or 'upstream'.
+ :rtype: tuple[str, Optional[str]]
+ """
+ if getattr(exc, PLATFORM_ERROR_TAG, False) is True:
+ detail = f"{type(exc).__name__}: {exc}"
+ if len(detail) > MAX_ERROR_DETAIL_LENGTH:
+ suffix = "...[truncated]"
+ detail = detail[: MAX_ERROR_DETAIL_LENGTH - len(suffix)] + suffix
+ return _ERROR_SOURCE_PLATFORM, detail
+ return _ERROR_SOURCE_UPSTREAM, None
+
+
+class ActivityAgentServerHost(AgentServerHost):
+ """Activity protocol host for Azure AI Hosted Agents.
+
+ A :class:`~azure.ai.agentserver.core.AgentServerHost` subclass that adds
+ the activity protocol endpoint at ``POST /activity/messages``. Use the decorator
+ methods to register M365 SDK activity handlers, or pass a custom
+ ``handler`` callable for full control.
+
+ When no ``handler`` is provided, the M365 Agents SDK is auto-initialized
+ from environment variables.
+
+ Usage::
+
+ from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+ app = ActivityAgentServerHost()
+
+ @app.activity("message")
+ async def on_message(context, state):
+ await context.send_activity(f"Echo: {context.activity.text}")
+
+ app.run()
+
+ :param handler: Optional custom handler function. When provided, the
+ decorator API is bypassed and the handler receives the raw Starlette
+ ``Request`` with ``request.state.activity`` set to the parsed
+ activity dict.
+ :type handler: Optional[Callable[[Request], Awaitable[Response]]]
+ """
+
+ _INSTRUMENTATION_SCOPE = "Azure.AI.AgentServer.Activity"
+
+ def __init__(
+ self,
+ *,
+ handler: Optional[Callable[[Request], Awaitable[Response]]] = None,
+ **kwargs: Any,
+ ) -> None:
+ # Initialize default env vars before bridge/app setup.
+ self._initialize_default_env_vars()
+
+ if handler is not None and not inspect.iscoroutinefunction(handler):
+ raise TypeError(
+ f"handler must be an async function, got {type(handler).__name__}. "
+ "Use 'async def' to define your handler."
+ )
+
+ # explicit handler: user owns the processing pipeline
+ # no handler: use built-in M365 bridge + decorators
+ self._handler = handler
+
+ activity_routes: list[Any] = [
+ Route(
+ "/activity/messages",
+ self._create_activity_endpoint,
+ methods=["POST"],
+ name="create_activity",
+ ),
+ Route(
+ "/api/messages",
+ self._create_activity_endpoint,
+ methods=["POST"],
+ name="create_activity_api_messages",
+ ),
+ ]
+
+ existing = list(kwargs.pop("routes", None) or [])
+ super().__init__(routes=existing + activity_routes, **kwargs)
+
+ # ------------------------------------------------------------------
+ # Handler decorators
+ # ------------------------------------------------------------------
+
+ def _initialize_default_env_vars(self) -> None:
+ """Initialize connection-related env vars used by the M365 SDK.
+
+ Precedence order is:
+ 1. Existing explicit connection env vars
+ 2. Values derived from Foundry-native env vars
+ 3. Static defaults for non-critical options
+ """
+
+ def _get_nonempty(name: str) -> str:
+ return os.environ.get(name, "").strip()
+
+ def _set_if_missing(name: str, value: str) -> None:
+ if value and not _get_nonempty(name):
+ os.environ[name] = value
+
+ defaults = {
+ "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHTYPE": "UserManagedIdentity",
+ "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES__0": "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default",
+ "CONNECTIONSMAP__0__SERVICEURL": "*",
+ "CONNECTIONSMAP__0__CONNECTION": "SERVICE_CONNECTION",
+ }
+ for key, value in defaults.items():
+ _set_if_missing(key, value)
+
+ foundry_blueprint_client_id = _get_nonempty("FOUNDRY_AGENT_BLUEPRINT_CLIENT_ID")
+ foundry_tenant_id = _get_nonempty("FOUNDRY_AGENT_TENANT_ID")
+
+ _set_if_missing(
+ "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID",
+ foundry_blueprint_client_id,
+ )
+ _set_if_missing(
+ "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID",
+ foundry_tenant_id,
+ )
+ _set_if_missing(
+ "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__AUTHORITY",
+ f"https://login.microsoftonline.com/{foundry_tenant_id}" if foundry_tenant_id else "",
+ )
+
+ def activity(self, activity_type: str):
+ """Register a handler for a specific activity type.
+
+ Usage::
+
+ @app.activity("message")
+ async def on_message(context, state):
+ await context.send_activity(f"Echo: {context.activity.text}")
+
+ :param activity_type: The activity type to handle (e.g., "message", "invoke").
+ :type activity_type: str
+ :return: A decorator function.
+ :rtype: Callable
+ """
+ def decorator(fn):
+ from ._m365_bridge import _get_or_create_lazy_app
+ lazy_app = _get_or_create_lazy_app()
+ lazy_app.activity(activity_type)(fn)
+ # Wire up the bridge handler if not already set
+ if self._handler is None:
+ from ._m365_bridge import create_bridge_handler
+ self._handler = create_bridge_handler
+ return fn
+ return decorator
+
+ def error(self, fn):
+ """Register an error handler.
+
+ Usage::
+
+ @app.error
+ async def on_error(context, error):
+ await context.send_activity(f"Error: {error}")
+
+ :param fn: Async error handler function.
+ :type fn: Callable
+ :return: The error handler function.
+ :rtype: Callable
+ """
+ from ._m365_bridge import _get_or_create_lazy_app
+ lazy_app = _get_or_create_lazy_app()
+ lazy_app.error(fn)
+ if self._handler is None:
+ from ._m365_bridge import create_bridge_handler
+ self._handler = create_bridge_handler
+ return fn
+
+ def _resolve_session_id(self, request: Request) -> str:
+ query_session_id = request.query_params.get("agent_session_id")
+ if query_session_id and query_session_id.strip():
+ return query_session_id.strip()
+
+ header_id = request.headers.get(ActivityConstants.SESSION_ID_HEADER)
+ if header_id and header_id.strip():
+ return header_id.strip()
+
+ if self.config.session_id and self.config.session_id.strip():
+ return self.config.session_id.strip()
+
+ return str(uuid.uuid4())
+
+ def _add_required_response_headers(self, response: Response, session_id: str) -> None:
+ response.headers[ActivityConstants.SESSION_ID_HEADER] = session_id
+
+ @staticmethod
+ def _response_body_preview(response: Response, limit: int = 1024) -> str:
+ """Return a truncated text preview of a response body for logging.
+
+ :param response: The response object.
+ :type response: Response
+ :param limit: Maximum number of characters to return.
+ :type limit: int
+ :return: A text preview of the response body.
+ :rtype: str
+ """
+ body = getattr(response, "body", None)
+ if not body:
+ return ""
+ try:
+ if isinstance(body, (bytes, bytearray)):
+ text = bytes(body).decode("utf-8", errors="replace")
+ else:
+ text = str(body)
+ except Exception: # pylint: disable=broad-exception-caught
+ return ""
+ return text[:limit]
+
+ async def _create_activity_endpoint(self, request: Request) -> Response:
+ """Handle inbound POST to /activity/messages or /api/messages.
+
+ Processes activity protocol requests, manages context variables,
+ ensures logging enrichment, and orchestrates the activity handler.
+
+ :param request: The inbound HTTP request.
+ :type request: Request
+ :return: The HTTP response.
+ :rtype: Response
+ """
+ # Resolve correlation identifiers from headers up-front so that every
+ # log line and span produced during this turn carries the values.
+ inbound_conversation_id = request.headers.get(ActivityConstants.CONVERSATION_ID_HEADER, "")
+ inbound_user_isolation_key = request.headers.get(USER_ISOLATION_KEY, "")
+ inbound_chat_isolation_key = request.headers.get(CHAT_ISOLATION_KEY, "")
+ session_id = _sanitize_id(self._resolve_session_id(request))
+ request_trace_id = request.headers.get("x-request-id", "").strip()
+
+ # Install global log/trace enrichment once, then bind the context vars so
+ # the scope fields are populated for the very first log line of the turn.
+ _ensure_log_enrichment()
+ _ensure_baggage_span_processor()
+ session_token = _session_id_var.set(session_id)
+ user_token = _user_isolation_key_var.set(inbound_user_isolation_key)
+ chat_token = _chat_isolation_key_var.set(inbound_chat_isolation_key)
+ protocol_token = _protocol_var.set(ActivityConstants.PROTOCOL)
+ baggage_token: Optional[Token[Any]] = None
+
+ try:
+ logger.debug(
+ "Activity endpoint hit | method=%s | path=%s | query=%s | content-type=%s",
+ request.method, request.url.path, str(request.query_params),
+ request.headers.get("content-type", ""),
+ )
+ logger.debug("Activity endpoint headers: %s", dict(request.headers))
+
+ try:
+ payload = await request.json()
+ except Exception: # pylint: disable=broad-exception-caught
+ logger.warning(
+ "Activity request rejected | reason=invalid_json | session_id=%s", session_id
+ )
+ response = create_error_response(
+ "invalid_request",
+ "Request body must be valid JSON",
+ status_code=400,
+ headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM),
+ )
+ self._add_required_response_headers(response, session_id)
+ return response
+
+ if not isinstance(payload, dict):
+ logger.warning(
+ "Activity request rejected | reason=non_object_payload | session_id=%s", session_id
+ )
+ response = create_error_response(
+ "invalid_request",
+ "Activity payload must be a JSON object",
+ status_code=400,
+ headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM),
+ )
+ self._add_required_response_headers(response, session_id)
+ return response
+
+ activity_id = payload.get("id", "") if isinstance(payload.get("id"), str) else ""
+ if not activity_id.strip():
+ activity_id = str(uuid.uuid4())
+ else:
+ activity_id = _sanitize_id(activity_id)
+
+ # Extract conversation ID from Activity payload (Bot Framework schema),
+ # falling back to the inbound conversation header if absent.
+ conversation_obj = payload.get("conversation", {})
+ conversation_id = ""
+ if isinstance(conversation_obj, dict):
+ conversation_id = conversation_obj.get("id", "")
+ if conversation_id and isinstance(conversation_id, str):
+ conversation_id = conversation_id.strip()
+ if not conversation_id and inbound_conversation_id:
+ conversation_id = inbound_conversation_id.strip()
+
+ # Pull common request details for logging / span events.
+ from_obj = payload.get("from", {})
+ from_id = from_obj.get("id", "") if isinstance(from_obj, dict) else ""
+ recipient_obj = payload.get("recipient", {})
+ recipient_id = recipient_obj.get("id", "") if isinstance(recipient_obj, dict) else ""
+ activity_type = payload.get("type", "") or ""
+ channel_id = payload.get("channelId", "") or ""
+ service_url = payload.get("serviceUrl", "") or ""
+ locale = payload.get("locale", "") or ""
+ request_text = str(payload.get("text", "") or "")
+
+ request.state.activity = payload
+ request.state.activity_id = activity_id
+ request.state.session_id = session_id
+ request.state.user_isolation_key = inbound_user_isolation_key
+ request.state.chat_isolation_key = inbound_chat_isolation_key
+
+ logger.info(
+ "Activity request received | type=%s | activity_id=%s | conversation_id=%s | "
+ "session_id=%s | from=%s | recipient=%s | channelId=%s | serviceUrl=%s | "
+ "locale=%s | x_request_id=%s | text=%s",
+ activity_type, activity_id, conversation_id, session_id, from_id, recipient_id,
+ channel_id, service_url, locale, request_trace_id, request_text[:512],
+ )
+
+ baggage_ctx = _otel_context.get_current()
+ # Set all required baggage keys per spec section 3.3.
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.session_id", session_id or "", context=baggage_ctx
+ )
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.protocol", ActivityConstants.PROTOCOL, context=baggage_ctx
+ )
+ if conversation_id:
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.conversation_id", conversation_id, context=baggage_ctx
+ )
+ if activity_id:
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.activity_id", activity_id, context=baggage_ctx
+ )
+ if inbound_user_isolation_key:
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.user_isolation_key", inbound_user_isolation_key, context=baggage_ctx
+ )
+ if inbound_chat_isolation_key:
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.chat_isolation_key", inbound_chat_isolation_key, context=baggage_ctx
+ )
+ if request_trace_id:
+ baggage_ctx = _otel_baggage.set_baggage(
+ "azure.ai.agentserver.x_request_id", request_trace_id, context=baggage_ctx
+ )
+ baggage_token = _otel_context.attach(baggage_ctx)
+
+
+ try:
+ if self._handler is None:
+ raise NotImplementedError(
+ "No activity handler registered. Use the @app.activity() decorator "
+ "or pass a handler= callable to ActivityAgentServerHost()."
+ )
+ response: Response = await self._handler(request) # type: ignore[assignment]
+
+ response.headers[ActivityConstants.ACTIVITY_ID_HEADER] = activity_id
+ self._add_required_response_headers(response, session_id)
+
+ # Record the outbound response as a structured log.
+ status_code = getattr(response, "status_code", 0)
+ response_text = self._response_body_preview(response)
+ logger.info(
+ "Activity response sent | status_code=%s | activity_id=%s | "
+ "conversation_id=%s | session_id=%s | body=%s",
+ status_code, activity_id, conversation_id, session_id, response_text,
+ )
+ return response
+ except Exception as exc: # pylint: disable=broad-exception-caught
+ error_source, error_detail = _classify_error(exc)
+ logger.error(
+ "Activity request failed | activity_id=%s | conversation_id=%s | "
+ "session_id=%s | error_source=%s | error=%s",
+ activity_id, conversation_id, session_id, error_source, exc, exc_info=True,
+ )
+
+ response = create_error_response(
+ "internal_error",
+ "Internal server error",
+ status_code=500,
+ headers=_apply_error_source_headers(
+ {ActivityConstants.ACTIVITY_ID_HEADER: activity_id},
+ error_source,
+ error_detail,
+ ),
+ )
+ self._add_required_response_headers(response, session_id)
+ return response
+ finally:
+ _session_id_var.reset(session_token)
+ _user_isolation_key_var.reset(user_token)
+ _chat_isolation_key_var.reset(chat_token)
+ _protocol_var.reset(protocol_token)
+ if baggage_token is not None:
+ try:
+ _otel_context.detach(baggage_token)
+ except ValueError:
+ pass
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py
new file mode 100644
index 000000000000..49b47565d763
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_constants.py
@@ -0,0 +1,26 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+from azure.ai.agentserver.core._platform_headers import SESSION_ID as _SESSION_ID # pylint: disable=import-error,no-name-in-module
+
+
+class ActivityConstants:
+ """Activity protocol constants.
+
+ Protocol-specific headers and telemetry attribute keys for activity
+ endpoint handling. Cross-cutting header names (for example session ID)
+ are imported from :mod:`azure.ai.agentserver.core._platform_headers`.
+ """
+
+ PROTOCOL = "activity"
+
+ # Request / response headers
+ ACTIVITY_ID_HEADER = "x-agent-activity-id"
+ SESSION_ID_HEADER = _SESSION_ID
+ CONVERSATION_ID_HEADER = "x-agent-conversation-id"
+
+ # Span attribute keys
+ ATTR_SPAN_SESSION_ID = "azure.ai.agentserver.activity.session_id"
+ ATTR_SPAN_PROTOCOL = "azure.ai.agentserver.activity.protocol"
+ ATTR_SPAN_ERROR_CODE = "azure.ai.agentserver.activity.error.code"
+ ATTR_SPAN_ERROR_MESSAGE = "azure.ai.agentserver.activity.error.message"
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py
new file mode 100644
index 000000000000..fa13f1a8243f
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_m365_bridge.py
@@ -0,0 +1,267 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""M365 Agents SDK bridge for the Activity protocol host.
+
+Provides auto-initialization of the M365 Agents SDK stack from
+environment variables, MSAL auth patches for Foundry containers,
+and a bridge function that converts activity dicts into M365 SDK
+turn processing.
+
+This module is used internally by :class:`ActivityAgentServerHost`
+when decorator-based handlers are registered. Users who pass their
+own ``handler`` callable bypass this module entirely.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from typing import Any, Optional
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+
+logger = logging.getLogger("azure.ai.agentserver")
+
+# Lazy imports — these are only needed when the bridge is actually used.
+# This avoids hard dependency failures if M365 SDK isn't installed.
+_m365_initialized = False
+_adapter = None
+_agent_app = None
+_connection_manager = None
+
+
+def _apply_msal_patches() -> None:
+ """Apply MSAL auth patches for Foundry container MAIB auth.
+
+ When AUTH_TYPE is UserManagedIdentity, the stock MsalAuth uses
+ ManagedIdentityClient which doesn't support fmi_path. This patch
+ replaces get_agentic_application_token with DefaultAzureCredential.
+ """
+ try:
+ from microsoft_agents.authentication.msal.msal_auth import MsalAuth
+ except ImportError:
+ logger.debug("microsoft-agents-authentication-msal not installed; skipping MSAL patches")
+ return
+
+ _PATCH_FLAG = "_activity_sdk_msal_patched"
+ if getattr(MsalAuth, _PATCH_FLAG, False):
+ return
+
+ async def _get_token_via_dac(self, tenant_id: str, agent_app_instance_id: str) -> Optional[str]:
+ from azure.identity.aio import DefaultAzureCredential
+
+ if not agent_app_instance_id:
+ from microsoft_agents.authentication.msal.errors import authentication_errors
+ raise ValueError(str(authentication_errors.AgentApplicationInstanceIdRequired))
+
+ logger.info(
+ "[activity-bridge] Acquiring agentic application token via "
+ "DefaultAzureCredential for agent_app_instance_id=%s",
+ agent_app_instance_id,
+ )
+
+ client_id = getattr(self._msal_configuration, "CLIENT_ID", None)
+ credential_kwargs: dict[str, Any] = {
+ "identity_config": {"fmi_path": agent_app_instance_id},
+ }
+ if client_id:
+ credential_kwargs["managed_identity_client_id"] = client_id
+
+ credential = DefaultAzureCredential(**credential_kwargs)
+ try:
+ token = await credential.get_token("api://AzureADTokenExchange/.default")
+ return token.token
+ except Exception:
+ logger.exception(
+ "Failed to acquire agentic application token for agent_app_instance_id=%s",
+ agent_app_instance_id,
+ )
+ return None
+ finally:
+ try:
+ await credential.close()
+ except Exception:
+ pass
+
+ MsalAuth.get_agentic_application_token = _get_token_via_dac
+ setattr(MsalAuth, _PATCH_FLAG, True)
+ logger.info("Patched MsalAuth.get_agentic_application_token → DefaultAzureCredential")
+
+
+def _ensure_m365_initialized():
+ """Lazily initialize the M365 Agents SDK from environment variables.
+
+ Called on first request when decorators are used. Idempotent.
+ """
+ global _m365_initialized, _adapter, _agent_app, _connection_manager
+
+ if _m365_initialized:
+ return _agent_app, _adapter
+
+ try:
+ from microsoft_agents.activity import Activity, load_configuration_from_env
+ from microsoft_agents.authentication.msal import MsalConnectionManager
+ from microsoft_agents.hosting.core import (
+ AgentApplication,
+ Authorization,
+ HttpAdapterBase,
+ MemoryStorage,
+ RestChannelServiceClientFactory,
+ TurnState,
+ )
+ except ImportError as exc:
+ raise ImportError(
+ "Activity decorator handlers require the M365 Agents SDK. "
+ "Install: pip install microsoft-agents-hosting-core "
+ "microsoft-agents-authentication-msal microsoft-agents-activity azure-identity"
+ ) from exc
+
+ # Apply MSAL patches before any MsalConnectionManager is created
+ _apply_msal_patches()
+
+ logger.info("Initializing M365 Agents SDK...")
+ config = load_configuration_from_env(os.environ)
+ storage = MemoryStorage()
+ _connection_manager = MsalConnectionManager(**config)
+ client_factory = RestChannelServiceClientFactory(_connection_manager)
+ _adapter = HttpAdapterBase(channel_service_client_factory=client_factory)
+ authorization = Authorization(storage, _connection_manager, **config)
+ _agent_app = AgentApplication[TurnState](
+ storage=storage,
+ adapter=_adapter,
+ authorization=authorization,
+ **config,
+ )
+ _m365_initialized = True
+ logger.info("M365 Agents SDK initialized successfully.")
+ return _agent_app, _adapter
+
+
+async def create_bridge_handler(request: Request) -> Response:
+ """Built-in bridge handler for decorator-based agents.
+
+ Converts the activity dict (set by ActivityAgentServerHost on
+ request.state) into an M365 SDK Activity and processes it through
+ the AgentApplication turn pipeline.
+
+ On first call, initializes the M365 SDK and replays any pending
+ handler registrations captured by the lazy proxy.
+ """
+ from microsoft_agents.activity import Activity
+ from microsoft_agents.hosting.core import ClaimsIdentity
+
+ global _lazy_agent_app
+ agent_app, adapter = _ensure_m365_initialized()
+
+ # Replay pending decorator registrations onto the real AgentApplication
+ if _lazy_agent_app is not None and not _lazy_agent_app._replayed:
+ _lazy_agent_app._replay_on(agent_app)
+
+ activity_dict = request.state.activity
+ activity_type = activity_dict.get("type", "unknown")
+ session_id = request.state.session_id
+
+ logger.info(
+ "Bridge: activity received | type=%s | session=%s",
+ activity_type, session_id,
+ )
+ logger.debug(
+ "Bridge: activity details | conversation=%s | serviceUrl=%s | channelId=%s | from=%s",
+ activity_dict.get("conversation", {}).get("id", "?") if isinstance(activity_dict.get("conversation"), dict) else "?",
+ activity_dict.get("serviceUrl", ""),
+ activity_dict.get("channelId", ""),
+ activity_dict.get("from", {}).get("id", "?") if isinstance(activity_dict.get("from"), dict) else "?",
+ )
+
+ activity = Activity.model_validate(activity_dict)
+
+ if not activity.type or not activity.conversation or not activity.conversation.id:
+ logger.warning(
+ "Bridge: rejecting activity with 400 | type=%s | has_conversation=%s | conversation_id=%s",
+ activity.type, activity.conversation is not None,
+ activity.conversation.id if activity.conversation else "None",
+ )
+ return JSONResponse(
+ status_code=400,
+ content={"error": {"code": "invalid_request", "message": "Activity must have type and conversation.id"}},
+ )
+
+ claims = ClaimsIdentity({}, is_authenticated=False, authentication_type="Anonymous")
+
+ try:
+ invoke_response = await adapter.process_activity(claims, activity, agent_app.on_turn)
+ except PermissionError:
+ logger.error("Permission denied processing activity | type=%s", activity_type)
+ return Response(status_code=401)
+ except TypeError as exc:
+ logger.warning("TypeError processing activity (likely missing serviceUrl) | type=%s | error=%s", activity_type, exc)
+ return Response(status_code=202)
+ except Exception:
+ # Re-raise so the outer _create_activity_endpoint can classify
+ # the error and return 500 with proper x-platform-error-source.
+ raise
+
+ if activity.type == "invoke" or activity.delivery_mode == "expectReplies":
+ if invoke_response is not None:
+ return JSONResponse(content=invoke_response.body, status_code=invoke_response.status)
+ return JSONResponse(content={}, status_code=200)
+
+ return Response(status_code=202)
+
+
+class _LazyAgentApp:
+ """Proxy that defers AgentApplication access until first request."""
+
+ def __init__(self):
+ self._pending_registrations: list = []
+ self._replayed = False
+
+ def activity(self, activity_type: str):
+ """Capture an activity handler registration for later replay."""
+ def decorator(fn):
+ self._pending_registrations.append(("activity", activity_type, fn))
+ return fn
+ return decorator
+
+ def error(self, fn):
+ """Capture an error handler registration for later replay."""
+ self._pending_registrations.append(("error", None, fn))
+ return fn
+
+ def _replay_on(self, agent_app):
+ """Replay all captured registrations onto the real AgentApplication.
+
+ Idempotent — only replays once even if called concurrently.
+ """
+ if self._replayed:
+ return
+ self._replayed = True
+ for kind, arg, fn in self._pending_registrations:
+ if kind == "activity":
+ agent_app.activity(arg)(fn)
+ elif kind == "error":
+ agent_app.error(fn)
+ self._pending_registrations.clear()
+
+
+# Module-level lazy proxy — shared across all decorator calls
+_lazy_agent_app: Optional[_LazyAgentApp] = None
+
+
+def _get_or_create_lazy_app() -> _LazyAgentApp:
+ global _lazy_agent_app
+ if _lazy_agent_app is None:
+ _lazy_agent_app = _LazyAgentApp()
+ return _lazy_agent_app
+
+
+def _reset_for_testing() -> None:
+ """Reset all module-level state. For test isolation only."""
+ global _m365_initialized, _adapter, _agent_app, _connection_manager, _lazy_agent_app
+ _m365_initialized = False
+ _adapter = None
+ _agent_app = None
+ _connection_manager = None
+ _lazy_agent_app = None
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py
new file mode 100644
index 000000000000..67d209a8cafd
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/_version.py
@@ -0,0 +1,5 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+
+VERSION = "1.0.0b1"
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/py.typed b/sdk/agentserver/azure-ai-agentserver-activity/azure/ai/agentserver/activity/py.typed
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/cspell.json b/sdk/agentserver/azure-ai-agentserver-activity/cspell.json
new file mode 100644
index 000000000000..c115521f62ea
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/cspell.json
@@ -0,0 +1,15 @@
+{
+ "ignoreWords": [
+ "agentserver",
+ "openapi",
+ "paramtype",
+ "rtype",
+ "starlette"
+ ],
+ "ignorePaths": [
+ "*.csv",
+ "*.json",
+ "*.rst",
+ "samples/**"
+ ]
+}
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt
new file mode 100644
index 000000000000..25c128efb198
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/dev_requirements.txt
@@ -0,0 +1,7 @@
+# keep in sync with pyproject.toml#dependency-groups.dev
+-e ../../../eng/tools/azure-sdk-tools
+-e ../azure-ai-agentserver-core
+pytest
+httpx
+pytest-asyncio
+opentelemetry-sdk>=1.40.0
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml
new file mode 100644
index 000000000000..817a7e856166
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/pyproject.toml
@@ -0,0 +1,75 @@
+[project]
+name = "azure-ai-agentserver-activity"
+dynamic = ["version", "readme"]
+description = "Activity protocol host for Azure AI Hosted Agents"
+requires-python = ">=3.10"
+authors = [
+ { name = "Microsoft Corporation", email = "azpysdkhelp@microsoft.com" },
+]
+license = "MIT"
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3 :: Only",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+]
+keywords = ["azure", "azure sdk", "agent", "agentserver", "activity"]
+
+dependencies = [
+ "azure-ai-agentserver-core>=2.0.0b4",
+ "opentelemetry-api>=1.40.0",
+]
+
+[dependency-groups]
+# keep in sync with dev_requirements.txt
+dev = [
+ "azure-sdk-tools",
+ "httpx",
+ "opentelemetry-sdk>=1.40.0",
+ "pytest-asyncio",
+ "pytest",
+]
+
+[project.urls]
+repository = "https://github.com/Azure/azure-sdk-for-python"
+
+[build-system]
+requires = ["setuptools>=69", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+exclude = [
+ "tests*",
+ "samples*",
+ "doc*",
+ "azure",
+ "azure.ai",
+ "azure.ai.agentserver",
+]
+
+[tool.setuptools.dynamic]
+version = { attr = "azure.ai.agentserver.activity._version.VERSION" }
+readme = { file = ["README.md"], content-type = "text/markdown" }
+
+[tool.setuptools.package-data]
+"azure.ai.agentserver.activity" = ["py.typed"]
+
+[tool.azure-sdk-build]
+analyze_python_version = "3.11"
+breaking = false
+mypy = true
+pyright = true
+verifytypes = false
+latestdependency = false
+mindependency = false
+pylint = true
+type_check_samples = false
+
+[tool.uv.sources]
+azure-ai-agentserver-core = { path = "../azure-ai-agentserver-core", editable = true }
+azure-sdk-tools = { path = "../../../eng/tools/azure-sdk-tools" }
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json b/sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json
new file mode 100644
index 000000000000..f36c5a7fe0d3
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/pyrightconfig.json
@@ -0,0 +1,11 @@
+{
+ "reportOptionalMemberAccess": "warning",
+ "reportArgumentType": "warning",
+ "reportAttributeAccessIssue": "warning",
+ "reportMissingImports": "warning",
+ "reportGeneralTypeIssues": "warning",
+ "reportReturnType": "warning",
+ "exclude": [
+ "**/samples/**"
+ ]
+}
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/main.py
new file mode 100644
index 000000000000..8889b7abebb9
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/main.py
@@ -0,0 +1,41 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Tier 1 - Zero-Config Activity Protocol Agent.
+
+The simplest possible activity protocol agent. The package auto-initializes
+the M365 Agents SDK from environment variables, applies MSAL auth patches,
+and bridges activities to the AgentApplication turn pipeline.
+
+You write only handler logic - no SDK wiring needed.
+"""
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+app = ActivityAgentServerHost()
+
+
+@app.activity("message")
+async def on_message(context, state):
+ """Echo the user's message back."""
+ user_text = context.activity.text or ""
+ if user_text.strip():
+ reply = f"Echo: {user_text}"
+ await context.send_activity(reply)
+
+
+@app.activity("conversationUpdate")
+async def on_members_added(context, state):
+ """Welcome new members."""
+ for member in context.activity.members_added or []:
+ if member.id != context.activity.recipient.id:
+ await context.send_activity(f"Welcome, {member.name}!")
+
+
+@app.error
+async def on_error(context, error):
+ """Handle unhandled errors."""
+ await context.send_activity(f"Sorry, something went wrong: {error}")
+
+
+if __name__ == "__main__":
+ app.run()
\ No newline at end of file
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/requirements.txt
new file mode 100644
index 000000000000..67656cca36bc
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/echo-agent/requirements.txt
@@ -0,0 +1,4 @@
+microsoft-agents-hosting-core
+microsoft-agents-authentication-msal
+microsoft-agents-activity
+azure-identity
\ No newline at end of file
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/main.py
new file mode 100644
index 000000000000..7ec09f023cc2
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/main.py
@@ -0,0 +1,49 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Multi-Protocol Agent - Activity + Invocations.
+
+Demonstrates composing Activity and Invocations protocols on a single
+server using Python mixin inheritance (Tier 2 - Builder pattern).
+
+Both protocols share the same server on port 8088:
+ POST /activity/messages - Activity protocol (Teams/M365)
+ POST /api/messages - Activity protocol (Bot Framework compat)
+ POST /invocations - Invocations protocol (HTTP API)
+"""
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+from azure.ai.agentserver.invocations import InvocationAgentServerHost
+
+
+class MultiProtocolHost(ActivityAgentServerHost, InvocationAgentServerHost):
+ pass
+
+
+app = MultiProtocolHost()
+
+
+@app.activity("message")
+async def on_teams_message(context, state):
+ """Handle messages from Teams via Activity protocol."""
+ user_text = context.activity.text or ""
+ if user_text.strip():
+ await context.send_activity(f"[Multi-Protocol] Echo: {user_text}")
+
+
+@app.invoke_handler
+async def handle_invocation(request: Request) -> Response:
+ """Handle HTTP invocations via Invocations protocol."""
+ data = await request.json()
+ message = data.get("message", "")
+ return JSONResponse({
+ "reply": f"[Multi-Protocol] Echo: {message}",
+ "protocol": "invocations",
+ })
+
+
+if __name__ == "__main__":
+ app.run()
+
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/requirements.txt
new file mode 100644
index 000000000000..bc8bbc5c489e
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/multi-protocol-agent/requirements.txt
@@ -0,0 +1,4 @@
+microsoft-agents-hosting-core
+microsoft-agents-authentication-msal
+microsoft-agents-activity
+azure-identity
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/main.py b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/main.py
new file mode 100644
index 000000000000..542888cfb41f
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/main.py
@@ -0,0 +1,113 @@
+# Copyright (c) Microsoft. All rights reserved.
+
+"""Self-Hosted Activity Agent - Full M365 SDK Control.
+
+Demonstrates the handler pattern (Tier 3) where the developer owns the
+full M365 Agents SDK pipeline: MsalConnectionManager, HttpAdapterBase,
+AgentApplication, and a custom bridge handler.
+
+Use this pattern when you need:
+- Direct access to M365 SDK features (auth_handlers, regex message matching)
+- Custom error handling or response logic
+- Full control over the activity processing pipeline
+"""
+
+from os import environ
+
+from starlette.responses import JSONResponse, Response
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost, apply_msal_patches
+
+from microsoft_agents.activity import Activity, load_configuration_from_env
+from microsoft_agents.authentication.msal import MsalConnectionManager
+from microsoft_agents.hosting.core import (
+ AgentApplication,
+ Authorization,
+ ClaimsIdentity,
+ HttpAdapterBase,
+ MemoryStorage,
+ RestChannelServiceClientFactory,
+ TurnContext,
+ TurnState,
+)
+
+# ── M365 SDK setup ───────────────────────────────────────────────
+# Apply MSAL patches before creating MsalConnectionManager.
+apply_msal_patches()
+
+config = load_configuration_from_env(environ)
+storage = MemoryStorage()
+connection_manager = MsalConnectionManager(**config)
+client_factory = RestChannelServiceClientFactory(connection_manager)
+adapter = HttpAdapterBase(channel_service_client_factory=client_factory)
+authorization = Authorization(storage, connection_manager, **config)
+agent_app = AgentApplication[TurnState](
+ storage=storage,
+ adapter=adapter,
+ authorization=authorization,
+ **config,
+)
+
+
+# ── Business logic ───────────────────────────────────────────────
+
+@agent_app.activity("message")
+async def on_message(context: TurnContext, state: TurnState):
+ """Echo the user's message back."""
+ user_text = context.activity.text or ""
+ await context.send_activity(Activity(type="typing"))
+ reply = f"[Self-Hosted] Echo: {user_text}"
+ await context.send_activity(reply)
+
+
+@agent_app.activity("conversationUpdate")
+async def on_members_added(context: TurnContext, state: TurnState):
+ """Welcome new members."""
+ for member in context.activity.members_added or []:
+ if member.id != context.activity.recipient.id:
+ await context.send_activity(f"Welcome, {member.name}!")
+
+
+@agent_app.error
+async def on_error(context: TurnContext, error: Exception):
+ """Handle unhandled errors."""
+ await context.send_activity("The agent encountered an error.")
+
+
+# ── Foundry host with custom handler ─────────────────────────────
+
+async def handle(request) -> Response:
+ """Bridge to M365 SDK - parses activity and delegates to agent_app."""
+ activity_dict = request.state.activity
+
+ activity = Activity.model_validate(activity_dict)
+
+ if not activity.type or not activity.conversation or not activity.conversation.id:
+ return JSONResponse(
+ status_code=400,
+ content={"error": {"code": "invalid_request", "message": "Missing type or conversation.id"}},
+ )
+
+ claims = ClaimsIdentity({}, is_authenticated=False, authentication_type="Anonymous")
+
+ try:
+ invoke_response = await adapter.process_activity(claims, activity, agent_app.on_turn)
+ except PermissionError:
+ return Response(status_code=401)
+ except TypeError:
+ return Response(status_code=202)
+ except Exception:
+ return Response(status_code=202)
+
+ if activity.type == "invoke" or activity.delivery_mode == "expectReplies":
+ if invoke_response is not None:
+ return JSONResponse(content=invoke_response.body, status_code=invoke_response.status)
+ return JSONResponse(content={}, status_code=200)
+
+ return Response(status_code=202)
+
+
+app = ActivityAgentServerHost(handler=handle)
+
+if __name__ == "__main__":
+ app.run()
\ No newline at end of file
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/requirements.txt b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/requirements.txt
new file mode 100644
index 000000000000..bc8bbc5c489e
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/samples/self-hosted-agent/requirements.txt
@@ -0,0 +1,4 @@
+microsoft-agents-hosting-core
+microsoft-agents-authentication-msal
+microsoft-agents-activity
+azure-identity
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py
new file mode 100644
index 000000000000..395853748275
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/conftest.py
@@ -0,0 +1,27 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""Shared fixtures for activity protocol tests."""
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from starlette.responses import JSONResponse, Response
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+
+def pytest_configure(config):
+ config.addinivalue_line("markers", "tracing_e2e: end-to-end tracing tests against live Application Insights")
+
+
+@pytest.fixture
+async def activity_client():
+ async def on_message(request) -> Response:
+ activity = request.state.activity
+ return JSONResponse({"type": "message", "text": f"echo:{activity.get('text', '')}"})
+
+ app = ActivityAgentServerHost(handler=on_message, configure_observability=None)
+
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ yield client
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py
new file mode 100644
index 000000000000..e973a09b9bce
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_decorator_pattern.py
@@ -0,0 +1,35 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""Tests for the decorator-based activity handler pattern."""
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from starlette.responses import JSONResponse
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+
+@pytest.mark.asyncio
+async def test_decorator_registers_handler():
+ """Verify that @app.activity() wires up the bridge handler."""
+ app = ActivityAgentServerHost(configure_observability=None)
+
+ @app.activity("message")
+ async def on_message(context, state):
+ pass
+
+ # After decorating, _handler should be set to the bridge
+ assert app._handler is not None
+
+
+@pytest.mark.asyncio
+async def test_error_decorator_registers_handler():
+ """Verify that @app.error wires up the bridge handler."""
+ app = ActivityAgentServerHost(configure_observability=None)
+
+ @app.error
+ async def on_error(context, error):
+ pass
+
+ assert app._handler is not None
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py
new file mode 100644
index 000000000000..b52c0b5eaa9e
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_error_source_classification.py
@@ -0,0 +1,50 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""Tests for x-platform-error-source classification in activity endpoints."""
+
+import pytest
+from azure.ai.agentserver.core._platform_headers import ERROR_DETAIL, ERROR_SOURCE, PLATFORM_ERROR_TAG
+from httpx import ASGITransport, AsyncClient
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+
+@pytest.mark.asyncio
+async def test_upstream_handler_error_is_classified_upstream():
+ async def handle(request): # pylint: disable=unused-argument
+ raise RuntimeError("handler bug")
+
+ app = ActivityAgentServerHost(handler=handle, configure_observability=None)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ resp = await client.post(
+ "/activity/messages",
+ json={"type": "message", "text": "hello"},
+ headers={"Authorization": "Bearer test-token", "x-agent-session-id": "session-123"},
+ )
+
+ assert resp.status_code == 500
+ assert resp.headers[ERROR_SOURCE] == "upstream"
+ assert ERROR_DETAIL not in resp.headers
+
+
+@pytest.mark.asyncio
+async def test_platform_tagged_error_is_classified_platform_with_detail():
+ async def handle(request): # pylint: disable=unused-argument
+ exc = RuntimeError("platform storage failure")
+ setattr(exc, PLATFORM_ERROR_TAG, True)
+ raise exc
+
+ app = ActivityAgentServerHost(handler=handle, configure_observability=None)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ resp = await client.post(
+ "/activity/messages",
+ json={"type": "message", "text": "hello"},
+ headers={"Authorization": "Bearer test-token", "x-agent-session-id": "session-123"},
+ )
+
+ assert resp.status_code == 500
+ assert resp.headers[ERROR_SOURCE] == "platform"
+ assert "platform storage failure" in resp.headers[ERROR_DETAIL]
diff --git a/sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py
new file mode 100644
index 000000000000..476a52d4a5c6
--- /dev/null
+++ b/sdk/agentserver/azure-ai-agentserver-activity/tests/test_id_sanitization.py
@@ -0,0 +1,90 @@
+# ---------------------------------------------------------
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# ---------------------------------------------------------
+"""Tests for activity ID sanitization (defense in depth)."""
+
+import uuid
+
+import pytest
+from httpx import ASGITransport, AsyncClient
+from starlette.responses import JSONResponse
+
+from azure.ai.agentserver.activity import ActivityAgentServerHost
+
+
+@pytest.mark.asyncio
+async def test_provided_activity_id_is_used():
+ async def handle(request): # pylint: disable=unused-argument
+ return JSONResponse({"ok": True})
+
+ app = ActivityAgentServerHost(handler=handle, configure_observability=None)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ resp = await client.post(
+ "/activity/messages",
+ json={"type": "message", "text": "hi", "id": "my-activity-123"},
+ headers={"Authorization": "Bearer test-token"},
+ )
+
+ assert resp.status_code == 200
+ assert resp.headers["x-agent-activity-id"] == "my-activity-123"
+
+
+@pytest.mark.asyncio
+async def test_missing_activity_id_generates_uuid():
+ async def handle(request): # pylint: disable=unused-argument
+ return JSONResponse({"ok": True})
+
+ app = ActivityAgentServerHost(handler=handle, configure_observability=None)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ resp = await client.post(
+ "/activity/messages",
+ json={"type": "message", "text": "hi"},
+ headers={"Authorization": "Bearer test-token"},
+ )
+
+ assert resp.status_code == 200
+ activity_id = resp.headers["x-agent-activity-id"]
+ # Should be a valid UUID
+ uuid.UUID(activity_id)
+
+
+@pytest.mark.asyncio
+async def test_oversized_activity_id_is_sanitized():
+ async def handle(request): # pylint: disable=unused-argument
+ return JSONResponse({"ok": True})
+
+ app = ActivityAgentServerHost(handler=handle, configure_observability=None)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ resp = await client.post(
+ "/activity/messages",
+ json={"type": "message", "text": "hi", "id": "x" * 300},
+ headers={"Authorization": "Bearer test-token"},
+ )
+
+ assert resp.status_code == 200
+ activity_id = resp.headers["x-agent-activity-id"]
+ assert len(activity_id) < 300
+ uuid.UUID(activity_id) # should be a fallback UUID
+
+
+@pytest.mark.asyncio
+async def test_malformed_activity_id_is_sanitized():
+ async def handle(request): # pylint: disable=unused-argument
+ return JSONResponse({"ok": True})
+
+ app = ActivityAgentServerHost(handler=handle, configure_observability=None)
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
+ resp = await client.post(
+ "/activity/messages",
+ json={"type": "message", "text": "hi", "id": "id with spaces & ")
+ assert "