diff --git a/README.md b/README.md
index 5068afedb..592ca9788 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,10 @@
A comprehensive SDK for building Microsoft Teams applications, bots, and AI agents using Python. This SDK provides a high-level framework with built-in Microsoft Graph integration, OAuth handling, and extensible plugin architecture.
+## Agent 365 Support
+
+Agent 365 support is being developed on this integration branch.
+
diff --git a/examples/agent365/README.md b/examples/agent365/README.md
new file mode 100644
index 000000000..cafacf74c
--- /dev/null
+++ b/examples/agent365/README.md
@@ -0,0 +1,18 @@
+# agent365
+
+Demonstrates passing `AgenticIdentity` directly to Teams API surfaces.
+
+## Proactive API Send
+
+`src/main.py` sends through the normal API client, but supplies `agentic_identity` to the activity create operation. The API layer uses the agentic token provider to put the right Agent ID token in the request header.
+
+```bash
+export CLIENT_ID=
+export CLIENT_SECRET=
+export TENANT_ID=
+
+uv run --project examples/agent365 python src/main.py \
+ \
+ \
+
+```
diff --git a/examples/agent365/pyproject.toml b/examples/agent365/pyproject.toml
new file mode 100644
index 000000000..43fc1ee3d
--- /dev/null
+++ b/examples/agent365/pyproject.toml
@@ -0,0 +1,13 @@
+[project]
+name = "agent365"
+version = "0.1.0"
+description = "Agent 365 token example"
+readme = "README.md"
+requires-python = ">=3.11,<4.0"
+dependencies = [
+ "dotenv>=0.9.9",
+ "microsoft-teams-apps",
+]
+
+[tool.uv.sources]
+microsoft-teams-apps = { workspace = true }
diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py
new file mode 100644
index 000000000..f9622093d
--- /dev/null
+++ b/examples/agent365/src/main.py
@@ -0,0 +1,39 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+import argparse
+import asyncio
+import logging
+
+from microsoft_teams.api import MessageActivityInput
+from microsoft_teams.apps import App
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+async def main():
+ parser = argparse.ArgumentParser(description="Send a proactive message using API-level AgenticIdentity")
+ parser.add_argument("conversation_id", help="The Teams conversation ID to send messages to")
+ parser.add_argument("agentic_app_id", help="The concrete agent identity app/client ID")
+ parser.add_argument("agentic_user_id", help="The agent user object ID")
+ args = parser.parse_args()
+
+ app = App()
+ await app.initialize()
+
+ agentic_identity = app.get_agentic_identity(args.agentic_app_id, args.agentic_user_id)
+ activity = MessageActivityInput(text="Hello from an API-level AgenticIdentity.")
+
+ result = await app.api.conversations.activities(args.conversation_id).create(
+ activity,
+ agentic_identity=agentic_identity,
+ )
+
+ logger.info("Sent activity as agentic identity. Activity ID: %s", result.id)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py
index 4821ca5b8..813458bf0 100644
--- a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py
+++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py
@@ -21,6 +21,8 @@ class CloudEnvironment:
"""The default multi-tenant login tenant (e.g. "botframework.com")."""
bot_scope: str
"""The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default")."""
+ agentic_bot_scope: str
+ """The Teams Bot API scope for Agent ID user-token calls."""
token_service_url: str
"""The Bot Framework token service base URL (e.g. "https://token.botframework.com")."""
openid_metadata_url: str
@@ -35,6 +37,7 @@ class CloudEnvironment:
login_endpoint="https://login.microsoftonline.com",
login_tenant="botframework.com",
bot_scope="https://api.botframework.com/.default",
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://token.botframework.com",
openid_metadata_url="https://login.botframework.com/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.com",
@@ -46,6 +49,7 @@ class CloudEnvironment:
login_endpoint="https://login.microsoftonline.us",
login_tenant="MicrosoftServices.onmicrosoft.us",
bot_scope="https://api.botframework.us/.default",
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://tokengcch.botframework.azure.us",
openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.us",
@@ -57,6 +61,7 @@ class CloudEnvironment:
login_endpoint="https://login.microsoftonline.us",
login_tenant="MicrosoftServices.onmicrosoft.us",
bot_scope="https://api.botframework.us/.default",
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://apiDoD.botframework.azure.us",
openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.us",
@@ -68,6 +73,7 @@ class CloudEnvironment:
login_endpoint="https://login.partner.microsoftonline.cn",
login_tenant="microsoftservices.partner.onmschina.cn",
bot_scope="https://api.botframework.azure.cn/.default",
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://token.botframework.azure.cn",
openid_metadata_url="https://login.botframework.azure.cn/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.azure.cn",
diff --git a/packages/api/src/microsoft_teams/api/auth/credentials.py b/packages/api/src/microsoft_teams/api/auth/credentials.py
index 471c7952c..ba59d359b 100644
--- a/packages/api/src/microsoft_teams/api/auth/credentials.py
+++ b/packages/api/src/microsoft_teams/api/auth/credentials.py
@@ -5,7 +5,7 @@
from typing import Awaitable, Callable, Literal, Optional, Union
-from ..models import CustomBaseModel
+from ..models import AgenticIdentity, CustomBaseModel
class ClientCredentials(CustomBaseModel):
@@ -36,8 +36,11 @@ class TokenCredentials(CustomBaseModel):
"""
The tenant ID.
"""
- # (scope: string | string[], tenantId?: string) => string | Promise
- token: Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]
+ # (scope: string | string[], tenantId?: string, agenticIdentity?: AgenticIdentity) => string | Promise
+ token: Callable[
+ [Union[str, list[str]], Optional[str], Optional[AgenticIdentity]],
+ Union[str, Awaitable[str]],
+ ]
"""
The token function.
"""
diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py
index 683d9c1ed..1c7e48ed6 100644
--- a/packages/api/src/microsoft_teams/api/clients/__init__.py
+++ b/packages/api/src/microsoft_teams/api/clients/__init__.py
@@ -6,6 +6,7 @@
from . import bot, conversation, meeting, reaction, team, user
from .api_client import ApiClient
from .api_client_settings import ApiClientSettings, merge_api_client_settings
+from .base_client import AuthProvider
from .bot import * # noqa: F403
from .conversation import * # noqa: F403
from .meeting import * # noqa: F403
@@ -17,6 +18,7 @@
__all__: list[str] = [
"ApiClient",
"ApiClientSettings",
+ "AuthProvider",
"merge_api_client_settings",
]
__all__.extend(bot.__all__)
diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py
index 2da68c7d5..ea9292537 100644
--- a/packages/api/src/microsoft_teams/api/clients/api_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/api_client.py
@@ -11,7 +11,7 @@
from microsoft_teams.common import ClientOptions
from .api_client_settings import ApiClientSettings
-from .base_client import BaseClient
+from .base_client import AuthProvider, BaseClient
from .bot import BotClient
from .conversation import ConversationClient
from .meeting import MeetingClient
@@ -32,6 +32,8 @@ def __init__(
options: Optional[Union[HttpClient, ClientOptions]] = None,
api_client_settings: Optional[ApiClientSettings] = None,
cloud: Optional[CloudEnvironment] = None,
+ *,
+ auth_provider: Optional[AuthProvider] = None,
) -> None:
"""Initialize the unified Teams API client.
@@ -41,13 +43,19 @@ def __init__(
api_client_settings: Optional API client settings.
cloud: Optional cloud environment for sovereign cloud support.
"""
- super().__init__(options, api_client_settings)
+ super().__init__(options, api_client_settings, auth_provider=auth_provider, cloud=cloud)
self.service_url = service_url.rstrip("/")
# Initialize all client types
self.bots = BotClient(self._http, self._api_client_settings, cloud=cloud)
self.users = UserClient(self._http, self._api_client_settings)
- self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings)
+ self.conversations = ConversationClient(
+ self.service_url,
+ self._http,
+ self._api_client_settings,
+ auth_provider=auth_provider,
+ cloud=cloud,
+ )
self.teams = TeamClient(self.service_url, self._http, self._api_client_settings)
self.meetings = MeetingClient(self.service_url, self._http, self._api_client_settings)
self._reactions: Optional[ReactionClient] = None
diff --git a/packages/api/src/microsoft_teams/api/clients/base_client.py b/packages/api/src/microsoft_teams/api/clients/base_client.py
index bc5ccd8f2..363ada03b 100644
--- a/packages/api/src/microsoft_teams/api/clients/base_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/base_client.py
@@ -3,13 +3,22 @@
Licensed under the MIT License.
"""
-from typing import Optional, Union
+from typing import Awaitable, Optional, Protocol, Union
+from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment
from microsoft_teams.common.http import Client, ClientOptions
+from microsoft_teams.common.http.client_token import StringLike
+from ..models.agentic_identity import AgenticIdentity
from .api_client_settings import ApiClientSettings, merge_api_client_settings
+class AuthProvider(Protocol):
+ def token(
+ self, scope: str, tenant_id: str | None = None, agentic_identity: AgenticIdentity | None = None
+ ) -> str | StringLike | None | Awaitable[str | StringLike | None]: ...
+
+
class BaseClient:
"""Base client"""
@@ -17,6 +26,9 @@ def __init__(
self,
options: Optional[Union[Client, ClientOptions]] = None,
api_client_settings: Optional[ApiClientSettings] = None,
+ *,
+ auth_provider: Optional[AuthProvider] = None,
+ cloud: Optional[CloudEnvironment] = None,
) -> None:
"""Initialize the BaseClient.
@@ -32,6 +44,8 @@ def __init__(
self._http = Client(options)
self._api_client_settings = merge_api_client_settings(api_client_settings)
+ self._auth_provider = auth_provider
+ self._cloud = cloud or PUBLIC
@property
def http(self) -> Client:
@@ -42,3 +56,12 @@ def http(self) -> Client:
def http(self, value: Client) -> None:
"""Set the HTTP client instance."""
self._http = value
+
+ def _get_agentic_token(self, identity: AgenticIdentity | None):
+ if identity is None:
+ return None
+ if self._auth_provider is None:
+ raise ValueError("agentic_identity requires a Teams auth provider")
+ auth_provider = self._auth_provider
+
+ return lambda: auth_provider.token(self._cloud.agentic_bot_scope, identity.tenant_id, identity)
diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py
index a1ee86ef8..aa1184b08 100644
--- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py
+++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py
@@ -3,15 +3,18 @@
Licensed under the MIT License.
"""
-from typing import List, Optional
+from typing import TYPE_CHECKING, List, Optional
from microsoft_teams.common.experimental import experimental
from microsoft_teams.common.http import Client
from ...activities import ActivityParams, SentActivity
-from ...models import TeamsChannelAccount
+from ...models import AgenticIdentity, TeamsChannelAccount
from ..api_client_settings import ApiClientSettings
-from ..base_client import BaseClient
+from ..base_client import AuthProvider, BaseClient
+
+if TYPE_CHECKING:
+ from ...auth.cloud_environment import CloudEnvironment
_PLACEHOLDER_ACTIVITY_ID = "DO_NOT_USE_PLACEHOLDER_ID"
@@ -26,6 +29,9 @@ def __init__(
service_url: str,
http_client: Optional[Client] = None,
api_client_settings: Optional[ApiClientSettings] = None,
+ *,
+ auth_provider: Optional[AuthProvider] = None,
+ cloud: Optional["CloudEnvironment"] = None,
):
"""
Initialize the conversation activity client.
@@ -35,10 +41,12 @@ def __init__(
http_client: Optional HTTP client to use. If not provided, a new one will be created.
api_client_settings: Optional API client settings.
"""
- super().__init__(http_client, api_client_settings)
+ super().__init__(http_client, api_client_settings, auth_provider=auth_provider, cloud=cloud)
self.service_url = service_url.rstrip("/")
- async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity:
+ async def create(
+ self, conversation_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None
+ ) -> SentActivity:
"""
Create a new activity in a conversation.
@@ -53,6 +61,7 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc
response = await self.http.post(
f"{self.service_url}/v3/conversations/{conversation_id}/activities",
json=activity.model_dump(by_alias=True, exclude_none=True),
+ token=self._get_agentic_token(agentic_identity),
)
# Note: Typing activities (non-streaming) always produce empty responses.
@@ -61,7 +70,13 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc
id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID)
return SentActivity(id=id, activity_params=activity)
- async def update(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
+ async def update(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ activity: ActivityParams,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> SentActivity:
"""
Update an existing activity in a conversation.
@@ -76,11 +91,18 @@ async def update(self, conversation_id: str, activity_id: str, activity: Activit
response = await self.http.put(
f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
json=activity.model_dump(by_alias=True, exclude_none=True),
+ token=self._get_agentic_token(agentic_identity),
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)
- async def reply(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
+ async def reply(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ activity: ActivityParams,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> SentActivity:
"""
Reply to an activity in a conversation.
@@ -97,11 +119,14 @@ async def reply(self, conversation_id: str, activity_id: str, activity: Activity
response = await self.http.post(
f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
json=activity_json,
+ token=self._get_agentic_token(agentic_identity),
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)
- async def delete(self, conversation_id: str, activity_id: str) -> None:
+ async def delete(
+ self, conversation_id: str, activity_id: str, agentic_identity: AgenticIdentity | None = None
+ ) -> None:
"""
Delete an activity from a conversation.
@@ -109,7 +134,10 @@ async def delete(self, conversation_id: str, activity_id: str) -> None:
conversation_id: The ID of the conversation
activity_id: The ID of the activity to delete
"""
- await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}")
+ await self.http.delete(
+ f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
+ token=self._get_agentic_token(agentic_identity),
+ )
async def get_members(self, conversation_id: str, activity_id: str) -> List[TeamsChannelAccount]:
"""
@@ -128,7 +156,9 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Team
return [TeamsChannelAccount.model_validate(member) for member in response.json()]
@experimental("ExperimentalTeamsTargeted")
- async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity:
+ async def create_targeted(
+ self, conversation_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None
+ ) -> SentActivity:
"""
Create a new targeted activity in a conversation.
@@ -148,12 +178,19 @@ async def create_targeted(self, conversation_id: str, activity: ActivityParams)
response = await self.http.post(
f"{self.service_url}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true",
json=activity.model_dump(by_alias=True, exclude_none=True),
+ token=self._get_agentic_token(agentic_identity),
)
id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID)
return SentActivity(id=id, activity_params=activity)
@experimental("ExperimentalTeamsTargeted")
- async def update_targeted(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
+ async def update_targeted(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ activity: ActivityParams,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> SentActivity:
"""
Update an existing targeted activity in a conversation.
@@ -172,12 +209,15 @@ async def update_targeted(self, conversation_id: str, activity_id: str, activity
response = await self.http.put(
f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true",
json=activity.model_dump(by_alias=True, exclude_none=True),
+ token=self._get_agentic_token(agentic_identity),
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)
@experimental("ExperimentalTeamsTargeted")
- async def delete_targeted(self, conversation_id: str, activity_id: str) -> None:
+ async def delete_targeted(
+ self, conversation_id: str, activity_id: str, agentic_identity: AgenticIdentity | None = None
+ ) -> None:
"""
Delete a targeted activity from a conversation.
@@ -190,5 +230,6 @@ async def delete_targeted(self, conversation_id: str, activity_id: str) -> None:
activity_id: The ID of the activity to delete
"""
await self.http.delete(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true"
+ f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true",
+ token=self._get_agentic_token(agentic_identity),
)
diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/client.py b/packages/api/src/microsoft_teams/api/clients/conversation/client.py
index cb9e491ef..14d24ee67 100644
--- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py
@@ -3,17 +3,20 @@
Licensed under the MIT License.
"""
-from typing import Optional, Union
+from typing import TYPE_CHECKING, Optional, Union
from microsoft_teams.common.http import Client, ClientOptions
-from ...models import ConversationResource
+from ...models import AgenticIdentity, ConversationResource
from ..api_client_settings import ApiClientSettings
-from ..base_client import BaseClient
+from ..base_client import AuthProvider, BaseClient
from .activity import ActivityParams, ConversationActivityClient
from .member import ConversationMemberClient
from .params import CreateConversationParams
+if TYPE_CHECKING:
+ from ...auth.cloud_environment import CloudEnvironment
+
class ConversationOperations:
"""Base class for conversation operations."""
@@ -26,32 +29,40 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None:
class ActivityOperations(ConversationOperations):
"""Operations for managing activities in a conversation."""
- async def create(self, activity: ActivityParams):
- return await self._client.activities_client.create(self._conversation_id, activity)
+ async def create(self, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None):
+ return await self._client.activities_client.create(self._conversation_id, activity, agentic_identity)
- async def update(self, activity_id: str, activity: ActivityParams):
- return await self._client.activities_client.update(self._conversation_id, activity_id, activity)
+ async def update(self, activity_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None):
+ return await self._client.activities_client.update(
+ self._conversation_id, activity_id, activity, agentic_identity
+ )
- async def reply(self, activity_id: str, activity: ActivityParams):
- return await self._client.activities_client.reply(self._conversation_id, activity_id, activity)
+ async def reply(self, activity_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None):
+ return await self._client.activities_client.reply(
+ self._conversation_id, activity_id, activity, agentic_identity
+ )
- async def delete(self, activity_id: str):
- await self._client.activities_client.delete(self._conversation_id, activity_id)
+ async def delete(self, activity_id: str, agentic_identity: AgenticIdentity | None = None):
+ await self._client.activities_client.delete(self._conversation_id, activity_id, agentic_identity)
async def get_members(self, activity_id: str):
return await self._client.activities_client.get_members(self._conversation_id, activity_id)
- async def create_targeted(self, activity: ActivityParams):
+ async def create_targeted(self, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None):
"""Create a new targeted activity visible only to the specified recipient."""
- return await self._client.activities_client.create_targeted(self._conversation_id, activity)
+ return await self._client.activities_client.create_targeted(self._conversation_id, activity, agentic_identity)
- async def update_targeted(self, activity_id: str, activity: ActivityParams):
+ async def update_targeted(
+ self, activity_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None
+ ):
"""Update an existing targeted activity."""
- return await self._client.activities_client.update_targeted(self._conversation_id, activity_id, activity)
+ return await self._client.activities_client.update_targeted(
+ self._conversation_id, activity_id, activity, agentic_identity
+ )
- async def delete_targeted(self, activity_id: str):
+ async def delete_targeted(self, activity_id: str, agentic_identity: AgenticIdentity | None = None):
"""Delete a targeted activity."""
- await self._client.activities_client.delete_targeted(self._conversation_id, activity_id)
+ await self._client.activities_client.delete_targeted(self._conversation_id, activity_id, agentic_identity)
class MemberOperations(ConversationOperations):
@@ -75,6 +86,9 @@ def __init__(
service_url: str,
options: Optional[Union[Client, ClientOptions]] = None,
api_client_settings: Optional[ApiClientSettings] = None,
+ *,
+ auth_provider: Optional[AuthProvider] = None,
+ cloud: Optional["CloudEnvironment"] = None,
) -> None:
"""Initialize the client.
@@ -83,10 +97,16 @@ def __init__(
options: Either an HTTP client instance or client options. If None, a default client is created.
api_client_settings: Optional API client settings.
"""
- super().__init__(options, api_client_settings)
+ super().__init__(options, api_client_settings, auth_provider=auth_provider, cloud=cloud)
self.service_url = service_url.rstrip("/")
- self._activities_client = ConversationActivityClient(self.service_url, self.http, self._api_client_settings)
+ self._activities_client = ConversationActivityClient(
+ self.service_url,
+ self.http,
+ self._api_client_settings,
+ auth_provider=auth_provider,
+ cloud=cloud,
+ )
self._members_client = ConversationMemberClient(self.service_url, self.http, self._api_client_settings)
@property
diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py
index df7e229fd..b00644124 100644
--- a/packages/api/src/microsoft_teams/api/models/__init__.py
+++ b/packages/api/src/microsoft_teams/api/models/__init__.py
@@ -27,6 +27,7 @@
from .activity import Activity as ActivityBase
from .activity import ActivityInput as ActivityInputBase
from .adaptive_card import * # noqa: F403
+from .agentic_identity import AgenticIdentity
from .app_based_link_query import AppBasedLinkQuery
from .attachment import * # noqa: F403
from .cache_info import CacheInfo
@@ -75,6 +76,7 @@
"Action",
"ActivityBase",
"ActivityInputBase",
+ "AgenticIdentity",
"AppBasedLinkQuery",
"CacheInfo",
"ChannelID",
diff --git a/packages/api/src/microsoft_teams/api/models/agentic_identity.py b/packages/api/src/microsoft_teams/api/models/agentic_identity.py
new file mode 100644
index 000000000..213d71b17
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/agentic_identity.py
@@ -0,0 +1,23 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class AgenticIdentity:
+ """Identifies an Agent ID user-shaped identity and its backing agent app."""
+
+ agentic_app_id: str
+ agentic_user_id: str
+ tenant_id: str | None = None
+ agentic_app_blueprint_id: str | None = None
+
+ @property
+ def channel_account_id(self) -> str:
+ return self.agentic_user_id if self.agentic_user_id.startswith("8:") else f"8:orgid:{self.agentic_user_id}"
+
+
+__all__ = ["AgenticIdentity"]
diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml
index c27b9db69..6a2b33c7f 100644
--- a/packages/apps/pyproject.toml
+++ b/packages/apps/pyproject.toml
@@ -17,7 +17,7 @@ dependencies = [
"cryptography>=3.4.0",
"pyjwt[crypto]>=2.12.0",
"dependency-injector>=4.48.1",
- "msal>=1.33.0",
+ "msal>=1.37.0",
"python-dotenv>=1.0.0",
"pydantic-settings>=2.11.0",
]
diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py
index 1ab97c96c..9aad3c87e 100644
--- a/packages/apps/src/microsoft_teams/apps/activity_sender.py
+++ b/packages/apps/src/microsoft_teams/apps/activity_sender.py
@@ -8,11 +8,13 @@
from microsoft_teams.api import (
ActivityParams,
+ AgenticIdentity,
ApiClient,
ConversationReference,
MessageActivityInput,
SentActivity,
)
+from microsoft_teams.api.clients.base_client import AuthProvider
from microsoft_teams.common import Client
from .http_stream import HttpStream
@@ -27,7 +29,7 @@ class ActivitySender:
Separate from transport concerns (HTTP, WebSocket, etc.)
"""
- def __init__(self, client: Client):
+ def __init__(self, client: Client, auth_provider: AuthProvider | None = None):
"""
Initialize ActivitySender.
@@ -35,8 +37,14 @@ def __init__(self, client: Client):
client: HTTP client with token provider configured
"""
self._client = client
-
- async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity:
+ self._auth_provider = auth_provider
+
+ async def send(
+ self,
+ activity: ActivityParams,
+ ref: ConversationReference,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> SentActivity:
"""
Send an activity to the Bot Framework.
@@ -57,7 +65,7 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se
raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.")
# Create API client for this conversation's service URL
- api = ApiClient(service_url=ref.service_url, options=self._client)
+ api = ApiClient(service_url=ref.service_url, options=self._client, auth_provider=self._auth_provider)
# Merge activity with conversation reference
activity.from_ = ref.bot
@@ -69,15 +77,15 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se
if is_update:
activity_id = cast(str, activity.id)
if is_targeted:
- res = await activities.update_targeted(activity_id, activity)
+ res = await activities.update_targeted(activity_id, activity, agentic_identity)
else:
- res = await activities.update(activity_id, activity)
+ res = await activities.update(activity_id, activity, agentic_identity)
return SentActivity.merge(activity, res)
if is_targeted:
- res = await activities.create_targeted(activity)
+ res = await activities.create_targeted(activity, agentic_identity)
else:
- res = await activities.create(activity)
+ res = await activities.create(activity, agentic_identity)
return SentActivity.merge(activity, res)
def create_stream(self, ref: ConversationReference) -> StreamerProtocol:
diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py
index c8e93be73..95c0dd39c 100644
--- a/packages/apps/src/microsoft_teams/apps/app.py
+++ b/packages/apps/src/microsoft_teams/apps/app.py
@@ -15,6 +15,7 @@
Account,
ActivityBase,
ActivityParams,
+ AgenticIdentity,
ApiClient,
ClientCredentials,
ConversationAccount,
@@ -42,6 +43,7 @@
from .app_process import ActivityProcessor
from .auth import TokenValidator
from .auth.remote_function_jwt_middleware import validate_remote_function_request
+from .auth_provider import AppAuthProvider
from .container import Container
from .contexts.function_context import FunctionContext
from .events import (
@@ -100,6 +102,7 @@ def __init__(self, **options: Unpack[AppOptions]):
credentials=self.credentials,
cloud=self.cloud,
)
+ self._auth_provider = AppAuthProvider(self._token_manager)
self.container = Container()
self.container.set_provider("storage", providers.Object(self.storage))
@@ -113,6 +116,7 @@ def __init__(self, **options: Unpack[AppOptions]):
self.http_client.clone(ClientOptions(token=self._get_bot_token)),
self.options.api_client_settings,
cloud=self.cloud,
+ auth_provider=self._auth_provider,
)
plugins: List[PluginBase] = list(self.options.plugins)
@@ -126,7 +130,10 @@ def __init__(self, **options: Unpack[AppOptions]):
self._initialized = False
# initialize ActivitySender for sending activities
- self.activity_sender = ActivitySender(self.http_client.clone(ClientOptions(token=self._get_bot_token)))
+ self.activity_sender = ActivitySender(
+ self.http_client.clone(ClientOptions(token=self._get_bot_token)),
+ auth_provider=self._auth_provider,
+ )
# initialize all event, activity, and plugin processors
self.activity_processor = ActivityProcessor(
@@ -290,7 +297,27 @@ async def stop(self) -> None:
self._events.emit("error", ErrorEvent(error, context={"method": "stop"}))
raise
- async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard):
+ @overload
+ async def send(
+ self,
+ conversation_id: str,
+ activity: str | ActivityParams | AdaptiveCard,
+ ) -> SentActivity: ...
+
+ @overload
+ async def send(
+ self,
+ conversation_id: str,
+ from_: AgenticIdentity,
+ activity: str | ActivityParams | AdaptiveCard,
+ ) -> SentActivity: ...
+
+ async def send( # type: ignore[reportInconsistentOverload]
+ self,
+ conversation_id: str,
+ from_or_activity: AgenticIdentity | str | ActivityParams | AdaptiveCard,
+ activity: str | ActivityParams | AdaptiveCard | None = None,
+ ) -> SentActivity:
"""Send an activity proactively to a conversation.
Sends to the exact conversation ID provided. For channel threads,
@@ -301,24 +328,62 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap
if not self._initialized:
raise ValueError("app not initialized - call app.initialize() or app.start() first")
- if self.id is None:
- raise ValueError("app credentials not configured")
+ if isinstance(from_or_activity, AgenticIdentity):
+ from_ = from_or_activity
+ activity_params = activity
+ if activity_params is None:
+ raise TypeError("activity is required when sending with an agentic identity")
+ else:
+ from_ = None
+ activity_params = from_or_activity
conversation_ref = ConversationReference(
channel_id="msteams",
service_url=self.api.service_url,
- bot=Account(id=self.id),
+ bot=self._get_outbound_account(from_),
conversation=ConversationAccount(id=conversation_id),
)
- if isinstance(activity, str):
- activity = MessageActivityInput(text=activity)
- elif isinstance(activity, AdaptiveCard):
- activity = MessageActivityInput().add_card(activity)
+ if isinstance(activity_params, str):
+ activity_params = MessageActivityInput(text=activity_params)
+ elif isinstance(activity_params, AdaptiveCard):
+ activity_params = MessageActivityInput().add_card(activity_params)
else:
- activity = activity
+ activity_params = activity_params
+
+ return await self.activity_sender.send(activity_params, conversation_ref, from_)
+
+ def get_agentic_identity(
+ self,
+ agentic_app_id: str,
+ agentic_user_id: str,
+ *,
+ tenant_id: Optional[str] = None,
+ agentic_app_blueprint_id: Optional[str] = None,
+ ) -> AgenticIdentity:
+ """Get an Agent ID identity for API calls."""
+ resolved_tenant_id = tenant_id or (self.credentials.tenant_id if self.credentials else None)
+ if resolved_tenant_id is None:
+ raise ValueError("tenant_id is required to get an agentic identity")
+
+ return AgenticIdentity(
+ agentic_app_id=agentic_app_id,
+ agentic_user_id=agentic_user_id,
+ tenant_id=resolved_tenant_id,
+ agentic_app_blueprint_id=agentic_app_blueprint_id,
+ )
+
+ def get_scoped_graph(self, identity: AgenticIdentity) -> "GraphServiceClient":
+ """Get a Graph client scoped to an Agent ID identity."""
+ return create_graph_client(lambda: self._get_agentic_graph_token(identity), cloud=self.cloud)
- return await self.activity_sender.send(activity, conversation_ref)
+ def _get_outbound_account(self, from_: AgenticIdentity | None = None) -> Account:
+ if from_ is None:
+ if self.id is None:
+ raise ValueError("app credentials not configured")
+ return Account(id=self.id)
+
+ return Account(id=from_.channel_account_id)
@overload
async def reply(
@@ -335,10 +400,28 @@ async def reply(
message_id: str | ActivityParams | AdaptiveCard,
) -> SentActivity: ...
+ @overload
+ async def reply(
+ self,
+ conversation_id: str,
+ from_: AgenticIdentity,
+ message_id: str,
+ activity: str | ActivityParams | AdaptiveCard,
+ ) -> SentActivity: ...
+
+ @overload
+ async def reply(
+ self,
+ conversation_id: str,
+ from_: AgenticIdentity,
+ message_id: str | ActivityParams | AdaptiveCard,
+ ) -> SentActivity: ...
+
async def reply( # type: ignore[reportInconsistentOverload]
self,
conversation_id: str,
- message_id: str | ActivityParams | AdaptiveCard = "",
+ from_or_message_id: AgenticIdentity | str | ActivityParams | AdaptiveCard = "",
+ message_id_or_activity: str | ActivityParams | AdaptiveCard | None = None,
activity: str | ActivityParams | AdaptiveCard | None = None,
) -> SentActivity:
"""Send an activity proactively to a conversation, optionally as a threaded reply.
@@ -357,12 +440,26 @@ async def reply( # type: ignore[reportInconsistentOverload]
message_id: The thread root message ID (3-arg form) or the activity (2-arg form)
activity: The activity to send (only in 3-arg form)
"""
- if activity is not None:
- if not isinstance(message_id, str):
+ if isinstance(from_or_message_id, AgenticIdentity):
+ if activity is not None:
+ if not isinstance(message_id_or_activity, str):
+ raise TypeError("message_id must be a string when activity is provided")
+ return await self.send(
+ to_threaded_conversation_id(conversation_id, message_id_or_activity), from_or_message_id, activity
+ )
+
+ if message_id_or_activity is None:
+ raise TypeError("activity is required when replying with an agentic identity")
+ return await self.send(conversation_id, from_or_message_id, message_id_or_activity)
+
+ if message_id_or_activity is not None:
+ if not isinstance(from_or_message_id, str):
raise TypeError("message_id must be a string when activity is provided")
- return await self.send(to_threaded_conversation_id(conversation_id, message_id), activity)
+ return await self.send(
+ to_threaded_conversation_id(conversation_id, from_or_message_id), message_id_or_activity
+ )
- return await self.send(conversation_id, message_id)
+ return await self.send(conversation_id, from_or_message_id)
def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None:
"""Add middleware to run on all activities."""
@@ -596,6 +693,18 @@ async def _stop_plugins(self) -> None:
async def _get_bot_token(self):
return await self._token_manager.get_bot_token()
+ async def _get_agentic_graph_token(self, identity: AgenticIdentity) -> Optional[TokenProtocol]:
+ if identity.tenant_id is None:
+ raise ValueError("tenant_id is required to get an agentic token")
+
+ return await self._token_manager.get_agent_user_token(
+ identity.tenant_id,
+ identity.agentic_app_id,
+ self.cloud.graph_scope,
+ agent_user_id=identity.agentic_user_id,
+ caller_name="get_agentic_graph_token",
+ )
+
async def _get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]:
return await self._token_manager.get_graph_token(tenant_id)
diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py
index 1b2e9f27a..bcdd959ce 100644
--- a/packages/apps/src/microsoft_teams/apps/app_process.py
+++ b/packages/apps/src/microsoft_teams/apps/app_process.py
@@ -27,6 +27,7 @@
from .app_events import EventManager
from .activity_sender import ActivitySender
+from .auth_provider import AppAuthProvider
from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent
from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError
from .routing.activity_context import ActivityContext
@@ -58,6 +59,7 @@ def __init__(
self.default_connection_name = default_connection_name
self.http_client = http_client
self.token_manager = token_manager
+ self.auth_provider = AppAuthProvider(token_manager)
self.api_client_settings = api_client_settings
self.activity_sender = activity_sender
self.cloud = cloud
@@ -95,6 +97,7 @@ async def _build_context(
service_url,
self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token)),
self.api_client_settings,
+ auth_provider=self.auth_provider,
)
# Check if user is signed in
diff --git a/packages/apps/src/microsoft_teams/apps/auth/__init__.py b/packages/apps/src/microsoft_teams/apps/auth/__init__.py
index f64ff6335..bf7b38ffe 100644
--- a/packages/apps/src/microsoft_teams/apps/auth/__init__.py
+++ b/packages/apps/src/microsoft_teams/apps/auth/__init__.py
@@ -4,6 +4,6 @@
"""
from .remote_function_jwt_middleware import validate_remote_function_request
-from .token_validator import TokenValidator
+from .token_validator import InboundActivityTokenValidator, TokenValidator
-__all__ = ["TokenValidator", "validate_remote_function_request"]
+__all__ = ["InboundActivityTokenValidator", "TokenValidator", "validate_remote_function_request"]
diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py
index a4f1df1d4..8105e2443 100644
--- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py
+++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py
@@ -34,6 +34,10 @@ class JwtValidationOptions:
""" Optional scope that must be present in the token """
clock_tolerance: int = JWT_LEEWAY_SECONDS
""" Allowable clock skew when validating JWTs """
+ app_id: Optional[str] = None
+ """ Optional app ID used to select per-token validators for inbound activities """
+ cloud: CloudEnvironment = PUBLIC
+ """ Cloud environment used to select issuer and JWKS endpoints """
class TokenValidator:
@@ -83,6 +87,8 @@ def for_service(
valid_audiences=cls._default_audiences(app_id),
jwks_uri=jwks_keys_uri,
service_url=service_url,
+ app_id=app_id,
+ cloud=env,
)
return cls(options)
@@ -128,6 +134,8 @@ def for_entra(
valid_audiences=valid_audiences,
jwks_uri=f"{env.login_endpoint}/{tenant_id}/discovery/v2.0/keys",
scope=scope,
+ app_id=app_id,
+ cloud=env,
)
return cls(options)
@@ -222,3 +230,54 @@ def _validate_scope(self, payload: Dict[str, Any], required_scope: str) -> None:
if required_scope not in scope_set:
logger.error(f"Token missing required scope: {required_scope}")
raise jwt.InvalidTokenError(f"Token missing required scope: {required_scope}")
+
+
+class InboundActivityTokenValidator:
+ """Validator for inbound Teams activities.
+
+ Classic bot activities use Bot Framework connector tokens. Agent ID activities use
+ Entra tokens whose audience is the agent identity blueprint app ID.
+ """
+
+ def __init__(self, app_id: str, cloud: Optional[CloudEnvironment] = None):
+ self._app_id = app_id
+ self._cloud = cloud or PUBLIC
+ self._service_validator = TokenValidator.for_service(app_id, cloud=self._cloud)
+ self._entra_validators_by_tenant: dict[str, TokenValidator] = {}
+
+ async def validate_token(self, raw_token: str, service_url: Optional[str] = None) -> Dict[str, Any]:
+ unverified_payload = jwt.decode(raw_token, options={"verify_signature": False})
+ issuer = unverified_payload.get("iss", "")
+ if self._is_entra_issuer(issuer):
+ return await self._validate_entra_token(raw_token, unverified_payload)
+
+ return await self._service_validator.validate_token(raw_token, service_url)
+
+ def _is_entra_issuer(self, issuer: Any) -> bool:
+ if not isinstance(issuer, str):
+ return False
+
+ return issuer.startswith(self._cloud.login_endpoint) or issuer.startswith("https://sts.windows.net/")
+
+ async def _validate_entra_token(self, raw_token: str, unverified_payload: Dict[str, Any]) -> Dict[str, Any]:
+ tenant_id = unverified_payload.get("tid")
+ if not tenant_id or not isinstance(tenant_id, str):
+ raise jwt.InvalidTokenError("Entra inbound token is missing tid")
+
+ validator = self._get_entra_validator(tenant_id)
+ # TODO: Agent ID inbound Entra tokens currently do not include serviceurl. Revisit service URL
+ # validation for this path once the platform defines a signed service URL claim or equivalent.
+ return await validator.validate_token(raw_token)
+
+ def _get_entra_validator(self, tenant_id: str) -> TokenValidator:
+ cached_validator = self._entra_validators_by_tenant.get(tenant_id)
+ if cached_validator:
+ return cached_validator
+
+ validator = TokenValidator.for_entra(
+ self._app_id,
+ tenant_id,
+ cloud=self._cloud,
+ )
+ self._entra_validators_by_tenant[tenant_id] = validator
+ return validator
diff --git a/packages/apps/src/microsoft_teams/apps/auth_provider.py b/packages/apps/src/microsoft_teams/apps/auth_provider.py
new file mode 100644
index 000000000..d5590ec44
--- /dev/null
+++ b/packages/apps/src/microsoft_teams/apps/auth_provider.py
@@ -0,0 +1,36 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Optional
+
+from microsoft_teams.api import AgenticIdentity, TokenProtocol
+
+from .token_manager import TokenManager
+
+
+class AppAuthProvider:
+ """Provides app and agentic tokens for Teams API clients."""
+
+ def __init__(self, token_manager: TokenManager):
+ self._token_manager = token_manager
+
+ async def token(
+ self, scope: str, tenant_id: str | None = None, agentic_identity: AgenticIdentity | None = None
+ ) -> Optional[TokenProtocol]:
+ if agentic_identity is None:
+ return await self._token_manager.get_bot_token()
+ if tenant_id is None:
+ raise ValueError("tenant_id is required to get an agentic token")
+
+ return await self._token_manager.get_agent_user_token(
+ tenant_id,
+ agentic_identity.agentic_app_id,
+ scope,
+ agent_user_id=agentic_identity.agentic_user_id,
+ caller_name="token",
+ )
+
+
+__all__ = ["AppAuthProvider"]
diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py
index e11c411a9..eb7a5129a 100644
--- a/packages/apps/src/microsoft_teams/apps/http/http_server.py
+++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py
@@ -13,7 +13,7 @@
from microsoft_teams.api.auth.json_web_token import JsonWebToken
from pydantic import BaseModel
-from ..auth import TokenValidator
+from ..auth import InboundActivityTokenValidator
from ..events import ActivityEvent, CoreActivity
from .adapter import HttpRequest, HttpResponse, HttpServerAdapter
@@ -43,7 +43,7 @@ def __init__(self, adapter: HttpServerAdapter, messaging_endpoint: str = "/api/m
raise ValueError("messaging_endpoint must be a non-empty path starting with '/'.")
self._messaging_endpoint = normalized_endpoint
self._on_request: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]] = None
- self._token_validator: Optional[TokenValidator] = None
+ self._token_validator: Optional[InboundActivityTokenValidator] = None
self._skip_auth: bool = False
self._cloud: CloudEnvironment = PUBLIC
self._initialized: bool = False
@@ -89,7 +89,7 @@ def initialize(
app_id = getattr(credentials, "client_id", None) if credentials else None
if app_id and not skip_auth:
- self._token_validator = TokenValidator.for_service(
+ self._token_validator = InboundActivityTokenValidator(
app_id,
cloud=self._cloud,
)
diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py
index c9d7a363f..c3ca686b2 100644
--- a/packages/apps/src/microsoft_teams/apps/options.py
+++ b/packages/apps/src/microsoft_teams/apps/options.py
@@ -8,7 +8,7 @@
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast
-from microsoft_teams.api import ApiClientSettings
+from microsoft_teams.api import AgenticIdentity, ApiClientSettings
from microsoft_teams.api.auth.cloud_environment import CloudEnvironment
from microsoft_teams.common import Client, ClientOptions, Storage
from typing_extensions import Unpack
@@ -30,7 +30,9 @@ class AppOptions(TypedDict, total=False):
"""Application ID URI from the Azure portal. Used for user authentication.
Matches webApplicationInfo.resource in the app manifest."""
# Custom token provider function
- token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]]
+ token: Optional[
+ Callable[[Union[str, list[str]], Optional[str], Optional[AgenticIdentity]], Union[str, Awaitable[str]]]
+ ]
"""Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials."""
# Managed identity configuration (used when client_id provided without client_secret or token)
@@ -111,7 +113,9 @@ class InternalAppOptions:
application_id_uri: Optional[str] = None
"""Application ID URI from the Azure portal. Used for user authentication.
Matches webApplicationInfo.resource in the app manifest."""
- token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None
+ token: Optional[
+ Callable[[Union[str, list[str]], Optional[str], Optional[AgenticIdentity]], Union[str, Awaitable[str]]]
+ ] = None
"""Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials."""
managed_identity_client_id: Optional[str] = None
"""
diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
index 6c70ceecd..e061c9b52 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
@@ -14,6 +14,7 @@
Account,
ActivityBase,
ActivityParams,
+ AgenticIdentity,
ApiClient,
CardAction,
CardActionType,
@@ -102,6 +103,7 @@ def __init__(
self.cloud = cloud
self._activity_sender = activity_sender
self._app_token = app_token
+ self.agentic_identity = self._get_agentic_identity()
self.stream = activity_sender.create_stream(conversation_ref)
self._next_handler: Optional[Callable[[], Awaitable[None]]] = None
@@ -191,7 +193,11 @@ async def send(
self._add_targeted_message_info_entity(activity)
ref = conversation_ref or self.conversation_ref
- res = await self._activity_sender.send(activity, ref)
+ if self.agentic_identity is not None:
+ ref = ref.model_copy(deep=True)
+ ref.bot = Account(id=self.agentic_identity.channel_account_id)
+
+ res = await self._activity_sender.send(activity, ref, self.agentic_identity)
return res
async def reply(self, input: str | ActivityParams) -> SentActivity:
@@ -246,6 +252,27 @@ def _incoming_targeted_sender(self) -> Optional[Account]:
return self.activity.from_
+ def _get_agentic_identity(self) -> Optional[AgenticIdentity]:
+ recipient = getattr(self.activity, "recipient", None)
+ if recipient is None or recipient.role != "agenticUser":
+ return None
+
+ if not recipient.agentic_user_id:
+ raise ValueError("agenticUser recipient is missing agenticUserId")
+ if not recipient.agentic_app_id:
+ raise ValueError("agenticUser recipient is missing agenticAppId")
+
+ tenant_id = recipient.tenant_id or getattr(self.activity.conversation, "tenant_id", None)
+ if not tenant_id:
+ raise ValueError("agenticUser recipient is missing tenantId")
+
+ return AgenticIdentity(
+ agentic_app_id=recipient.agentic_app_id,
+ agentic_user_id=recipient.agentic_user_id,
+ tenant_id=tenant_id,
+ agentic_app_blueprint_id=recipient.agentic_app_blueprint_id,
+ )
+
def _should_outbound_be_auto_targeted(
self,
activity: ActivityParams,
diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py
index e4c3525f1..ccf0bdad1 100644
--- a/packages/apps/src/microsoft_teams/apps/token_manager.py
+++ b/packages/apps/src/microsoft_teams/apps/token_manager.py
@@ -5,11 +5,12 @@
import asyncio
import logging
-from inspect import isawaitable
-from typing import Any, Optional
+from inspect import Parameter, isawaitable, signature
+from typing import Any, Awaitable, Callable, Optional, cast
import requests
from microsoft_teams.api import (
+ AgenticIdentity,
ClientCredentials,
Credentials,
JsonWebToken,
@@ -29,6 +30,8 @@
)
DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common"
+TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"
+AGENT_BOT_API_SCOPE = "https://botapi.skype.com/.default"
logger = logging.getLogger(__name__)
@@ -45,6 +48,7 @@ def __init__(
self._cloud = cloud or PUBLIC
self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {}
self._federated_identity_clients_by_tenant: dict[str, ConfidentialClientApplication] = {}
+ self._agent_identity_clients_by_tenant_and_app_id: dict[tuple[str, str], ConfidentialClientApplication] = {}
self._managed_identity_client: Optional[ManagedIdentityClient] = None
async def get_bot_token(self) -> Optional[TokenProtocol]:
@@ -68,6 +72,97 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok
self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN)
)
+ async def get_agent_bot_token(
+ self,
+ tenant_id: str,
+ agent_identity_app_id: str,
+ *,
+ agent_user_id: str | None = None,
+ agent_user_upn: str | None = None,
+ caller_name: str | None = None,
+ ) -> Optional[TokenProtocol]:
+ """Get a Teams bot API token for an agent identity acting through its agent user."""
+ return await self.get_agent_user_token(
+ tenant_id,
+ agent_identity_app_id,
+ AGENT_BOT_API_SCOPE,
+ agent_user_id=agent_user_id,
+ agent_user_upn=agent_user_upn,
+ caller_name=caller_name,
+ )
+
+ async def get_agent_user_token(
+ self,
+ tenant_id: str,
+ agent_identity_app_id: str,
+ scope: str,
+ *,
+ agent_user_id: str | None = None,
+ agent_user_upn: str | None = None,
+ caller_name: str | None = None,
+ ) -> Optional[TokenProtocol]:
+ """Get a resource token for an agent identity acting through its agent user."""
+ if not agent_user_id and not agent_user_upn:
+ raise ValueError("Either agent_user_id or agent_user_upn must be provided")
+ if agent_user_id and agent_user_upn:
+ raise ValueError("agent_user_id and agent_user_upn are mutually exclusive")
+ if self._credentials is None:
+ if caller_name:
+ logger.debug(f"No credentials provided for {caller_name}")
+ return None
+
+ credentials = self._credentials
+ agentic_identity = AgenticIdentity(
+ agentic_app_id=agent_identity_app_id,
+ agentic_user_id=agent_user_id or agent_user_upn or "",
+ tenant_id=tenant_id,
+ )
+ if isinstance(credentials, TokenCredentials):
+ return await self._get_token_with_token_provider(credentials, scope, tenant_id, agentic_identity)
+
+ if not isinstance(credentials, ClientCredentials):
+ raise ValueError("Agent user tokens require ClientCredentials")
+ confidential_client = self._get_confidential_client(credentials, tenant_id)
+
+ def get_t1_assertion(_context: dict[str, Any]) -> str:
+ t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client(
+ [TOKEN_EXCHANGE_SCOPE], fmi_path=agent_identity_app_id
+ )
+ return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed")
+
+ # The agent identity app needs its own MSAL client. It uses the Federated Managed
+ # Identity assertion from step 1 as its client assertion for the next exchanges.
+ t2_confidential_client = self._get_agent_identity_client(
+ tenant_id,
+ agent_identity_app_id,
+ get_t1_assertion,
+ )
+
+ t2_raw: dict[str, Any] = await asyncio.to_thread(
+ lambda: t2_confidential_client.acquire_token_for_client([TOKEN_EXCHANGE_SCOPE])
+ )
+
+ t2 = self._get_access_token_or_raise(t2_raw, "Agent token exchange step 2 failed")
+
+ t3_raw: dict[str, Any] = await asyncio.to_thread(
+ lambda: t2_confidential_client.acquire_token_by_user_federated_identity_credential(
+ [scope],
+ assertion=t2,
+ user_object_id=agent_user_id,
+ username=agent_user_upn,
+ data={"requested_token_use": "on_behalf_of"},
+ )
+ )
+ return self._handle_token_response(t3_raw, caller_name or "get_agent_user_token")
+
+ def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: str) -> str:
+ if token_res.get("access_token", None):
+ return token_res["access_token"]
+
+ error_description = token_res.get("error_description") or token_res.get("error") or "Could not acquire token"
+ logger.error(f"{error_prefix}: {error_description}")
+ raise ValueError(f"{error_prefix}: {error_description}")
+
async def _get_token(
self, scope: str, tenant_id: str, *, caller_name: str | None = None
) -> Optional[TokenProtocol]:
@@ -160,9 +255,10 @@ async def _get_token_with_token_provider(
credentials: TokenCredentials,
scope: str,
tenant_id: str,
+ agentic_identity: AgenticIdentity | None = None,
) -> TokenProtocol:
"""Get token using custom token provider function."""
- token = credentials.token(scope, tenant_id)
+ token = self._call_token_provider(credentials, scope, tenant_id, agentic_identity)
if isawaitable(token):
access_token = await token
@@ -171,6 +267,27 @@ async def _get_token_with_token_provider(
return JsonWebToken(access_token)
+ def _call_token_provider(
+ self,
+ credentials: TokenCredentials,
+ scope: str,
+ tenant_id: str,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> str | Awaitable[str]:
+ token_provider = cast(Any, credentials.token)
+ try:
+ parameters = list(signature(token_provider).parameters.values())
+ except (TypeError, ValueError):
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity))
+
+ accepts_agentic_identity = (
+ any(parameter.kind == Parameter.VAR_POSITIONAL for parameter in parameters) or len(parameters) >= 3
+ )
+ if accepts_agentic_identity:
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity))
+
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id))
+
def _handle_token_response(self, token_res: dict[str, Any], error_prefix: str = "") -> TokenProtocol:
"""Handle token response from MSAL client."""
if token_res.get("access_token", None):
@@ -220,6 +337,24 @@ def _get_federated_identity_client(
self._federated_identity_clients_by_tenant[tenant_id] = client
return client
+ def _get_agent_identity_client(
+ self,
+ tenant_id: str,
+ agent_identity_app_id: str,
+ client_assertion: Callable[[dict[str, Any]], str],
+ ) -> ConfidentialClientApplication:
+ cached_client = self._agent_identity_clients_by_tenant_and_app_id.get((tenant_id, agent_identity_app_id))
+ if cached_client:
+ return cached_client
+
+ client: ConfidentialClientApplication = ConfidentialClientApplication(
+ agent_identity_app_id,
+ client_credential={"client_assertion": client_assertion},
+ authority=f"{self._cloud.login_endpoint}/{tenant_id}",
+ )
+ self._agent_identity_clients_by_tenant_and_app_id[(tenant_id, agent_identity_app_id)] = client
+ return client
+
def _get_managed_identity_client(
self, credentials: ManagedIdentityCredentials | FederatedIdentityCredentials
) -> ManagedIdentityClient:
diff --git a/stubs/msal/__init__.pyi b/stubs/msal/__init__.pyi
index 0ec244167..5e5272ced 100644
--- a/stubs/msal/__init__.pyi
+++ b/stubs/msal/__init__.pyi
@@ -1,8 +1,8 @@
"""Type stubs for msal"""
-from typing import Any, Callable, TypeAlias
+from typing import Any, Callable, Optional, TypeAlias
-ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str]] | None
+ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str] | Callable[[dict[str, Any]], str]] | None
class ConfidentialClientApplication:
"""MSAL Confidential Client Application"""
@@ -16,7 +16,22 @@ class ConfidentialClientApplication:
**kwargs: Any,
) -> None: ...
def acquire_token_for_client(
- self, scopes: list[str] | str, claims_challenge: str | None = None, **kwargs: Any
+ self,
+ scopes: list[str] | str,
+ claims_challenge: str | None = None,
+ *,
+ fmi_path: str | None = None,
+ **kwargs: Any,
+ ) -> dict[str, Any]: ...
+ def acquire_token_by_user_federated_identity_credential(
+ self,
+ scopes: list[str],
+ assertion: str | Callable[[], str],
+ *,
+ username: Optional[str] = None,
+ user_object_id: Optional[str] = None,
+ claims_challenge: Optional[None] = None,
+ **kwargs: Any,
) -> dict[str, Any]: ...
class SystemAssignedManagedIdentity:
diff --git a/uv.lock b/uv.lock
index 7a2693d06..44fe0682e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -9,6 +9,7 @@ resolution-markers = [
[manifest]
members = [
"a2a",
+ "agent365",
"ai-agentframework",
"botbuilder",
"cards",
@@ -129,6 +130,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/58/1878b9ac4db703f40c687129c0bccdbf88c94d557b64f0172f3c4e954558/agent_framework_openai-1.1.0-py3-none-any.whl", hash = "sha256:8fbcdb87fbc3fb6aa6f3d781a61a2c48fbc308d2ee8165454f94e53e026a787b", size = 50280, upload-time = "2026-04-21T06:20:11.864Z" },
]
+[[package]]
+name = "agent365"
+version = "0.1.0"
+source = { virtual = "examples/agent365" }
+dependencies = [
+ { name = "dotenv" },
+ { name = "microsoft-teams-apps" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "dotenv", specifier = ">=0.9.9" },
+ { name = "microsoft-teams-apps", editable = "packages/apps" },
+]
+
[[package]]
name = "ai-agentframework"
version = "0.1.0"
@@ -1818,7 +1834,7 @@ requires-dist = [
{ name = "microsoft-teams-api", editable = "packages/api" },
{ name = "microsoft-teams-common", editable = "packages/common" },
{ name = "microsoft-teams-graph", marker = "extra == 'graph'", editable = "packages/graph" },
- { name = "msal", specifier = ">=1.33.0" },
+ { name = "msal", specifier = ">=1.37.0" },
{ name = "pydantic-settings", specifier = ">=2.11.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
@@ -1931,16 +1947,16 @@ dev = [
[[package]]
name = "msal"
-version = "1.34.0"
+version = "1.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" },
+ { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" },
]
[[package]]