# Teams SDK - Python Documentation (Complete) > Microsoft Teams SDK - A comprehensive framework for building AI-powered Teams applications using Python. Using this SDK, you can easily build and integrate a variety of features in Microsoft Teams by building Agents or Tools. The documentation here helps by giving background information and code samples on how best to do this. IMPORTANT THINGS TO REMEMBER: - This SDK is NOT based off of BotFramework (which the _previous_ version of the Teams SDK was based on). This SDK is a completely new framework. Use this guide to find snippets to drive your decisions. - When scaffolding new applications, using the CLI is a lot simpler and preferred than doing it all by yourself. See the Quickstart guide for that. - It's a good idea to run `pyright` to make sure the code is correctly typed and fix any type errors. YOU MUST FOLLOW THE ABOVE GUIDANCE. ## Main Documentation ### Action commands # Action commands Action commands allow you to present your users with a modal pop-up called a dialog in Teams. The dialog collects or displays information, processes the interaction, and sends the information back to Teams compose box. ## Action command invocation locations There are three different areas action commands can be invoked from: 1. Compose Area 2. Compose Box 3. Message ### Compose Area and Box ![Screenshot of Teams with outlines around the 'Compose Box' (for typing messages) and the 'Compose Area' (the menu option next to the compose box that provides a search bar for actions and apps).](/screenshots/compose-area.png) ### Message action command ![Screenshot of message extension response in Teams. By selecting the '...' button, a menu has opened with 'More actions' option in which they can select from a list of available message extension actions.](/screenshots/message.png) :::tip See the [Invoke Locations](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command?tabs=Teams-toolkit%2Cdotnet#select-action-command-invoke-locations) guide to learn more about the different entry points for action commands. ::: ## Setting up your Teams app manifest To use action commands you have define them in the Teams app manifest. Here is an example: ```json "composeExtensions": [ { "botId": "${{BOT_ID}}", "commands": [ { "id": "createCard", "type": "action", "context": [ "compose", "commandBox" ], "description": "Command to run action to create a card from the compose box.", "title": "Create Card", "parameters": [ { "name": "title", "title": "Card title", "description": "Title for the card", "inputType": "text" }, { "name": "subTitle", "title": "Subtitle", "description": "Subtitle for the card", "inputType": "text" }, { "name": "text", "title": "Text", "description": "Text for the card", "inputType": "textarea" } ] }, { "id": "getMessageDetails", "type": "action", "context": [ "message" ], "description": "Command to run action on message context.", "title": "Get Message Details" }, { "id": "fetchConversationMembers", "description": "Fetch the conversation members", "title": "Fetch Conversation Members", "type": "action", "fetchTask": true, "context": [ "compose" ] }, ] } ] ``` Here we have defining three different commands: 1. `createCard` - that can be invoked from either the `compose` or `commandBox` areas. Upon invocation a dialog will popup asking the user to fill the `title`, `subTitle`, and `text`. ![Screenshot of a message extension dialog with the editable fields 'Card title', 'Subtitle', and 'Text'.](/screenshots/parameters.png) 2. `getMessageDetails` - It is invoked from the `message` overflow menu. Upon invocation the message payload will be sent to the app which will then return the details like `createdDate`, etc. ![Screenshot of the 'More actions' message extension menu expanded with 'Get Message Details' option selected.](/screenshots/message-command.png) 3. `fetchConversationMembers` - It is invoked from the `compose` area. Upon invocation the app will return an adaptive card in the form of a dialog with the conversation roster. ![Screenshot of the 'Fetch Conversation Members' option exposed from the message extension menu '...' option.](/screenshots/fetch-conversation-members.png) ## Handle submission Handle submission when the `createCard` or `getMessageDetails` actions commands are invoked. ```python from microsoft_teams.api import AdaptiveCardAttachment, MessageExtensionSubmitActionInvokeActivity, card_attachment from microsoft_teams.api.models import AttachmentLayout, MessagingExtensionActionInvokeResponse, MessagingExtensionAttachment, MessagingExtensionResult, MessagingExtensionResultType from microsoft_teams.apps import ActivityContext # ... @app.on_message_ext_submit async def handle_message_ext_submit(ctx: ActivityContext[MessageExtensionSubmitActionInvokeActivity]): command_id = ctx.activity.value.command_id if command_id == "createCard": card = create_card(ctx.activity.value.data or {}) elif command_id == "getMessageDetails" and ctx.activity.value.message_payload: card = create_message_details_card(ctx.activity.value.message_payload) else: raise Exception(f"Unknown commandId: {command_id}") main_attachment = card_attachment(AdaptiveCardAttachment(content=card)) attachment = MessagingExtensionAttachment( content_type=main_attachment.content_type, content=main_attachment.content ) result = MessagingExtensionResult( type=MessagingExtensionResultType.RESULT, attachment_layout=AttachmentLayout.LIST, attachments=[attachment] ) return MessagingExtensionActionInvokeResponse(compose_extension=result) ``` ### Create card `create_card()` method ```py from typing import Dict from microsoft_teams.cards import AdaptiveCard # ... def create_card(data: Dict[str, str]) -> AdaptiveCard: """Create an adaptive card from form data.""" return AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "Image", "url": IMAGE_URL}, { "type": "TextBlock", "text": data.get("title", ""), "size": "Large", "weight": "Bolder", "color": "Accent", "style": "heading", }, { "type": "TextBlock", "text": data.get("subTitle", ""), "size": "Small", "weight": "Lighter", "color": "Good", }, {"type": "TextBlock", "text": data.get("text", ""), "wrap": True, "spacing": "Medium"}, ], } ) ``` ### Create message details card `create_message_details_card()` method ```python from typing import Dict, List, Union from microsoft_teams.api.models.message import Message from microsoft_teams.cards import AdaptiveCard # ... def create_message_details_card(message_payload: Message) -> AdaptiveCard: """Create a card showing message details.""" body: List[Dict[str, Union[str, bool]]] = [ { "type": "TextBlock", "text": "Message Details", "size": "Large", "weight": "Bolder", "color": "Accent", "style": "heading", } ] if message_payload.body and message_payload.body.content: content_blocks: List[Dict[str, Union[str, bool]]] = [ {"type": "TextBlock", "text": "Content", "size": "Medium", "weight": "Bolder", "spacing": "Medium"}, {"type": "TextBlock", "text": message_payload.body.content}, ] body.extend(content_blocks) if message_payload.attachments: attachment_blocks: List[Dict[str, Union[str, bool]]] = [ {"type": "TextBlock", "text": "Attachments", "size": "Medium", "weight": "Bolder", "spacing": "Medium"}, { "type": "TextBlock", "text": f"Number of attachments: {len(message_payload.attachments)}", "wrap": True, "spacing": "Small", }, ] body.extend(attachment_blocks) if message_payload.created_date_time: date_blocks: List[Dict[str, Union[str, bool]]] = [ {"type": "TextBlock", "text": "Created Date", "size": "Medium", "weight": "Bolder", "spacing": "Medium"}, {"type": "TextBlock", "text": message_payload.created_date_time, "wrap": True, "spacing": "Small"}, ] body.extend(date_blocks) if message_payload.link_to_message: link_blocks: List[Dict[str, Union[str, bool]]] = [ {"type": "TextBlock", "text": "Message Link", "size": "Medium", "weight": "Bolder", "spacing": "Medium"} ] body.extend(link_blocks) actions = [{"type": "Action.OpenUrl", "title": "Go to message", "url": message_payload.link_to_message}] else: actions = [] return AdaptiveCard.model_validate({"type": "AdaptiveCard", "version": "1.4", "body": body, "actions": actions}) ``` ## Handle opening adaptive card dialog Handle opening adaptive card dialog when the `fetchConversationMembers` command is invoked. ```python from microsoft_teams.api import AdaptiveCardAttachment, MessageExtensionFetchTaskInvokeActivity, card_attachment from microsoft_teams.api.models import CardTaskModuleTaskInfo, MessagingExtensionActionInvokeResponse, TaskModuleContinueResponse from microsoft_teams.apps import ActivityContext # ... @app.on_message_ext_open async def handle_message_ext_open(ctx: ActivityContext[MessageExtensionFetchTaskInvokeActivity]): conversation_id = ctx.activity.conversation.id members = await ctx.api.conversations.members(conversation_id).get_all() card = create_conversation_members_card(members) card_info = CardTaskModuleTaskInfo( title="Conversation members", height="small", width="small", card=card_attachment(AdaptiveCardAttachment(content=card)), ) task = TaskModuleContinueResponse(value=card_info) return MessagingExtensionActionInvokeResponse(task=task) ``` ### Create conversation members card `create_conversation_members_card()` method ```python from typing import List from microsoft_teams.api import Account from microsoft_teams.cards import AdaptiveCard # ... def create_conversation_members_card(members: List[Account]) -> AdaptiveCard: """Create a card showing conversation members.""" members_list = ", ".join(member.name for member in members if member.name) return AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "TextBlock", "text": "Conversation members", "size": "Medium", "weight": "Bolder", "color": "Accent", "style": "heading", }, {"type": "TextBlock", "text": members_list, "wrap": True, "spacing": "Small"}, ], } ) ``` ## Resources - [Action commands](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/define-action-command?tabs=Teams-toolkit%2Cdotnet) - [Returning Adaptive Card Previews in Task Modules](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/action-commands/respond-to-task-module-submit?tabs=dotnet%2Cdotnet-1#bot-response-with-adaptive-card) --- ### Adaptive Cards # Adaptive Cards Overview of Adaptive Cards in Python Teams SDK for building rich, interactive user experiences in Teams applications. Adaptive Cards provide a flexible, cross-platform content format for creating rich, interactive experiences. They consist of a customizable body of card elements combined with optional action sets, all fully serializable for delivery to clients. Through a powerful combination of text, graphics, and interactive buttons, Adaptive Cards enable compelling user experiences across various platforms. The Adaptive Card framework is widely implemented throughout Microsoft's ecosystem, with significant integration in Microsoft Teams. Within Teams, Adaptive Cards power numerous key scenarios including: - Rich interactive messages - Dialogs - Message Extensions - Link Unfurling - Configuration forms - And many more application contexts Mastering Adaptive Cards is essential for creating sophisticated, engaging experiences that leverage the full capabilities of the Teams platform. This guide will help you learn how to use them in this SDK. For a more comprehensive guide on Adaptive Cards, see the [official documentation](https://adaptivecards.microsoft.com/). --- ### App Basics # App Basics The `App` class is the main entry point for your agent. It is responsible for: 1. Hosting and running the server (via plugins) 2. Serving incoming requests and routing them to your handlers 3. Handling authentication for your agent to the Teams backend 4. Providing helpful utilities which simplify the ability for your application to interact with the Teams platform 5. Managing plugins which can extend the functionality of your agent ```mermaid flowchart LR %% Layout Definitions direction LR Teams subgraph AppClass CorePlugins["Plugins"] Events["Events"] subgraph AppResponsibilities direction TB ActivityRouting["Activity Routing"] Utilities["Utilities"] Auth["Auth"] end Plugins2["Plugins"] end ApplicationLogic["Application Logic"] %% Connections Teams --> CorePlugins CorePlugins --> Events Events --> ActivityRouting ActivityRouting --> Plugins2 Plugins2 --> ApplicationLogic Auth --> ApplicationLogic Utilities --> ApplicationLogic %% Styling style Teams fill:#2E86AB,stroke:#1B4F72,stroke-width:2px,color:#ffffff style ApplicationLogic fill:#28B463,stroke:#1D8348,stroke-width:2px,color:#ffffff ``` ## Core Components **Plugins** - Can be used to set up the server - Can listen to messages or send messages out **Events** - Listens to events from core plugins - Emit interesting events to the application **Activity Routing** - Routes activities to appropriate handlers **Utilities** - Provides utility functions for convenience (like sending replies or proactive messages) **Auth** - Handles authenticating your agent with Teams, Graph, etc. - Simplifies the process of authenticating your app or user for your app **Plugins (Secondary)** - Can hook into activity handlers or proactive scenarios - Can modify or update agent activity events ## Plugins You'll notice that plugins are present in the front, which exposes your application as a server, and also in the back after the app does some processing to the incoming message. The plugin architecture allows the application to be built in an extremely modular way. Each plugin can be swapped out to change or augment the functionality of the application. The plugins can listen to various events that happen (e.g. the server starting or ending, an error occuring, etc), activities being sent to or from the application and more. This allows the application to be extremely flexible and extensible. --- ### Building Adaptive Cards # Building Adaptive Cards Adaptive Cards are JSON payloads that describe rich, interactive UI fragments. With `microsoft-teams-cards` you can build these cards entirely in Python while enjoying full IntelliSense and compiler safety. ## The Builder Pattern `microsoft-teams-cards` exposes small **builder helpers** including `Card`, `TextBlock`, `ToggleInput`, `ExecuteAction`, _etc._ Each helper wraps raw JSON and provides fluent, chainable methods that keep your code concise and readable. ```python from microsoft_teams.cards import AdaptiveCard, TextBlock, ToggleInput, ActionSet, ExecuteAction card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ TextBlock(text="Hello world", wrap=True, weight="Bolder"), ToggleInput(label="Notify me").with_id("notify"), ActionSet( actions=[ ExecuteAction(title="Submit") .with_data({"action": "submit_basic"}) .with_associated_inputs("auto") ] ), ], ) ``` Benefits: | Benefit | Description | | ----------- | ----------------------------------------------------------------------------- | | Readability | No deep JSON trees—just chain simple methods. | | Re‑use | Extract snippets to functions or classes and share across cards. | | Safety | Builders validate every property against the Adaptive Card schema (see next). | :::info The builder helpers use typed dictionaries and type hints. Use your IDE's IntelliSense features to explore available properties. Source code lives in the `teams.cards` module. ::: ## Type‑safe Authoring & IntelliSense The package bundles the **Adaptive Card v1.5 schema** as strict Python types. While coding you get: - **Autocomplete** for every element and attribute. - **In‑editor validation**—invalid enum values or missing required properties produce build errors. - Automatic upgrades when the schema evolves; simply update the package. ```python # "huge" is not a valid size for TextBlock text_block = TextBlock(text="Test", wrap=True, weight="Bolder", size="huge"), ``` ## The Visual Designer Prefer a drag‑and‑drop approach? Use [Microsoft's Adaptive Card Designer](https://adaptivecards.microsoft.com/designer.html): 1. Add elements visually until the card looks right. 2. Copy the JSON payload from the editor pane. 3. Paste the JSON into your project **or** convert it to builder calls: ```python card = AdaptiveCard.model_validate( { "type": "AdaptiveCard", "body": [ { "type": "ColumnSet", "columns": [ { "type": "Column", "verticalContentAlignment": "center", "items": [ { "type": "Image", "style": "Person", "url": "https://aka.ms/AAp9xo4", "size": "Small", "altText": "Portrait of David Claux", } ], "width": "auto", }, { "type": "Column", "spacing": "medium", "verticalContentAlignment": "center", "items": [{"type": "TextBlock", "weight": "Bolder", "text": "David Claux", "wrap": True}], "width": "auto", }, { "type": "Column", "spacing": "medium", "verticalContentAlignment": "center", "items": [ { "type": "TextBlock", "text": "Principal Platform Architect at Microsoft", "isSubtle": True, "wrap": True, } ], "width": "stretch", }, ], } ], "version": "1.5", } ) # Send the card as an attachment message = MessageActivityInput(text="Hello text!").add_card(card) ``` This method leverages the full Adaptive Card schema and ensures that the payload adheres strictly to `AdaptiveCard`. :::tip You can use a combination of raw JSON and builder helpers depending on whatever you find easier. ::: ## End‑to‑end Example – Task Form Card Below is a complete example showing a task management form. Notice how the builder pattern keeps the file readable and maintainable: ```python from datetime import datetime from microsoft_teams.api import MessageActivity, TypingActivityInput from microsoft_teams.apps import ActivityContext from microsoft_teams.cards import AdaptiveCard, TextBlock, ActionSet, ExecuteAction, Choice, ChoiceSetInput, DateInput, TextInput # ... @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.reply(TypingActivityInput()) card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ TextBlock(text="Create New Task", weight="Bolder", size="Large"), TextInput(id="title").with_label("Task Title").with_placeholder("Enter task title"), TextInput(id="description").with_label("Description").with_placeholder("Enter task details").with_is_multiline(True), ChoiceSetInput(choices=[ Choice(title="High", value="high"), Choice(title="Medium", value="medium"), Choice(title="Low", value="low"), ]).with_id("priority").with_label("Priority").with_value("medium"), DateInput(id="due_date").with_label("Due Date").with_value(datetime.now().strftime("%Y-%m-%d")), ActionSet( actions=[ ExecuteAction(title="Create Task") .with_data({"action": "create_task"}) .with_associated_inputs("auto") .with_style("positive") ] ), ], ) await ctx.send(card) ``` ## Additional Resources - [**Official Adaptive Card Documentation**](https://adaptivecards.microsoft.com/) - [**Adaptive Cards Designer**](https://adaptivecards.microsoft.com/designer.html) ### Summary - Use **builder helpers** for readable, maintainable card code. - Enjoy **full type safety** and IDE assistance. - Prototype quickly in the **visual designer** and refine with builders. Happy card building! 🎉 --- ### Creating Dialogs # Creating Dialogs :::tip If you're not familiar with how to build Adaptive Cards, check out [the cards guide](../adaptive-cards). Understanding their basics is a prerequisite for this guide. ::: ## Entry Point To open a dialog, you need to supply a special type of action as to the Adaptive Card. Once this button is clicked, the dialog will open and ask the application what to show. ```python from microsoft_teams.api import MessageActivity, MessageActivityInput, TypingActivityInput from microsoft_teams.apps import ActivityContext from microsoft_teams.cards import AdaptiveCard, TextBlock, TaskFetchAction # ... @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.reply(TypingActivityInput()) card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ TextBlock( text="Select the examples you want to see!", size="Large", weight="Bolder", ) ] ).with_actions([ # Special type of action to open a dialog TaskFetchAction(value={"OpenDialogType": "webpage_dialog"}).with_title("Webpage Dialog"), # This data will be passed back in an event, so we can handle what to show in the dialog TaskFetchAction(value={"OpenDialogType": "multi_step_form"}).with_title("Multi-step Form"), TaskFetchAction(value={"OpenDialogType": "mixed_example"}).with_title("Mixed Example") ]) # Send the card as an attachment message = MessageActivityInput(text="Enter this form").add_card(card) await ctx.send(message) ``` ## Handling Dialog Open Events Once an action is executed to open a dialog, the Teams client will send an event to the agent to request what the content of the dialog should be. Here is how to handle this event: ```python @app.on_dialog_open async def handle_dialog_open(ctx: ActivityContext[TaskFetchInvokeActivity]): """Handle dialog open events for all dialog types.""" card = AdaptiveCard(...) # Return an object with the task value that renders a card return InvokeResponse( body=TaskModuleResponse( task=TaskModuleContinueResponse( value=CardTaskModuleTaskInfo( title="Title of Dialog", card=card_attachment(AdaptiveCardAttachment(content=card)), ) ) ) ) ``` ### Rendering A Card You can render an Adaptive Card in a dialog by returning a card response. ```python from microsoft_teams.api import AdaptiveCardAttachment, TaskFetchInvokeActivity, InvokeResponse, card_attachment from microsoft_teams.api import CardTaskModuleTaskInfo, TaskModuleContinueResponse, TaskModuleResponse from microsoft_teams.apps import ActivityContext from microsoft_teams.cards import AdaptiveCard, TextBlock, TextInput, SubmitAction, SubmitActionData # ... @app.on_dialog_open async def handle_dialog_open(ctx: ActivityContext[TaskFetchInvokeActivity]): """Handle dialog open events for all dialog types.""" # Return an object with the task value that renders a card dialog_card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ TextBlock(text="This is a simple form", size="Large", weight="Bolder"), TextInput().with_label("Name").with_is_required(True).with_id("name").with_placeholder("Enter your name"), ], actions=[ SubmitAction().with_title("Submit").with_data(SubmitActionData(ms_teams={"SubmissionDialogType": "simple_form"})) ] ) # Return an object with the task value that renders a card return InvokeResponse( body=TaskModuleResponse( task=TaskModuleContinueResponse( value=CardTaskModuleTaskInfo( title="Simple Form Dialog", card=card_attachment(AdaptiveCardAttachment(content=dialog_card)), ) ) ) ) ``` :::info The action type for submitting a dialog must be `Action.Submit`. This is a requirement of the Teams client. If you use a different action type, the dialog will not be submitted and the agent will not receive the submission event. ::: ### Rendering A Webpage You can render a webpage in a dialog as well. There are some security requirements to be aware of: 1. The webpage must be hosted on a domain that is allow-listed as `validDomains` in the Teams app [manifest](/teams/manifest) for the agent 2. The webpage must also host the [teams-js client library](https://www.npmjs.com/package/@microsoft/teams-js). The reason for this is that for security purposes, the Teams client will not render arbitrary webpages. As such, the webpage must explicitly opt-in to being rendered in the Teams client. Setting up the teams-js client library handles this for you. ```python from microsoft_teams.api import InvokeResponse, TaskModuleContinueResponse, TaskModuleResponse, UrlTaskModuleTaskInfo # ... return InvokeResponse( body=TaskModuleResponse( task=TaskModuleContinueResponse( value=UrlTaskModuleTaskInfo( title="Webpage Dialog", # Here we are using a webpage that is hosted in the same # server as the agent. This server needs to be publicly accessible, # needs to set up teams.js client library (https://www.npmjs.com/package/@microsoft/teams-js) # and needs to be registered in the manifest. url=f"{os.getenv('BOT_ENDPOINT')}/tabs/dialog-webpage", width=1000, height=800, ) ) ) ) ``` ### Setting up Embedded Web Content To serve web content for dialogs, you can use the `page` method to host static webpages: ```python # In your app setup (e.g., main.py) # Hosts a static webpage at /tabs/dialog-form app.page("customform", os.path.join(os.path.dirname(__file__), "views", "customform"), "/tabs/dialog-form") ``` --- ### Listening To Activities # Listening To Activities An **Activity** is the Teams‑specific payload that flows between the user and your bot. Where _events_ describe high‑level happenings inside your app, _activities_ are the raw Teams messages such as chat text, card actions, installs, or invoke calls. The Teams SDK exposes a fluent router so you can subscribe to these activities with `@app.event("activity")`. ```mermaid flowchart LR Teams["Teams"]:::less-interesting Server["App Server"]:::interesting ActivityRouter["Activity Router (app.on())"]:::interesting Handlers["Your Activity Handlers"]:::interesting Teams --> |Events| Server Server --> |Activity Event| ActivityRouter ActivityRouter --> |handler invoked| Handlers classDef interesting fill:#b1650f,stroke:#333,stroke-width:4px; classDef less-interesting fill:#666,stroke:#333,stroke-width:4px; ``` Here is an example of a basic message handler: ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.send(f"You said '{ctx.activity.text}'") ``` In the above example, the `ctx.activity` parameter is of type `MessageActivity`, which has a `text` property. You'll notice that the handler here does not return anything, but instead handles it by `send`ing a message back. For message activities, Teams does not expect your application to return anything (though it's usually a good idea to send some sort of friendly acknowledgment!). ## Middleware pattern The `event` activity handlers (and attributes) follow a [middleware](https://www.patterns.dev/vanilla/mediator-pattern/) pattern similar to how `python` middlewares work. This means that for each activity handler, a `next` function is passed in which can be called to pass control to the next handler. This allows you to build a chain of handlers that can process the same activity in different ways. ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): """Handle message activities using the new generated handler system.""" print(f"[GENERATED onMessage] Message received: {ctx.activity.text}") await ctx.next() ``` ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): """Handle message activities using the new generated handler system.""" if ctx.activity.text == "/help": await ctx.send("Here are all the ways I can help you...") await ctx.next() ``` ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.send(f"You said '{ctx.activity.text}'") ``` :::info Just like other middlewares, if you stop the chain by not calling `next()`, the activity will not be passed to the next handler. The order of registration for the handlers also matters as that determines how the handlers will be called. ::: --- ### Middleware # Middleware Middleware is a useful tool for logging, validation, and more. You can easily register your own middleware using the `app.use` method. Below is an example of a middleware that will log the elapse time of all handlers that come after it. ```python @app.use async def log_activity(ctx: ActivityContext[MessageActivity]): started_at = datetime.now() await ctx.next() ctx.logger.debug(f"{datetime.now() - started_at}") ``` --- ### Proactive Messaging # Proactive Messaging In [Sending Messages](./), you were shown how to respond to an event when it happens. However, there are times when you want to send a message to the user without them sending a message first. This is called proactive messaging. You can do this by using the `send` method in the `app` instance. This approach is useful for sending notifications or reminders to the user. The main thing to note is that you need to have the `conversation_id` of the chat or channel that you want to send the message to. It's a good idea to store this value somewhere from an activity handler so that you can use it for proactive messaging later. ```python from microsoft_teams.api import InstalledActivity, MessageActivityInput from microsoft_teams.apps import ActivityContext # ... # This would be some persistent storage storage = dict[str, str]() # Installation is just one place to get the conversation_id. All activities have this field as well. @app.on_install_add async def handle_install_add(ctx: ActivityContext[InstalledActivity]): # Save the conversation_id storage[ctx.activity.from_.aad_object_id] = ctx.activity.conversation.id await ctx.send("Hi! I am going to remind you to say something to me soon!") # This queues up the proactive notifaction to be sent in 1 minute notication_queue.add_reminder(ctx.activity.from_.aad_object_id, send_proactive_notification, 60000) ``` Then, when you want to send a proactive message, you can retrieve the `conversation_id` from storage and use it to send the message. ```python from microsoft_teams.api import MessageActivityInput # ... async def send_proactive_notification(user_id: str): conversation_id = storage.get(user_id, "") if not conversation_id: return activity = MessageActivityInput(text="Hey! It's been a while. How are you?") await app.send(conversation_id, activity) ``` :::tip In this example, you see how to get the `conversation_id` using one of the activity handlers. This is a good place to store the conversation id, but you can also do this in other places like when the user installs the app or when they sign in. The important thing is that you have the conversation id stored somewhere so you can use it later. ::: ## Targeted Proactive Messages :::info[Preview] Targeted messages are currently in preview. ::: Targeted messages, also known as ephemeral messages, are delivered to a specific user in a shared conversation. From a single user's perspective, they appear as regular inline messages in a conversation. Other participants won't see these messages. When sending targeted messages proactively, you must explicitly specify the recipient account. ```python from microsoft_teams.api import MessageActivityInput, Account # When sending proactively, you must provide an explicit recipient account async def send_targeted_notification(conversation_id: str, recipient: Account): await app.send( conversation_id, MessageActivityInput(text="This is a private notification just for you!") .with_recipient(recipient, is_targeted=True) ) ``` --- ### Quickstart # Quickstart Get started with Teams SDK quickly using the Teams CLI. ## Set up a new project ### Prerequisites - **Python** v3.12 or higher. Install or upgrade from [python.org/downloads](https://www.python.org/downloads/). ## Instructions ### Use the Teams CLI Use your terminal to run the Teams CLI using npx: ```sh npx @microsoft/teams.cli --version ``` :::info _The [Teams CLI](/developer-tools/cli) is a command-line tool that helps you create and manage Teams applications. It provides a set of commands to simplify the development process._

Using `npx` allows you to run the Teams CLI without installing it globally. You can verify it works by running the version command above. ::: ## Creating Your First Agent Let's begin by creating a simple echo agent that responds to messages. Run: ```sh npx @microsoft/teams.cli@latest new python quote-agent --template echo ``` This command: 1. Creates a new directory called `quote-agent`. 2. Bootstraps the echo agent template files into it under `quote-agent/src`. 3. Creates your agent's manifest files, including a `manifest.json` file and placeholder icons in the `quote-agent/appPackage` directory. The Teams [app manifest](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) is required for [sideloading](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) the app into Teams. > The `echo` template creates a basic agent that repeats back any message it receives - perfect for learning the fundamentals. ## Running your agent Navigate to your new agent's directory: ```sh cd quote-agent ``` Start the development server: ```sh python src/main.py ``` In the console, you should see a similar output: ```sh [INFO] @teams/app Successfully initialized all plugins [INFO] @teams/app.HttpPlugin Starting HTTP server on port 3978 INFO: Started server process [6436] INFO: Waiting for application startup. [INFO] @teams/app.HttpPlugin listening on port 3978 🚀 [INFO] @teams/app Teams app started successfully INFO: Application startup complete.. INFO: Uvicorn running on http://0.0.0.0:3978 (Press CTRL+C to quit) ``` When the application starts, you'll see: 1. An HTTP server starting up (on port `3978`). This is the main server which handles incoming requests and serves the agent application. ## Add to an Existing Project If you already have a project and want to add Teams support, install the SDK directly: ```sh pip install microsoft-teams-apps ``` Then initialize the Teams app with your existing server: ```python from fastapi import FastAPI # highlight-next-line from microsoft_teams.apps import App, FastAPIAdapter # Your existing FastAPI app my_fastapi = FastAPI() # highlight-start # Wrap your app in an adapter and create the Teams app adapter = FastAPIAdapter(app=my_fastapi) app = App(http_server_adapter=adapter) @app.on_message async def handle_message(ctx): await ctx.send(f"You said: {ctx.activity.text}") # highlight-end async def main(): # highlight-next-line await app.initialize() # Register the Teams endpoint (does not start a server) # Start your server as usual config = uvicorn.Config(app=my_fastapi, host="0.0.0.0", port=3978) server = uvicorn.Server(config) await server.serve() asyncio.run(main()) ``` `app.initialize()` registers the Teams endpoint on your server without starting a new one — you keep full control of your server lifecycle. See the [HTTP Server guide](../in-depth-guides/server/http-server) for full details on adapters and custom server setups. ## Next steps After creating and running your first agent, read about [the code basics](code-basics.txt) to better understand its components and structure. Otherwise, if you want to run your agent in Teams, you can check out the [Running in Teams](running-in-teams.txt) guide. ## Resources - [Teams CLI documentation](/developer-tools/cli) - [Teams manifest schema](https://learn.microsoft.com/en-us/microsoftteams/platform/resources/schema/manifest-schema) - [Teams sideloading](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) --- ### Service URL Validation # Service URL Validation The Teams SDK validates that incoming `serviceUrl` values belong to known domains before using them for outbound API calls. This prevents the bot's credentials from being sent to unauthorized endpoints. ## How it works When your bot receives an activity from the Bot Framework Channel Service, the activity includes a `serviceUrl` field that tells the SDK where to send responses. The SDK validates this URL against allowed hostnames from the configured cloud environment before making any outbound requests. ### Allowed hostnames by cloud Each cloud environment preset includes the allowed service URL hostnames for that cloud: | Cloud | Allowed hostnames | |---|---| | Public (default) | `smba.trafficmanager.net`, `smba.onyx.prod.teams.trafficmanager.net`, `smba.infra.gcc.teams.microsoft.com` | | US Gov (GCCH) | `smba.infra.gov.teams.microsoft.us` | | US Gov DoD | `smba.infra.dod.teams.microsoft.us` | | China (21Vianet) | `frontend.botapi.msg.infra.teams.microsoftonline.cn` | | `localhost` / `127.0.0.1` | Always allowed for local development | If your bot is configured for a sovereign cloud, only that cloud's hostnames are allowed by default. ## Adding custom domains If your bot receives activities from a service URL outside the cloud preset's allowlist (e.g., sovereign clouds without presets, non-Teams channels, or custom environments), you can add additional hostnames. Sovereign cloud FQDNs that require `additionalAllowedDomains`: | Environment | FQDN | |---|---| | France (Bleu) | `smba.teams.sovcloud-core.fr` | | EagleX | `frontend.botapi.msg.infra.teams.eaglex.ic.gov` | | SCloud | `frontend.botapi.msg.infra.teams.microsoft.scloud` | ```python app = App( additional_allowed_domains=["api.my-custom-channel.com"], ) ``` ## Disabling validation :::warning Disabling service URL validation removes a security protection that prevents your bot's credentials from being sent to unauthorized endpoints. Only disable this if you understand the security implications. ::: If you have a non-standard setup where domain-based validation does not work, you can disable it by passing `*` as an additional domain: ```python app = App( additional_allowed_domains=["*"], ) ``` ## Proactive messaging :::info Service URL validation runs on incoming activities. If you store a `ConversationReference` for proactive messaging, the `serviceUrl` was validated when the original activity was received. Only store conversation references from validated inbound activities -- never from untrusted external sources. ::: --- ### Static Pages # Static Pages The `App` class lets you host web apps in the agent. This can be used for an efficient inner loop when building a complex app using Microsoft 365 Agents Toolkit, as it lets you build, deploy, and sideload both an agent and a Tab app inside of Teams in a single step. It's also useful in production scenarios, as it makes it straight-forward to host a simple experience such as an agent configuration page or a Dialog. To host a static tab web app, call the `app.tab()` function and provide an app name and a path to a folder containing an `index.html` file to be served up. ```python app.tab("my_app", os.path.abspath("dist/client")) ``` This registers a route that is hosted at `http://localhost:PORT/tabs/my_app` or `https://BOT_DOMAIN/tabs/my_app`. ## Additional resources - For an example of hosting a Dialog, see the [Creating Dialogs](../dialogs/creating-dialogs) in-depth guide. --- ### 🔍 Search commands # 🔍 Search commands Message extension search commands allow users to search external systems and insert the results of that search into a message in the form of a card. ## Search command invocation locations There are two different areas search commands can be invoked from: 1. Compose Area 2. Compose Box ### Compose Area and Box ![Screenshot of Teams with outlines around the 'Compose Box' (for typing messages) and the 'Compose Area' (the menu option next to the compose box that provides a search bar for actions and apps).](/screenshots/compose-area.png) ## Setting up your Teams app manifest To use search commands you have to define them in the Teams app manifest. Here is an example: ```json "composeExtensions": [ { "botId": "${{BOT_ID}}", "commands": [ { "id": "searchQuery", "context": [ "compose", "commandBox" ], "description": "Test command to run query", "title": "Search query", "type": "query", "parameters": [ { "name": "searchQuery", "title": "Search Query", "description": "Your search query", "inputType": "text" } ] } ] } ] ``` Here we are defining the `searchQuery` search (or query) command. ## Handle submission Handle the search query submission when the `searchQuery` search command is invoked. ```python from microsoft_teams.api import AdaptiveCardAttachment, MessageExtensionQueryInvokeActivity, ThumbnailCardAttachment, card_attachment, InvokeResponse, AttachmentLayout, MessagingExtensionAttachment, MessagingExtensionInvokeResponse, MessagingExtensionResult, MessagingExtensionResultType # ... @app.on_message_ext_query async def handle_message_ext_query(ctx: ActivityContext[MessageExtensionQueryInvokeActivity]): command_id = ctx.activity.value.command_id search_query = "" if ctx.activity.value.parameters and len(ctx.activity.value.parameters) > 0: search_query = ctx.activity.value.parameters[0].value or "" if command_id == "searchQuery": cards = await create_dummy_cards(search_query) attachments: list[MessagingExtensionAttachment] = [] for card_data in cards: main_attachment = card_attachment(AdaptiveCardAttachment(content=card_data["card"])) preview_attachment = card_attachment(ThumbnailCardAttachment(content=card_data["thumbnail"])) attachment = MessagingExtensionAttachment( content_type=main_attachment.content_type, content=main_attachment.content, preview=preview_attachment ) attachments.append(attachment) result = MessagingExtensionResult( type=MessagingExtensionResultType.RESULT, attachment_layout=AttachmentLayout.LIST, attachments=attachments ) return MessagingExtensionInvokeResponse(compose_extension=result) return InvokeResponse[MessagingExtensionInvokeResponse](status400.txt) ``` `create_dummy_cards()` method ```python from typing import Any, Dict, List from microsoft_teams.cards import AdaptiveCard # ... async def create_dummy_cards(search_query: str) -> List[Dict[str, Any]]: """Create dummy cards for search results.""" dummy_items = [ { "title": "Item 1", "description": f"This is the first item and this is your search query: {search_query}", }, {"title": "Item 2", "description": "This is the second item"}, {"title": "Item 3", "description": "This is the third item"}, {"title": "Item 4", "description": "This is the fourth item"}, {"title": "Item 5", "description": "This is the fifth item"}, ] cards: List[Dict[str, Any]] = [] for item in dummy_items: card_data: Dict[str, Any] = { "card": AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "TextBlock", "text": item["title"], "size": "Large", "weight": "Bolder", "color": "Accent", "style": "heading", }, {"type": "TextBlock", "text": item["description"], "wrap": True, "spacing": "Medium"}, ], } ), "thumbnail": { "title": item["title"], "text": item["description"], }, } cards.append(card_data) return cards ``` The search results include both a full adaptive card and a preview card. The preview card appears as a list item in the search command area: ![Screenshot of Teams showing a message extensions search menu open with list of search results displayed as preview cards.](/screenshots/preview-card.png) When a user clicks on a list item the dummy adaptive card is added to the compose box: ![Screenshot of Teams showing the selected adaptive card added to the compose box.](/screenshots/card-in-compose.png) To implement custom actions when a user clicks on a search result item, you can add the `tap` property to the preview card. This allows you to handle the click event with custom logic: ```python from microsoft_teams.api import MessageExtensionSelectItemInvokeActivity, AttachmentLayout, MessagingExtensionInvokeResponse, MessagingExtensionResult, MessagingExtensionResultType from microsoft_teams.apps import ActivityContext # ... @app.on_message_ext_select_item async def handle_message_ext_select_item(ctx: ActivityContext[MessageExtensionSelectItemInvokeActivity]): option = getattr(ctx.activity.value, "option", None) await ctx.send(f"Selected item: {option}") result = MessagingExtensionResult( type=MessagingExtensionResultType.RESULT, attachment_layout=AttachmentLayout.LIST, attachments=[] ) return MessagingExtensionInvokeResponse(compose_extension=result) ``` ## Resources - [Search command](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/search-commands/define-search-command?tabs=Teams-toolkit%2Cdotnet) - [Just-In-Time Install](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/search-commands/universal-actions-for-search-based-message-extensions#just-in-time-install) --- ### 🗃️ Custom Logger # 🗃️ Custom Logger The `App` will provide a default logger, but you can also provide your own. The default `Logger` instance will be set to Python's standard `logging` module (configured with `ConsoleFormatter` from the SDK). from the `microsoft-teams-common` package. The Python SDK writes to the standard `logging` module. Configure a handler and formatter at startup: ```python from microsoft_teams.api import MessageActivity from microsoft_teams.apps import ActivityContext, App from microsoft_teams.common import ConsoleFormatter # Setup logging logging.getLogger().setLevel(logging.DEBUG) stream_handler = logging.StreamHandler() stream_handler.setFormatter(ConsoleFormatter()) logging.getLogger().addHandler(stream_handler) logger = logging.getLogger(__name__) app = App() @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): logger.debug(ctx.activity) await ctx.send(f"You said '{ctx.activity.text}'") if __name__ == "__main__": asyncio.run(app.start()) ``` ## Log Levels Python's standard `logging` levels apply: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. ## Filtering by Logger Name Each logger is created with a name. You can filter which loggers emit output by providing a name pattern, using `*` as a wildcard. Use `ConsoleFilter` to limit which loggers emit, matched by name with `*` wildcards: ```python from microsoft_teams.common import ConsoleFilter, ConsoleFormatter handler = logging.StreamHandler() handler.setFormatter(ConsoleFormatter()) handler.addFilter(ConsoleFilter("microsoft_teams*")) # only SDK loggers logging.getLogger().addHandler(handler) ``` ## Environment Variables The Python SDK does not read logging environment variables on its own. If you want `LOG_LEVEL` to control verbosity, read it yourself at startup: ```python logging.getLogger().setLevel(os.getenv("LOG_LEVEL", "INFO").upper()) ``` ## Child Loggers --- ### Code Basics # Code Basics After following the guidance in [the quickstart](quickstart.txt) to create your first Teams application, let's review its structure and key components. This knowledge can help you build more complex applications as you progress. ## Project Structure When you create a new Teams application, it generates a directory with this basic structure: ``` quote-agent/ |── appPackage/ # Teams app package files ├── src ├── main.py # Main application code ``` - **appPackage/**: Contains the Teams app package files, including the `manifest.json` file and icons. This is required for [sideloading](https://learn.microsoft.com/en-us/microsoftteams/platform/concepts/deploy-and-publish/apps-upload) the app into Teams for testing. The app manifest defines the app's metadata, capabilities, and permissions. - **src/**: Contains the main application code. The `main.py` file is the entry point for your application. ## Core Components Let's break down the simple application from the [quickstart](quickstart.txt) into its core components. ### The App Class The heart of an application is the `App` class. This class handles all incoming activities and manages the application's lifecycle. It also acts as a way to host your application service. ```python title="src/main.py" from microsoft_teams.api import MessageActivity, TypingActivityInput from microsoft_teams.apps import ActivityContext, App app = App() ``` The app configuration includes a variety of options that allow you to customize its behavior, including controlling the underlying server, authentication, and other settings. ### Plugins Plugins are a core part of the Teams SDK. They allow you to hook into various lifecycles of the application. The lifecycles include server events (start, stop, initialize, etc.), and also Teams Activity events (on_activity, on_activity_sent, etc.). ### Message Handling Teams applications respond to various types of activities. The most basic is handling messages: ```python title="src/main.py" @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.reply(TypingActivityInput()) if "reply" in ctx.activity.text.lower(): await ctx.reply("Hello! How can I assist you today?") else: await ctx.send(f"You said '{ctx.activity.text}'") ``` This code: 1. Listens for all incoming messages using `app.on_message` 2. Sends a typing indicator, which renders as an animated ellipsis (…) in the chat. 3. Responds by echoing back the received message if any other text aside from "reply" is sent. :::info Python uses type hints for better development experience. You can change the activity handler to different supported activities, and the type system will provide appropriate hints and validation. ::: ### Application Lifecycle Your application starts when you run: ```python if __name__ == "__main__": asyncio.run(app.start()) ``` This code initializes your application server and, when configured for Teams, also authenticates it to be ready for sending and receiving messages. ## Next Steps Now that you understand the basic structure of your Teams application, you're ready to [run it in Teams](running-in-teams.txt). You will learn about Microsoft 365 Agents Toolkit and other important tools that help you with deployment and testing your application. After that, you can: - Add more activity handlers for different types of interactions. See [Listening to Activities](../essentials/on-activity) for more details. - Integrate with external services using the [API Client](../essentials/api). - Add interactive [cards](../in-depth-guides/adaptive-cards) and [dialogs](../in-depth-guides/dialogs). Continue on to the next page to learn about these advanced features. ## Other Resources - [Essentials](../essentials) - [Teams concepts](/teams) - [Teams developer tools](/developer-tools) --- ### Dialogs # Dialogs Dialogs are a helpful paradigm in Teams which improve interactions between your agent and users. When dialogs are **invoked**, they pop open a window for a user in the Teams client. The content of the dialog can be supplied by the agent application. :::note In Teams client v1, dialogs were called task modules. They may occasionaly be used synonymously. ::: ## Key benefits 1. Dialogs pop open for a user in the Teams client. This means in group-settings, dialog actions are not visible to other users in the channel, reducing clutter. 2. Interactions like filling out complex forms, or multi-step forms where each step depends on the previous step are excellent use cases for dialogs. 3. The content for the dialog can be hard-coded in, or fetched at runtime. This makes them extremely flexible and powerful. ## Resources - [Task Modules](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-task-modules) - [Invoking Task Modules](https://learn.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/task-modules/invoking-task-modules) --- ### Essentials # Essentials At its core, an application that hosts an agent on Microsoft Teams exists to do three things well: listen to events, handle the ones that matter, and respond efficiently. Whether a user sends a message, opens a dialog, or clicks a button — your app is there to interpret the event and act on it. With Teams SDK, we've made it easier than ever to build this kind of reactive, conversational logic. The SDK introduces a few simple but powerful paradigms to help you connect to Teams, register handlers, and build intelligent agent behaviors quickly. Before diving in, let's define a few key terms: - Event: Anything interesting that happens on Teams — or within your application as a result of handling an earlier event. - Activity: A special type of Teams-specific event. Activities include things like messages, reactions, and adaptive card actions. - InvokeActivity: A specific kind of activity triggered by user interaction (like submitting a form), which may or may not require a response. - Handler: The logic in your application that reacts to events or activities. Handlers decide what to do, when, and how to respond. ```mermaid flowchart LR Teams["Teams"] Server["App Server"] AppEventHandlers["Event Handler decorator (@app.event())"] AppRouter["Activity Event Router"] AppActivityHandlers["Activity Handler decorators (@app.on_activity())"] Teams --> |Activity| Server Teams --> |Signed In| Server Teams --> |...other
incoming events| Server Server --> |ActivityEvent
or InvokeEvent| AppRouter Server ---> |incoming
events| AppEventHandlers Server ---> |outgoing
events
| AppEventHandlers AppRouter --> |message activity| AppActivityHandlers AppRouter --> |card activity| AppActivityHandlers AppRouter --> |installation activity| AppActivityHandlers AppRouter --> |...other activities| AppActivityHandlers linkStyle 0,3 stroke:#66fdf3,stroke-width:1px,color:Tomato linkStyle 1,2,4,5 stroke:#66fdf3,stroke-width:1px linkStyle 6,7,8,9 color:Tomato ``` This section will walk you through the foundational pieces needed to build responsive, intelligent agents using the SDK. --- ### Executing Actions # Executing Actions Adaptive Cards support interactive elements through **actions**—buttons, links, and input submission triggers that respond to user interaction. You can use these to collect form input, trigger workflows, show task modules, open URLs, and more. ## Action Types The Teams SDK supports several action types for different interaction patterns: | Action Type | Purpose | Description | | ------------------------- | ---------------------- | ---------------------------------------------------------------------------- | | `Action.Execute` | Server‑side processing | Send data to your bot for processing. Best for forms & multi‑step workflows. | | `Action.Submit` | Simple data submission | Legacy action type. Prefer `Execute` for new projects. | | `Action.OpenUrl` | External navigation | Open a URL in the user's browser. | | `Action.ShowCard` | Progressive disclosure | Display a nested card when clicked. | | `Action.ToggleVisibility` | UI state management | Show/hide card elements dynamically. | :::info For complete reference, see the [official documentation](https://adaptivecards.microsoft.com/?topic=Action.Execute). ::: ## Creating Actions with the SDK ### Single Actions The SDK provides builder helpers that abstract the underlying JSON. For example: ```python from microsoft_teams.cards.core import ExecuteAction # ... action = ExecuteAction(title="Submit Feedback") .with_data({"action": "submit_feedback"}) .with_associated_inputs("auto") ``` ### Action Sets Group actions together using `ActionSet`: ```python from microsoft_teams.cards.core import ActionSet, ExecuteAction, OpenUrlAction # ... action_set = ActionSet( actions=[ ExecuteAction(title="Submit Feedback") .with_data({"action": "submit_feedback"}), OpenUrlAction(url="https://adaptivecards.microsoft.com").with_title("Learn More") ] ), ``` ### Raw JSON Alternative Just like when building cards, if you prefer to work with raw JSON, you can do just that. You get type safety for free in Python. ```python json = { "type": "Action.OpenUrl", "url": "https://adaptivecards.microsoft.com", "title": "Learn More", } ``` ## Working with Input Values ### Associating data with the cards Sometimes you want to send a card and have it be associated with some data. Set the `data` value to be sent back to the client so you can associate it with a particular entity. ```python from microsoft_teams.cards import AdaptiveCard, ActionSet, ExecuteAction, OpenUrlAction from microsoft_teams.cards.core import TextInput, ToggleInput # ... profile_card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ TextInput(id="name").with_label("Name").with_value("John Doe"), TextInput(id="email", label="Email", value="john@contoso.com"), ToggleInput(title="Subscribe to newsletter").with_id("subscribe").with_value("false"), ActionSet( actions=[ ExecuteAction(title="Save") # entity_id will come back after the user submits .with_data({"action": "save_profile", "entity_id": "12345"}), ] ), ], ) # Data received in handler: """ { "action": "save_profile", "entity_id": "12345", # From action data "name": "John Doe", # From name input "email": "john@doe.com", # From email input "subscribe": "true" # From toggle input (as string) } """ ``` ### Input Validation Input Controls provide ways for you to validate. More details can be found on the Adaptive Cards [documentation](https://adaptivecards.microsoft.com/?topic=input-validation). ```python from microsoft_teams.cards import AdaptiveCard, ActionSet, ExecuteAction, NumberInput, TextInput # ... def create_profile_card_input_validation(): age_input = NumberInput(id="age").with_label("age").with_is_required(True).with_min(0).with_max(120) # Can configure custom error messages name_input = TextInput(id="name").with_label("Name").with_is_required(True).with_error_message("Name is required") card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ age_input, name_input, TextInput(id="location").with_label("Location"), ActionSet( actions=[ ExecuteAction(title="Save") # All inputs should be validated .with_data({"action": "save_profile"}) .with_associated_inputs("auto") ] ), ], ) return card ``` ## Server Handlers ### Basic Structure Card actions arrive as `card.action` activities in your app. These give you access to the validated input values plus any `data` values you had configured to be sent back to you. ```python from microsoft_teams.api import AdaptiveCardInvokeActivity, AdaptiveCardActionErrorResponse, AdaptiveCardActionMessageResponse, HttpError, InnerHttpError, AdaptiveCardInvokeResponse from microsoft_teams.apps import ActivityContext # ... @app.on_card_action async def handle_card_action(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: data = ctx.activity.value.action.data if not data.get("action"): return AdaptiveCardActionErrorResponse( status_code=400, type="application/vnd.microsoft.error", value=HttpError( code="BadRequest", message="No action specified", inner_http_error=InnerHttpError( status_code=400, body={"error": "No action specified"}, ), ), ) print("Received action data:", data) if data["action"] == "submit_feedback": await ctx.send(f"Feedback received: {data.get('feedback')}") elif data["action"] == "purchase_item": await ctx.send(f"Purchase request received for game: {data.get('choiceGameSingle')}") elif data["action"] == "save_profile": await ctx.send( f"Profile saved!\nName: {data.get('name')}\nEmail: {data.get('email')}\nSubscribed: {data.get('subscribe')}" ) else: return AdaptiveCardActionErrorResponse( status_code=400, type="application/vnd.microsoft.error", value=HttpError( code="BadRequest", message="Unknown action", inner_http_error=InnerHttpError( status_code=400, body={"error": "Unknown action"}, ), ), ) return AdaptiveCardActionMessageResponse( status_code=200, type="application/vnd.microsoft.activity.message", value="Action processed successfully", ) ``` :::note The `data` values are accessible as a dictionary and can be accessed using `.get()` method for safe access. ::: --- ### Handling Dialog Submissions # Handling Dialog Submissions Dialogs have a specific `dialog_submit` event to handle submissions. When a user submits a form inside a dialog, the app is notified via this event, which is then handled to process the submission values, and can either send a response or proceed to more steps in the dialogs (see [Multi-step Dialogs](./handling-multi-step-forms)). In this example, we show how to handle dialog submissions from an Adaptive Card form: ```python from typing import Optional, Any from microsoft_teams.api import TaskSubmitInvokeActivity, TaskModuleResponse, TaskModuleMessageResponse from microsoft_teams.apps import ActivityContext # ... @app.on_dialog_submit async def handle_dialog_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): """Handle dialog submit events for all dialog types.""" data: Optional[Any] = ctx.activity.value.data dialog_type = data.get("submissiondialogtype") if data else None if dialog_type == "simple_form": name = data.get("name") if data else None await ctx.send(f"Hi {name}, thanks for submitting the form!") return TaskModuleResponse(task=TaskModuleMessageResponse(value="Form was submitted")) ``` Similarly, handling dialog submissions from rendered webpages is also possible: ```python from typing import Optional, Any from microsoft_teams.api import TaskSubmitInvokeActivity, InvokeResponse, TaskModuleResponse, TaskModuleMessageResponse from microsoft_teams.apps import ActivityContext # ... @app.on_dialog_submit async def handle_dialog_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): """Handle dialog submit events for all dialog types.""" data: Optional[Any] = ctx.activity.value.data dialog_type = data.get("submissiondialogtype") if data else None if dialog_type == "webpage_dialog": name = data.get("name") if data else None email = data.get("email") if data else None await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") return InvokeResponse( body=TaskModuleResponse(task=TaskModuleMessageResponse(value="Form submitted successfully")) ) ``` --- ### Listening To Events # Listening To Events An **event** is a foundational concept in building agents — it represents something noteworthy happening either on Microsoft Teams or within your application. These events can originate from the user (e.g. installing or uninstalling your app, sending a message, submitting a form), or from your application server (e.g. startup, error in a handler). ```mermaid flowchart LR Teams["Teams"]:::less-interesting Server["App Server"]:::interesting AppEventHandlers["Event Handler (app.event())"]:::interesting Teams --> |Activity| Server Teams --> |Signed In| Server Teams --> |...other
incoming events| Server Server ---> |incoming
events| AppEventHandlers Server ---> |outgoing
events
| AppEventHandlers linkStyle 0,1,2,3,4 stroke:#b1650f,stroke-width:1px classDef interesting fill:#b1650f,stroke:#333,stroke-width:4px; ``` The Teams SDK makes it easy to subscribe to these events and respond appropriately. You can register event handlers to take custom actions when specific events occur — such as logging errors, triggering workflows, or sending follow-up messages. Here are the events that you can start building handlers for: | **Event Name** | **Description** | | ------------------- | ------------------------------------------------------------------------------ | | `start` | Triggered when your application starts. Useful for setup or boot-time logging. | | `sign_in` | Triggered during a sign-in flow via Teams. | | `error` | Triggered when an unhandled error occurs in your app. Great for diagnostics. | | `activity` | Triggered for all incoming Teams activities (messages, commands, etc.). | | `activity_response` | Triggered when your app sends a response to an activity. Useful for logging. | | `activity_sent` | Triggered when an activity is sent (not necessarily in response). |
:::info Event handler registration uses `@app.event("")` with an async function that receives an event object specific to the event type (e.g., `ErrorEvent`, `ActivityEvent`). ::: ### Example 1 We can subscribe to errors that occur in the app. ```python @app.event("error") async def handle_error(event: ErrorEvent): """Handle error events.""" print(f"Error occurred: {event.error}") if hasattr(event, "context") and event.context: print(f"Context: {event.context}") ``` ### Example 2 When an activity is received, log its payload. ```python @app.event("activity") async def handle_activity(event: ActivityEvent): """Handle activity events.""" print(f"Activity received: {event.activity}") ``` --- ### Self-Managing Your Server # Self-Managing Your Server By default, `app.start()` spins up an HTTP server, registers the Teams endpoint, and manages the full lifecycle for you. Under the hood, the SDK uses [FastAPI](https://fastapi.tiangolo.com/) as its built-in HTTP framework. But if you need to self-manage your server — because you have an existing app, need custom server configuration (TLS, workers, middleware), or use a different HTTP framework — the SDK supports that through the `HttpServerAdapter` interface. ## How It Works The SDK splits HTTP handling into two layers: - **HttpServer** handles Teams protocol concerns: JWT authentication, activity parsing, and routing to your handlers. - **HttpServerAdapter** handles framework concerns: translating between your HTTP framework's request/response model and the SDK's pure handler pattern. ```mermaid flowchart LR Teams["Teams Service"] -->|HTTP POST| Adapter["HttpServerAdapter
(your framework)"] Adapter -->|"{ body, headers }"| HttpServer["HttpServer
(auth + parse)"] HttpServer --> Handlers["Your App Handlers"] Handlers -->|"{ status, body }"| HttpServer HttpServer --> Adapter Adapter -->|"HTTP Response"| Teams ``` The adapter interface is intentionally simple — implement `registerRoute` and the SDK handles the rest. ## The Adapter Interface ```python class HttpServerAdapter(Protocol): def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: ... def serve_static(self, path: str, directory: str) -> None: ... async def start(self, port: int) -> None: ... async def stop(self) -> None: ... class HttpRouteHandler(Protocol): async def __call__(self, request: HttpRequest) -> HttpResponse: ... ``` - **`registerRoute`** — Required. Routes are registered dynamically (`/api/messages`, `/api/functions/name`, etc.). - **`serveStatic`** — Optional. Only needed for tabs or static pages. - **`start` / `stop`** — Optional. Omit when you manage the server lifecycle yourself. ## Self-Managing Your Server To add Teams to an existing server: 1. Create your server with your own routes and middleware. 2. Wrap it in an adapter (or use the built-in one with your server instance). 3. Call `app.initialize()` — this registers the Teams routes on your server. Do **not** call `app.start()`. 4. Start the server yourself. ```python from fastapi import FastAPI from microsoft_teams.apps import App, FastAPIAdapter # 1. Create your FastAPI app with your own routes my_fastapi = FastAPI(title="My App + Teams Bot") @my_fastapi.get("/health") async def health(): return {"status": "healthy"} # 2. Wrap it in the FastAPIAdapter adapter = FastAPIAdapter(app=my_fastapi) # 3. Create the Teams app with the adapter app = App(http_server_adapter=adapter) @app.on_message async def handle_message(ctx): await ctx.send(f"Echo: {ctx.activity.text}") async def main(): # 4. Initialize — registers /api/messages on your FastAPI app (does NOT start a server) await app.initialize() # 5. Start the server yourself config = uvicorn.Config(app=my_fastapi, host="0.0.0.0", port=3978) server = uvicorn.Server(config) await server.serve() asyncio.run(main()) ``` > See the full example: [FastAPI non-managed example](https://github.com/microsoft/teams.py/tree/main/examples/http-adapters/src/fastapi_non_managed.py) ## Using a Different Framework If you use a framework other than the built-in default, implement the adapter interface for your framework. The core work is in `registerRoute` — translate incoming requests to ` body, headers `, call the handler, and write the response back. Since you manage the server lifecycle yourself, `start`/`stop` aren't needed. And `serveStatic` is only required if you serve tabs or static pages. Here is a Starlette adapter — only `register_route` is needed: ```python from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route from microsoft_teams.apps.http.adapter import HttpMethod, HttpRequest, HttpResponse, HttpRouteHandler class StarletteAdapter: def __init__(self, app: Starlette): self._app = app def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: # Teams only sends POST requests to your bot endpoint async def starlette_handler(request: Request) -> Response: body = await request.json() headers = dict(request.headers) result: HttpResponse = await handler(HttpRequest(body=body, headers=headers)) if result.get("body") is not None: return JSONResponse(content=result["body"], status_code=result["status"]) return Response(status_code=result["status"]) route = Route(path, starlette_handler, methods=[method]) self._app.routes.insert(0, route) ``` Usage: ```python starlette_app = Starlette() adapter = StarletteAdapter(starlette_app) app = App(http_server_adapter=adapter) await app.initialize() # Start Starlette with uvicorn yourself ``` > See the full implementation: [Starlette adapter example](https://github.com/microsoft/teams.py/tree/main/examples/http-adapters/src/starlette_adapter.py) --- ### ⚙️ Settings # ⚙️ Settings You can add a settings page that allows users to configure settings for your app. The user can access the settings by right-clicking the app item in the compose box.
Settings This guide will show how to enable user access to settings, as well as setting up a page that looks like this: ![Settings Page](/screenshots/settings-page.png) ## 1. Update the Teams Manifest Set the `canUpdateConfiguration` field to `true` in the desired message extension under `composeExtensions`. ```json "composeExtensions": [ { "botId": "${{BOT_ID}}", "canUpdateConfiguration": true, ... } ] ``` ## 2. Serve the settings `html` page This is the code snippet for the settings `html` page: ```html Message Extension Settings

Message Extension Settings

``` Save it in the `index.html` file in the same folder as where your app is initialized. You can serve it by adding the following code to your app: ```python app.page("settings", str(Path(__file__).parent), "/tabs/settings") ``` :::note This will serve the HTML page to the `$BOT_ENDPOINT/tabs/settings` endpoint as a tab. ::: ## 3. Specify the URL to the settings page To enable the settings page, your app needs to handle the `message.ext.query-settings-url` activity that Teams sends when a user right-clicks the app in the compose box. Your app must respond with the URL to your settings page. Here's how to implement this: ```python @app.on_message_ext_query_settings_url async def handle_message_ext_query_settings_url(ctx: ActivityContext[MessageExtensionQuerySettingUrlInvokeActivity]): user_settings = {"selectedOption": ""} escaped_selected_option = user_settings["selectedOption"] bot_endpoint = os.environ.get("BOT_ENDPOINT", "") settings_action = CardAction( type=CardActionType.OPEN_URL, title="Settings", value=f"{bot_endpoint}/tabs/settings?selectedOption={escaped_selected_option}", ) suggested_actions = MessagingExtensionSuggestedAction(actions=[settings_action]) result = MessagingExtensionResult(type=MessagingExtensionResultType.CONFIG, suggested_actions=suggested_actions) return MessagingExtensionInvokeResponse(compose_extension=result) ``` ## 4. Handle Form Submission When a user submits the settings form, Teams sends a `message.ext.setting` activity with the selected option in the `activity.value.state` property. Handle it to save the user's selection: ```python @app.on_message_ext_setting async def handle_message_ext_setting(ctx: ActivityContext[MessageExtensionSettingInvokeActivity]): state = getattr(ctx.activity.value, "state", None) if state == "CancelledByUser": result = MessagingExtensionResult( type=MessagingExtensionResultType.RESULT, attachment_layout=AttachmentLayout.LIST, attachments=[] ) return MessagingExtensionInvokeResponse(compose_extension=result) selected_option = state await ctx.send(f"Selected option: {selected_option}") result = MessagingExtensionResult( type=MessagingExtensionResultType.RESULT, attachment_layout=AttachmentLayout.LIST, attachments=[] ) return MessagingExtensionInvokeResponse(compose_extension=result) ``` --- ### 📖 Message Extensions # 📖 Message Extensions Message extensions (or Compose Extensions) allow your application to hook into messages that users can send or perform actions on messages that users have already sent. They enhance user productivity by providing quick access to information and actions directly within the Teams interface. Users can search or initiate actions from the compose message area, the command box, or directly from a message, with the results returned as richly formatted cards that make information more accessible and actionable. There are two types of message extensions: [API-based](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/api-based-overview) and [Bot-based](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/build-bot-based-message-extension?tabs=search-commands). API-based message extensions use an OpenAPI specification that Teams directly queries, requiring no additional application to build or maintain, but offering less customization. Bot-based message extensions require building an application to handle queries, providing more flexibility and customization options. This SDK supports bot-based message extensions only. ## Resources - [What are message extensions?](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/what-are-messaging-extensions?tabs=desktop) --- ### Handling Multi-Step Forms # Handling Multi-Step Forms Dialogs can become complex yet powerful with multi-step forms. These forms can alter the flow of the survey depending on the user's input or customize subsequent steps based on previous answers. Start off by sending an initial card in the `dialog_open` event. ```python dialog_card = AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "text": "This is a multi-step form", "size": "Large", "weight": "Bolder"}, { "type": "Input.Text", "id": "name", "label": "Name", "placeholder": "Enter your name", "isRequired": True, }, ], "actions": [ { "type": "Action.Submit", "title": "Submit", "data": {"submissiondialogtype": "webpage_dialog_step_1"}, } ], } ) ``` Then in the submission handler, you can choose to `continue` the dialog with a different card. ```python @app.on_dialog_submit async def handle_dialog_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): """Handle dialog submit events for all dialog types.""" data: Optional[Any] = ctx.activity.value.data dialog_type = data.get("submissiondialogtype") if data else None if dialog_type == "webpage_dialog": name = data.get("name") if data else None email = data.get("email") if data else None await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") return InvokeResponse( body=TaskModuleResponse(task=TaskModuleMessageResponse(value="Form submitted successfully")) ) elif dialog_type == "webpage_dialog_step_1": name = data.get("name") if data else None next_step_card = AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "text": "Email", "size": "Large", "weight": "Bolder"}, { "type": "Input.Text", "id": "email", "label": "Email", "placeholder": "Enter your email", "isRequired": True, }, ], "actions": [ { "type": "Action.Submit", "title": "Submit", "data": {"submissiondialogtype": "webpage_dialog_step_2", "name": name}, } ], } ) return InvokeResponse( body=TaskModuleResponse( task=TaskModuleContinueResponse( value=CardTaskModuleTaskInfo( title=f"Thanks {name} - Get Email", card=card_attachment(AdaptiveCardAttachment(content=next_step_card)), ) ) ) ) elif dialog_type == "webpage_dialog_step_2": name = data.get("name") if data else None email = data.get("email") if data else None await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") return InvokeResponse( body=TaskModuleResponse(task=TaskModuleMessageResponse(value="Multi-step form completed successfully")) ) return TaskModuleResponse(task=TaskModuleMessageResponse(value="Unknown submission type")) ``` --- ### In-Depth Guides # In-Depth Guides This documentation covers advanced features and capabilities of the Teams SDK in Python. This section provides comprehensive technical guides for integration with useful Teams features. Learn how to implement AI-powered bots, create adaptive cards, manage authentication flows, and build sophisticated message extensions. Each guide includes practical examples and best practices for production applications. --- ### 🔒 User Authentication # 🔒 User Authentication At times agents must access secured online resources on behalf of the user, such as checking email, checking on flight status, or placing an order. To enable this, the user must authenticate their identity and grant consent for the application to access these resources. This process results in the application receiving a token, which the application can then use to access the permitted resources on the user's behalf. :::info This is an advanced guide. It is highly recommended that you are familiar with [Teams Core Concepts](/teams/core-concepts) before attempting this guide. ::: :::warning User authentication does not work with the developer tools setup. You have to run the app in Teams. Follow these [instructions](/typescript/getting-started/running-in-teams#debugging-in-teams) to run your app in Teams. ::: :::info It is possible to authenticate the user into [other auth providers](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-concept-identity-providers?view=azure-bot-service-4.0&tabs=adv2%2Cga2#other-identity-providers) like Facebook, Github, Google, Dropbox, and so on. ::: Once you have configured your Azure Bot resource OAuth settings, as described in the [official documentation](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-concept-authentication?view=azure-bot-service-4.0), add the following code to your `App`: ## Project Setup ### Create an app with the `graph` template :::tip Skip this step if you want to add the auth configurations to an existing app. ::: Use your terminal to run the following command: ```sh npx @microsoft/teams.cli@latest new python oauth-app --template graph ``` This command: 1. Creates a new directory called `oauth-app`. 2. Bootstraps the graph agent template files into it under `oauth-app/src`. 3. Creates your agent's manifest files, including a `manifest.json` file and placeholder icons in the `oauth-app/appPackage` directory. ### Add Agents Toolkit auth configuration Open your terminal with the project folder set as the current working directory and run the following command: ```sh npx @microsoft/teams.cli config add atk.oauth ``` The `atk.oauth` configuration is a basic setup for Agents Toolkit along with configurations to authenticate the user with Microsoft Entra ID to access Microsoft Graph APIs. This [CLI](/developer-tools/cli) command adds configuration files required by Agents Toolkit, including: - Azure Application Entra ID manifest file `aad.manifest.json`. - Azure bicep files to provision Azure bot in `infra/` folder. :::info Agents Toolkit, in the debugging flow, will deploy the `aad.manifest.json` and `infra/azure.local.bicep` file to provision the Application Entra ID and Azure bot with oauth configurations. ::: ## Configure the OAuth connection ```python from teams import App from teams.api import MessageActivity, SignInEvent from teams.apps import ActivityContext from teams.logger import ConsoleLogger, ConsoleLoggerOptions app = App( # The name of the auth connection to use. # It should be the same as the Oauth connection name defined in the Azure Bot configuration. default_connection_name="graph", logger=ConsoleLogger().create_logger("auth", options=ConsoleLoggerOptions(level="debug"))) ``` :::tip Make sure you use the same name you used when creating the OAuth connection in the Azure Bot Service resource. ::: :::note In many templates, `graph` is the default name of the OAuth connection, but you can change that by supplying a different connection name in your app configuration. ::: ## Signing In :::note This uses the Single Sign-On (SSO) authentication flow. To learn more about all the available flows and their differences see the [official documentation](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-concept-authentication?view=azure-bot-service-4.0). ::: You must call the `signin` method inside your route handler, for example: to signin when receiving the `/signin` message: ```python @app.on_message async def handle_signin_message(ctx: ActivityContext[MessageActivity]): """Handle message activities for signing in.""" ctx.logger.info("User requested sign-in.") if ctx.is_signed_in: await ctx.send("You are already signed in.") else: await ctx.sign_in() ``` ## Subscribe to the SignIn event You can subscribe to the `signin` event, that will be triggered once the OAuth flow completes. ```python @app.event("sign_in") async def handle_sign_in(event: SignInEvent): """Handle sign-in events.""" await event.activity_ctx.send("You are now signed in!") ``` ## Start using the graph client From this point, you can use the `IsSignedIn` flag and the `userGraph` client to query graph, for example to reply to the `/whoami` message, or in any other route. :::note The default OAuth configuration requests the `User.ReadBasic.All` permission. It is possible to request other permissions by modifying the App Registration for the bot on Azure. ::: ```python @app.on_message async def handle_whoami_message(ctx: ActivityContext[MessageActivity]): """Handle messages to show user information from Microsoft Graph.""" if not ctx.is_signed_in: await ctx.send("You are not signed in! Please sign in to continue.") return # Access user's Microsoft Graph data me = await ctx.user_graph.me.get() await ctx.send(f"Hello {me.display_name}! Your email is {me.mail or me.user_principal_name}") @app.on_message async def handle_all_messages(ctx: ActivityContext[MessageActivity]): """Handle all other messages.""" if ctx.is_signed_in: await ctx.send(f'You said: "{ctx.activity.text}". Please type **/whoami** to see your profile or **/signout** to sign out.') else: await ctx.send(f'You said: "{ctx.activity.text}". Please type **/signin** to sign in.') ``` ## Signing Out You can signout by calling the `signout` method, this will remove the token from the User Token service cache ```python @app.on_message async def handle_signout_message(ctx: ActivityContext[MessageActivity]): """Handle sign out requests.""" if not ctx.is_signed_in: await ctx.send("You are not signed in!") return await ctx.sign_out() await ctx.send("You have been signed out!") ``` ## Handling Sign-In Failures When using SSO, if the token exchange fails Teams sends a `signin/failure` invoke activity to your app. The SDK includes a built-in default handler that logs a warning with actionable troubleshooting guidance. You can optionally register your own handler to customize the behavior: ```python @app.on_signin_failure() async def handle_signin_failure(ctx): failure = ctx.activity.value print(f"Sign-in failed: {failure.code} - {failure.message}") await ctx.send("Sign-in failed.") ``` :::note In Python, registering a custom handler does **not** replace the built-in default handler. Both will run as part of the middleware chain. ::: :::tip The most common failure codes are `installedappnotfound` (bot app not installed for the user) and `resourcematchfailed` (Token Exchange URL doesn't match the Application ID URI). See [SSO Setup - Troubleshooting](/teams/user-authentication/sso-setup#troubleshooting-sso) for a full list of failure codes and troubleshooting steps. ::: ## Regional Configs You may be building a regional bot that is deployed in a specific Azure region (such as West Europe, East US, etc.) rather than global. This is important for organizations that have data residency requirements or want to reduce latency by keeping data and authentication flows within a specific area. These examples use West Europe, but follow the equivalent for other regions. To configure a new regional bot in Azure, you must setup your resoures in the desired region. Your resource group must also be in the same region. 1. Deploy a new App Registration in `westeurope`. 2. Deploy and link a new Enterprise Application (Service Principal) on Microsoft Entra in `westeurope`. 3. Deploy and link a new Azure Bot in `westeurope`. 4. In your App Registration, in the `Authentication (Preview)` tab, add a `Redirect URI` for the Platform Type `Web` to your regional endpoint (e.g., `https://europe.token.botframework.com/.auth/web/redirect`) ![Authentication Tab](/screenshots/regional-auth.png) 5. In your `.env` file (or wherever you set your environment variables), add your `OAUTH_URL`. For example: `OAUTH_URL=https://europe.token.botframework.com` To configure a new regional bot with ATK, you will need to make a few updates. Note that this assumes you have not yet deployed the bot previously. 1. In `azurebot.bicep`, replace all `global` occurrences to `westeurope` 2. In `manifest.json`, in `validDomains`, `*.botframework.com` should be replaced by `europe.token.botframework.com` 3. In `aad.manifest.json`, replace `https://token.botframework.com/.auth/web/redirect` with `https://europe.token.botframework.com/.auth/web/redirect` 4. In your `.env` file, add your `OAUTH_URL`. For example: `OAUTH_URL=https://europe.token.botframework.com`. ## Resources [User Authentication Basics](https://learn.microsoft.com/en-us/azure/bot-service/bot-builder-concept-authentication?view=azure-bot-service-4.0) --- ### 🔗 Link unfurling # 🔗 Link unfurling Link unfurling lets your app respond when users paste URLs into Teams. When a URL from your registered domain is pasted, your app receives the URL and can return a card with additional information or actions. This works like a search command where the URL acts as the search term. :::note Users can use link unfurling even before they discover or install your app in Teams. This is called [Zero install link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling?tabs=desktop%2Cjson%2Cadvantages#zero-install-for-link-unfurling). In this scenario, your app will receive a `message.ext.anon-query-link` activity instead of the usual `message.ext.query-link`. ::: ## Setting up your Teams app manifest ### Configure message handlers ```json "composeExtensions": [ { "botId": "${{BOT_ID}}", "messageHandlers": [ { "type": "link", "value": { "domains": [ "www.test.com" ] } } ] } ] ``` ### How link unfurling works When a user pastes a URL from your registered domain (like `www.test.com`) into the Teams compose box, your app will receive a notification. Your app can then respond by returning an adaptive card that displays a preview of the linked content. This preview card appears before the user sends their message in the compose box, allowing them to see how the link will be displayed to others. ```mermaid flowchart TD A1["User pastes a URL (e.g., www\.test\.com) in Teams compose box"] B1([Microsoft Teams]) C1["Your App"] D1["Adaptive Card Preview"] A1 --> B1 B1 -->|Sends URL paste notification| C1 C1 -->|Returns card and preview| B1 B1 --> D1 %% Styling for readability and compatibility style B1 fill:#2E86AB,stroke:#1B4F72,stroke-width:2px,color:#ffffff style C1 fill:#28B463,stroke:#1D8348,stroke-width:2px,color:#ffffff style D1 fill:#F39C12,stroke:#D68910,stroke-width:2px,color:#ffffff ``` ## Implementing link unfurling ### Handle the query link event Handle link unfurling when a URL from your registered domain is submitted into the Teams compose box. ```python from microsoft_teams.api import ( AdaptiveCardAttachment, MessageExtensionQueryLinkInvokeActivity, ThumbnailCardAttachment, card_attachment, InvokeResponse, AttachmentLayout, MessagingExtensionAttachment, MessagingExtensionInvokeResponse, MessagingExtensionResult, MessagingExtensionResultType, ) from microsoft_teams.apps import ActivityContext # ... @app.on_message_ext_query_link async def handle_message_ext_query_link(ctx: ActivityContext[MessageExtensionQueryLinkInvokeActivity]): url = ctx.activity.value.url if not url: return InvokeResponse[MessagingExtensionInvokeResponse](status400.txt) card_data = create_link_unfurl_card(url) main_attachment = card_attachment(AdaptiveCardAttachment(content=card_data["card"])) preview_attachment = card_attachment(ThumbnailCardAttachment(content=card_data["thumbnail"])) attachment = MessagingExtensionAttachment( content_type=main_attachment.content_type, content=main_attachment.content, preview=preview_attachment, ) result = MessagingExtensionResult( type=MessagingExtensionResultType.RESULT, attachment_layout=AttachmentLayout.LIST, attachments=[attachment], ) return MessagingExtensionInvokeResponse(compose_extension=result) ``` ### Create the unfurl card `create_link_unfurl_card()` function ```python from typing import Any, Dict from microsoft_teams.cards import AdaptiveCard # ... def create_link_unfurl_card(url: str) -> Dict[str, Any]: """Create a card for link unfurling.""" thumbnail = { "title": "Unfurled Link", "text": url, "images": [{"url": IMAGE_URL}], } card = AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "TextBlock", "text": "Unfurled Link", "size": "Large", "weight": "Bolder", "color": "Accent", "style": "heading", }, { "type": "TextBlock", "text": url, "size": "Small", "weight": "Lighter", "color": "Good", }, ], } ) return {"card": card, "thumbnail": thumbnail} ``` ### User experience flow The link unfurling response includes both a full adaptive card and a preview card. The preview card appears in the compose box when a user pastes a URL: ![Screenshot showing a preview card for an unfurled URL in the Teams compose box.](/screenshots/link-unfurl-preview.png) The user can expand the preview card by clicking on the _expand_ button on the top right. ![Screenshot of Teams compose box with an outline around the unfurled link card labeled 'Adaptive Card'.](/screenshots/link-unfurl-card.png) The user can then choose to send either the preview or the full adaptive card as a message. ## Resources - [Link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling?tabs=desktop%2Cjson%2Cadvantages) - [Zero install link unfurling](https://learn.microsoft.com/en-us/microsoftteams/platform/messaging-extensions/how-to/link-unfurling?tabs=desktop%2Cjson%2Cadvantages#zero-install-for-link-unfurling) --- ### Sending Messages # Sending Messages Sending messages is a core part of an agent's functionality. With all activity handlers, a `send` method is provided which allows your handlers to send a message back to the user to the relevant conversation. ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.send(f"You said '{ctx.activity.text}'") ``` In the above example, the handler gets a `message` activity, and uses the `send` method to send a reply to the user. ```python @app.event("sign_in") async def handle_sign_in(event: SignInEvent): """Handle sign-in events.""" await event.activity_ctx.send("You are now signed in!") ``` You are not restricted to only replying to `message` activities. In the above example, the handler is listening to `sign_in` events, which are sent when a user successfully signs in. :::tip This shows an example of sending a text message. Additionally, you are able to send back things like [adaptive cards](../../in-depth-guides/adaptive-cards) by using the same `send` method. Look at the [adaptive card](../../in-depth-guides/adaptive-cards) section for more details. ::: ## Streaming You may also stream messages to the user which can be useful for long messages, or AI generated messages. The SDK makes this simple for you by providing a `stream` function which you can use to send messages in chunks. ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): ctx.stream.update("Stream starting...") await asyncio.sleep(1) # Stream messages with delays using ctx.stream.emit for message in STREAM_MESSAGES: # Add some randomness to timing await asyncio.sleep(random()) ctx.stream.emit(message) ``` :::note Streaming is currently only supported in 1:1 conversations, not group chats or channels ::: ![Animated image showing agent response text incrementally appearing in the chat window.](/screenshots/streaming-chat.gif) ## @Mention Sending a message at `@mentions` a user is as simple including the details of the user using the `add_mention` method ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): await ctx.send(MessageActivityInput(text='hi!').add_mention(account=ctx.activity.from_)) ``` ## Targeted Messages :::info[Preview] Targeted messages are currently in preview. ::: Targeted messages, also known as ephemeral messages, are delivered to a specific user in a shared conversation. From a single user's perspective, they appear as regular inline messages in a conversation. Other participants won't see these messages, making them useful for authentication flows, help or error responses, personal reminders, or sharing contextual information without cluttering the group conversation. To send a targeted message when responding to an incoming activity, use the `with_recipient` method with the recipient account and set the targeting flag to true. ```python from microsoft_teams.api import MessageActivity, MessageActivityInput from microsoft_teams.apps import ActivityContext @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): # Using with_recipient with is_targeted=True explicitly targets the specified recipient await ctx.send( MessageActivityInput(text="This message is only visible to you!") .with_recipient(ctx.activity.from_, is_targeted=True) ) ``` ### Targeted messages in preview ## Reactions :::info[Preview] Reactions are currently in preview. ::: Reactions allow your agent to add or remove emoji reactions on messages in a conversation. The reactions client is available via the API client. ### Reactions in preview --- ### App Authentication # App Authentication Your application needs to authenticate to send messages to Teams as your bot. Authentication allows your app service to certify that it is _allowed_ to send messages as your Azure Bot. :::info Azure Setup Required Before configuring your application, you must first set up authentication in Azure. See the [App Authentication Setup](/teams/app-authentication) guide for instructions on creating the necessary Azure resources. ::: ## Authentication Methods There are 3 main ways of authenticating: 1. **Client Secret** - Simple password-based authentication using a client secret 2. **User Managed Identity** - Passwordless authentication using Azure managed identities 3. **Federated Identity Credentials** - Advanced identity federation using managed identities ## Configuration Reference The Teams SDK automatically detects which authentication method to use based on the environment variables you set: | CLIENT_ID | CLIENT_SECRET | MANAGED_IDENTITY_CLIENT_ID | Authentication Method | |-|-|-|-| | not_set | | | No-Auth (local development only) | | set | set | | Client Secret | | set | not_set | | User Managed Identity | | set | not_set | set (same as CLIENT_ID) | User Managed Identity | | set | not_set | set (different from CLIENT_ID) | Federated Identity Credentials (UMI) | | set | not_set | "system" | Federated Identity Credentials (System Identity) | ## Client Secret The simplest authentication method using a password-like secret. ### Setup First, complete the [Client Secret Setup](/teams/app-authentication/client-secret) in Azure Portal or Azure CLI. ### Configuration Set the following environment variables in your application: - `CLIENT_ID`: Your Application (client) ID - `CLIENT_SECRET`: The client secret value you created - `TENANT_ID`: The tenant id where your bot is registered ```env CLIENT_ID=your-client-id-here CLIENT_SECRET=your-client-secret-here TENANT_ID=your-tenant-id ``` The SDK will automatically use Client Secret authentication when both `CLIENT_ID` and `CLIENT_SECRET` are provided. ## User Managed Identity Passwordless authentication using Azure managed identities - no secrets to rotate or manage. ### Setup First, complete the [User Managed Identity Setup](/teams/app-authentication/user-managed-identity) in Azure Portal or Azure CLI. ### Configuration Your application should automatically use User Managed Identity authentication when you provide the `CLIENT_ID` environment variable without a `CLIENT_SECRET`. ## Configuration Set the following environment variables in your application: - `CLIENT_ID`: Your Application (client) ID - **Do not set** `CLIENT_SECRET` - `TENANT_ID`: The tenant id where your bot is registered ```env CLIENT_ID=your-client-id-here # Do not set CLIENT_SECRET TENANT_ID=your-tenant-id ``` ## Federated Identity Credentials Advanced identity federation allowing you to assign managed identities directly to your App Registration. ### Setup First, complete the [Federated Identity Credentials Setup](/teams/app-authentication/federated-identity-credentials) in Azure Portal or Azure CLI. ### Configuration Depending on the type of managed identity you select, set the environment variables accordingly. **For User Managed Identity:** Set the following environment variables: - `CLIENT_ID`: Your Application (client) ID - `MANAGED_IDENTITY_CLIENT_ID`: The Client ID for the User Managed Identity resource - **Do not set** `CLIENT_SECRET` - `TENANT_ID`: The tenant id where your bot is registered ```env CLIENT_ID=your-app-client-id-here MANAGED_IDENTITY_CLIENT_ID=your-managed-identity-client-id-here # Do not set CLIENT_SECRET TENANT_ID=your-tenant-id ``` **For System Assigned Identity:** Set the following environment variables: - `CLIENT_ID`: Your Application (client) ID - `MANAGED_IDENTITY_CLIENT_ID`: `system` - **Do not set** `CLIENT_SECRET` - `TENANT_ID`: The tenant id where your bot is registered ```env CLIENT_ID=your-app-client-id-here MANAGED_IDENTITY_CLIENT_ID=system # Do not set CLIENT_SECRET TENANT_ID=your-tenant-id ``` ## Troubleshooting If you encounter authentication errors, see the [Authentication Troubleshooting](/teams/app-authentication/troubleshooting) guide for common issues and solutions. --- ### Teams API Client # Teams API Client Teams has a number of areas that your application has access to via its API. These are all available via the `app.api` object. Here is a short summary of the different areas: | Area | Description | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `conversations` | Gives your application the ability to perform activities on conversations (send, update, delete messages, etc.), or create conversations (like 1:1 chat with a user) | | `meetings` | Gives your application access to meeting details and participant information via `get_by_id` and `get_participant` | | `teams` | Gives your application access to team or channel details | An instance of the API client is passed to handlers that can be used to fetch details: ## Example In this example, we use the API client to fetch the members in a conversation. The `api` object is passed to the activity handler in this case. ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): members = await ctx.api.conversations.members.get(ctx.activity.conversation.id) ``` ## Proactive API It's also possible to access the API client from outside a handler via the app instance. Here we have the same example as above, but we're access the API client via the app instance. ```python members = await app.api.conversations.members.get("...") ``` ## Meetings Example In this example, we use the API client to get a specific meeting participant's details, such as their role (e.g. Organizer) and whether they are currently in the meeting. Provide the user's AAD Object ID to specify which participant to look up. The `meetingId` and `tenantId` are available from the activity's channel data. :::note To retrieve **all** members of a meeting, use the conversations API as shown in the [example above](#example), since meetings are also conversations. ::: ```python @app.on_activity("meetingStart") async def handle_meeting_start(ctx: ActivityContext): meeting_id = ctx.activity.channel_data.meeting.id tenant_id = ctx.activity.channel_data.tenant.id user_id = ctx.activity.from_.aad_object_id if meeting_id and tenant_id and user_id: participant = await ctx.api.meetings.get_participant(meeting_id, user_id, tenant_id) # participant.meeting.role — "Organizer", "Presenter", "Attendee" # participant.meeting.in_meeting — True/False ``` Visit [Meeting Events](../in-depth-guides/meeting-events) to learn more about meeting events. --- ### Feedback # Feedback User feedback is essential for the improvement of any application. Teams provides specialized UI components to help facilitate the gathering of feedback from users. ![Animated image showing user selecting the thumbs-up button on an agent response and a dialog opening asking 'What did you like?'. The user types 'Nice' and hits Submit.](/screenshots/feedback.gif) ## Storage Once you receive a feedback event, you can choose to store it in some persistent storage. In the example below, we are storing it in an in-memory store. Once you receive a feedback event, you can choose to store it in some persistent storage. You'll need to implement storage for tracking: - Like/dislike counts per message - Text feedback comments - Message ID associations For production applications, consider using databases, file systems, or cloud storage. The examples below use in-memory storage for simplicity. ## Including Feedback Buttons When sending a message that you want feedback in, simply add feedback functionality to the message you are sending. ```python from microsoft_teams.ai import Agent from microsoft_teams.api import MessageActivityInput from microsoft_teams.apps import ActivityContext, MessageActivity @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): """Handle 'feedback demo' command to demonstrate feedback collection""" agent = Agent(current_model) chat_result = await agent.send( input="Tell me a short joke", instructions="You are a comedian. Keep responses brief and funny." ) if chat_result.response.content: message = MessageActivityInput(text=chat_result.response.content) .add_ai_generated() # Create message with feedback enabled .add_feedback() await ctx.send(message) ``` ## Handling the feedback Once the user decides to like/dislike the message, you can handle the feedback in a received event. Once received, you can choose to include it in your persistent store. ```python from typing import Dict, Any from microsoft_teams.api import MessageSubmitActionInvokeActivity from microsoft_teams.apps import ActivityContext # ... # Handle feedback submission events @app.on_message_submit_feedback async def handle_message_feedback(ctx: ActivityContext[MessageSubmitActionInvokeActivity]): """Handle feedback submission events""" activity = ctx.activity # Extract feedback data from activity value if not hasattr(activity, "value") or not activity.value: logger.warning(f"No value found in activity {activity.id}") return # Access feedback data directly from invoke value invoke_value = activity.value assert invoke_value.action_name == "feedback" feedback_str = invoke_value.action_value.feedback reaction = invoke_value.action_value.reaction feedback_json: Dict[str, Any] = json.loads(feedback_str) # { 'feedbackText': 'the ai response was great!' } if not activity.reply_to_id: logger.warning(f"No replyToId found for messageId {activity.id}") return # Store the feedback (implement your own storage logic) upsert_feedback_storage(activity.reply_to_id, reaction, feedback_json.get('feedbackText', '')) # Optionally Send confirmation response feedback_text: str = feedback_json.get("feedbackText", "") reaction_text: str = f" and {reaction}" if reaction else "" text_part: str = f" with comment: '{feedback_text}'" if feedback_text else "" await ctx.reply(f"✅ Thank you for your feedback{reaction_text}{text_part}!") ``` --- ### Graph API Client # Graph API Client [Microsoft Graph](https://docs.microsoft.com/en-us/graph/overview) gives you access to the wider Microsoft 365 ecosystem. You can enrich your application with data from across Microsoft 365. The SDK gives your application easy access to the Microsoft Graph API via the `microsoft-teams-graph` package. ## Calling APIs Microsoft Graph can be accessed by your application using its own application token, or by using the user's token. If you need access to resources that your application may not have, but your user does, you will need to use the user's scoped graph client. To grant explicit consent for your application to access resources on behalf of a user, follow the [auth guide](../in-depth-guides/user-authentication). To access the graph using the Graph using the app, you may use the `app.graph` object to call the endpoint of your choice. ```python # Equivalent of https://learn.microsoft.com/en-us/graph/api/user-get # Gets the details of the bot-user user = await app.graph.me.get() print(f"User ID: {user.id}") print(f"User Display Name: {user.display_name}") print(f"User Email: {user.mail}") print(f"User Job Title: {user.job_title}") ``` You can also access the graph using the user's token from within a message handler via the `user_graph` property. ```python @app.on_message async def handle_message(ctx: ActivityContext[MessageActivity]): user = await ctx.user_graph.me.get() print(f"User ID: {user.id}") print(f"User Display Name: {user.display_name}") print(f"User Email: {user.mail}") print(f"User Job Title: {user.job_title}") ``` Here, the `user_graph` object is a scoped graph client for the user that sent the message. :::tip You also have access to the `app_graph` object in the activity handler. This is equivalent to `app.graph`. ::: --- ### Meeting Events # Meeting Events Microsoft Teams provides meeting events that allow your application to respond to various meeting lifecycle changes. Your app can listen to events like when a meeting starts, meeting ends, and participant activities to create rich, interactive experiences. ## Overview Meeting events enable your application to: - Send notifications when meetings start or end - Track participant activity (join/leave events) - Display relevant information or cards based on meeting context - Integrate with meeting workflows ## Configuring Your Bot There are a few requirements in the Teams app manifest (`manifest.json`) to support these events. 1. The scopes section must include `team`, and `groupChat` ```json bots": [ { "botId": "", "scopes": [ "team", "personal", "groupChat" ], "isNotificationOnly": false } ] ``` 2. In the authorization section, make sure to specify the following resource-specific permissions: ```json "authorization":{ "permissions":{ "resourceSpecific":[ { "name":"OnlineMeetingParticipant.Read.Chat", "type":"Application" }, { "name":"ChannelMeeting.ReadBasic.Group", "type":"Application" }, { "name":"OnlineMeeting.ReadBasic.Chat", "type":"Application" } ] } } ``` 3. In the Teams Developer Portal, for your `Bot`, make sure the `Meeting Event Subscriptions` are checked off. This enables you to receive the Meeting Participant events. For these events, you must create your Bot via TDP. ## Meeting Start Event When a meeting starts, your app can handle the `meetingStart` event to send a notification or card to the meeting chat. ```python from microsoft_teams.api.activities.event import MeetingStartEventActivity from microsoft_teams.apps import ActivityContext, App from microsoft_teams.cards import AdaptiveCard, OpenUrlAction, TextBlock app = App() @app.on_meeting_start async def handle_meeting_start(ctx: ActivityContext[MeetingStartEventActivity]): meeting_data = ctx.activity.value start_time = meeting_data.start_time.strftime("%c") card = AdaptiveCard( body=[ TextBlock( text=f"'{meeting_data.title}' has started at {start_time}.", wrap=True, weight="Bolder", ) ], actions=[OpenUrlAction(url=meeting_data.join_url, title="Join the meeting")], ) await ctx.send(card) ``` ## Meeting End Event When a meeting ends, your app can handle the `meetingEnd` event to send a summary or follow-up information. ```python from microsoft_teams.api.activities.event import MeetingEndEventActivity from microsoft_teams.apps import ActivityContext, App from microsoft_teams.cards import AdaptiveCard, TextBlock app = App() @app.on_meeting_end async def handle_meeting_end(ctx: ActivityContext[MeetingEndEventActivity]): meeting_data = ctx.activity.value end_time = meeting_data.end_time.strftime("%c") card = AdaptiveCard( body=[ TextBlock( text=f"'{meeting_data.title}' has ended at {end_time}.", wrap=True, weight="Bolder", ) ] ) await ctx.send(card) ``` ## Participant Join Event When a participant joins a meeting, your app can handle the `meetingParticipantJoin` event to welcome them or display their role. ```python from microsoft_teams.api.activities.event import MeetingParticipantJoinEventActivity from microsoft_teams.apps import ActivityContext, App from microsoft_teams.cards import AdaptiveCard, TextBlock app = App() @app.on_meeting_participant_join async def handle_meeting_participant_join(ctx: ActivityContext[MeetingParticipantJoinEventActivity]): meeting_data = ctx.activity.value member = meeting_data.members[0].user.name role = meeting_data.members[0].meeting.role if hasattr(meeting_data.members[0].meeting, "role") else "a participant" card = AdaptiveCard( body=[ TextBlock( text=f"{member} has joined the meeting as {role}.", wrap=True, weight="Bolder", ) ] ) await ctx.send(card) ``` ## Participant Leave Event When a participant leaves a meeting, your app can handle the `meetingParticipantLeave` event to notify others. ```python from microsoft_teams.api.activities.event import MeetingParticipantLeaveEventActivity from microsoft_teams.apps import ActivityContext, App from microsoft_teams.cards import AdaptiveCard, TextBlock app = App() @app.on_meeting_participant_leave async def handle_meeting_participant_leave(ctx: ActivityContext[MeetingParticipantLeaveEventActivity]): meeting_data = ctx.activity.value member = meeting_data.members[0].user.name card = AdaptiveCard( body=[ TextBlock( text=f"{member} has left the meeting.", wrap=True, weight="Bolder", ) ] ) await ctx.send(card) ``` --- ### Observability # Observability ---