# Agents Sdk Dotnet Activityhandler Migration

> Use when migrating a Microsoft 365 Agents SDK agent that uses ActivityHandler or TeamsActivityHandler to AgentApplication. This is ONLY for DotNet projects that uses `Microsoft.Agents.*` packages. Triggered by Agents SDK bots that subclass ActivityHandler or TeamsActivityHandler that need to be modernized to AgentApplication routing.

- Skill: `tomevault-io/agents-sdk-dotnet-activityhandler-migration` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/agents-sdk-dotnet-activityhandler-migration`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/agents-sdk-dotnet-activityhandler-migration/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/agents-sdk-dotnet-activityhandler-migration

---


# Agents SDK ActivityHandler → AgentApplication Migration (.NET)

## Overview

Upgrades a bot from the ActivityHandler compat layer to the modern `AgentApplication` routing pattern. This is ONLY for DotNet projects that already use `Microsoft.Agents.*` packages.  If the project is still using `Microsoft.Bot.*` packages the `bf-to-agents-sdk-dotnet-migration` skill should be used first.

**Two entry points:**
- **Standalone:** The bot already uses `Microsoft.Agents.*` packages, namespaces, and appsettings — focus is on the bot class, Program.cs, and endpoint mapping.
- **Second step after `bf-to-agents-sdk-dotnet-migration`:** Packages, namespaces, and appsettings were converted in that step — this skill handles the AgentApplication conversion.

---

## What Does NOT Need to Change

Verify each of these is already correct — do not modify unless broken:

| Item | Expected State |
|------|---------------|
| Namespaces in bot class | Already `Microsoft.Agents.*` — only remove compat namespaces |
| `appsettings.json` | Already has `Connections` + `TokenValidation` sections |
| `builder.Services.AddAgentAspNetAuthentication(...)` | Already present — keep |
| `AspNetExtensions.cs` (if present) | Sample-provided auth helper — keep as-is |
| Custom DI in Program.cs | Preserve dialogs, adapters, custom services — **but remove `ConversationState` and `UserState`** (see Step 2) |

> **⚠️ If beta packages are required:** Tell the user explicitly: *"This migration uses pre-release (`-beta`) Agents SDK packages, which are nightly builds and not suitable for production. Upgrade to a stable release before deploying."*

---

## Handler Mapping: ActivityHandler → AgentApplication

| ActivityHandler Override | AgentApplication Registration |
|--------------------------|-------------------------------|
| `OnMessageActivityAsync(ITurnContext<IMessageActivity>, CT)` | `OnActivity(ActivityTypes.Message, handler, rank: RouteRank.Last)` |
| `OnMembersAddedAsync(IList<ChannelAccount>, ITurnContext<IConversationUpdateActivity>, CT)` | `OnConversationUpdate(ConversationUpdateEvents.MembersAdded, handler)` |
| `OnMembersRemovedAsync(...)` | `OnConversationUpdate(ConversationUpdateEvents.MembersRemoved, handler)` |
| `OnMessageUpdateActivityAsync(...)` | `OnActivity(ActivityTypes.MessageUpdate, handler)` |
| `OnMessageDeleteActivityAsync(...)` | `OnActivity(ActivityTypes.MessageDelete, handler)` |
| `OnReactionsAddedAsync(IList<MessageReaction>, ...)` | `OnMessageReactionsAdded(handler)` |
| `OnReactionsRemovedAsync(IList<MessageReaction>, ...)` | `OnMessageReactionsRemoved(handler)` |
| `OnEventAsync(ITurnContext<IEventActivity>, CT)` | `OnEvent(eventName, handler)` or `OnActivity(ActivityTypes.Event, handler)` |
| `OnInvokeActivityAsync(...)` with `adaptiveCard/action` + verb switch | `AdaptiveCards.OnActionExecute(verb, handler)` — one registration per verb (preferred) |
| `OnInvokeActivityAsync(...)` with `application/search` | `AdaptiveCards.OnSearch(dataset, handler)` — one registration per `choices.data.dataset` value |
| `OnInvokeActivityAsync(...)` (other invoke types) | `OnActivity(ActivityTypes.Invoke, handler)` |
| `OnTurnAsync(...)` override (cross-cutting) | `OnBeforeTurn(handler)` / `OnAfterTurn(handler)` |
| `OnInstallationUpdateActivityAsync(...)` | `OnActivity(ActivityTypes.InstallationUpdate, handler)` |
| `OnEndOfConversationActivityAsync(...)` | `OnActivity(ActivityTypes.EndOfConversation, handler)` |

**Key difference:** Handler signature changes from `(ITurnContext<T>, CT)` to `(ITurnContext, ITurnState, CT)`.
`membersAdded`, `membersRemoved`, `messageReactions` are no longer separate parameters — access via `turnContext.Activity.*`.

### `adaptiveCard/action` invokes → `AdaptiveCards.OnActionExecute`

If the bot overrides `OnInvokeActivityAsync` and switches on a verb from `adaptiveCard/action` activities, replace it with per-verb `AdaptiveCards.OnActionExecute` registrations. `AdaptiveCards` is a property on `AgentApplication` (no `RegisterExtension` needed):

```csharp
// Before: one OnInvokeActivityAsync with switch(verb)
protected override async Task<InvokeResponse> OnInvokeActivityAsync(ITurnContext<IInvokeActivity> ctx, CancellationToken ct)
{
    if (ctx.Activity.Name == "adaptiveCard/action")
    {
        var wrapper = JsonSerializer.Deserialize<AdaptiveCardInvokeValue>(ctx.Activity.Value.ToString());
        switch (wrapper.Action.Verb)
        {
            case "approve": return CreateInvokeResponse(GetApproveCard(wrapper.Action.Data));
            case "reject":  return CreateInvokeResponse(GetRejectCard(wrapper.Action.Data));
        }
    }
    return null;
}

// After: one registration per verb — framework filters and extracts action.data
public MyBot(AgentApplicationOptions options) : base(options)
{
    AdaptiveCards.OnActionExecute("approve", OnApproveAsync);
    AdaptiveCards.OnActionExecute("reject",  OnRejectedAsync);
}

// Handler receives action.data directly — NOT the full AdaptiveCardInvokeValue
// Return AdaptiveCardInvokeResponse directly — no CreateInvokeResponse() wrapper
private Task<AdaptiveCardInvokeResponse> OnApproveAsync(
    ITurnContext turnContext, ITurnState turnState, object data, CancellationToken ct)
{
    var actionData = ProtocolJsonSerializer.ToObject<MyDataModel>(data);  // data == action.data
    return Task.FromResult(new AdaptiveCardInvokeResponse
    {
        StatusCode = 200,
        Type = "application/vnd.microsoft.card.adaptive",
        Value = BuildCard(actionData)
    });
}
```

**Key points:**
- `data` parameter = `invokeValue.Action.Data` — deserialize as your data model, not as `AdaptiveCardInvokeValue`
- Return `AdaptiveCardInvokeResponse` directly — the framework wraps it in an `InvokeResponse` automatically
- `CreateInvokeResponse()` is a compat-layer helper that does not exist on `AgentApplication`
- Namespace: `using Microsoft.Agents.Builder.App.AdaptiveCards;`

---

## Step 1-a: Convert Bot Class to AgentApplication (not using Dialogs)

- Remove `using Microsoft.Agents.Builder.Compat;` (ActivityHandler namespace)
- Remove `using Microsoft.Agents.Extensions.Teams.Compat;` (TeamsActivityHandler namespace)
- Keep all other `using Microsoft.Agents.*` statements — they are already correct

```csharp
// Before: ActivityHandler compat layer
using Microsoft.Agents.Builder.Compat;         // REMOVE
using Microsoft.Agents.Extensions.Teams.Compat; // REMOVE if Teams
using Microsoft.Agents.Builder;
using Microsoft.Agents.Core.Models;

public class EchoBot : ActivityHandler  // or TeamsActivityHandler
{
    protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
    { /* business logic */
    }
}

// After: AgentApplication
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Core.Models;

public class EchoBot : AgentApplication
{
    public EchoBot(AgentApplicationOptions options) : base(options)
    {
        OnConversationUpdate(ConversationUpdateEvents.MembersAdded, OnMembersAddedAsync);
        OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);
    }

    // Signature: (ITurnContext, ITurnState, CancellationToken)
    private async Task OnMessageAsync(ITurnContext ctx, ITurnState state, CancellationToken ct)
    { /* same business logic */ }
}
```

For Teams bots (`TeamsActivityHandler`) with Teams-specific handlers (messaging extensions, task modules, etc.), see **Step 1-d** below.

## Step 1-b: Convert Bot Class to AgentApplication (using Dialogs)

- Remove `using Microsoft.Agents.Builder.Compat;` (ActivityHandler namespace)
- Remove `using Microsoft.Agents.Extensions.Teams.Compat;` (TeamsActivityHandler namespace)
- Keep all other `using Microsoft.Agents.*` statements — they are already correct

```csharp
// Before: ActivityHandler compat layer
using Microsoft.Agents.Builder.Compat;         // REMOVE
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core.Models;

public class EchoBot : ActivityHandler
{
    protected override Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
        => dialog.RunAsync(turnContext, conversationState, ct);
}

// After: AgentApplication
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Core.Models;

public class EchoBot : AgentApplication
{
    public EchoBot(AgentApplicationOptions options, MainDialog dialog) : base(options)
    {
        _dialog = dialog;
        OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);
        // signin/verifyState and signin/tokenExchange are both matched by "signin/.*"
        OnActivity(
            (ctx, ct) => Task.FromResult(ctx.Activity.IsType(ActivityTypes.Invoke) && ctx.Activity.Name != null && Regex.IsMatch(ctx.Activity.Name, "signin/.*")),
            OnSigninInvokeStateAsync);
    }

    // Signature: (ITurnContext, ITurnState, CancellationToken) — NOT the ActivityHandler signature
    private Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
        => _dialog.RunAsync(turnContext, turnState.Conversation, ct);

    private Task OnSigninInvokeStateAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
        => _dialog.RunAsync(turnContext, turnState.Conversation, ct);
}
```

**Key differences from non-dialog migration:**
- Do **not** inject `ConversationState` into the bot — pass `turnState.Conversation` to `Dialog.RunAsync` instead
- Use `InvokeRouteBuilder` (not `OnActivity(ActivityTypes.Invoke, ...)`) to route signin invokes — the name pattern matches both `signin/verifyState` and `signin/tokenExchange`
- Remove `ConversationState` and `UserState` from DI entirely — see Step 2

> ⚠️ **Generic `DialogBot<T>` base class pattern:** Some projects use a two-level class hierarchy where a generic base class (e.g. `DialogBot<T> : ActivityHandler`) holds the dialog field, state injection, and `OnTurnAsync` state-saving override, and the concrete bot class (e.g. `MyBot : DialogBot<MainDialog>`) only adds routing. Collapse both classes into a single non-generic `AgentApplication` subclass — inject the concrete dialog type directly in the constructor. The generic type parameter is not needed. Remove `OnTurnAsync` entirely (AgentApplication manages state automatically).

---

## Step 1-c: Remove State Management Boilerplate

### Remove OnTurnBeginAsync / OnTurnEndAsync Overrides

`ActivityHandler` and `TeamsActivityHandler` bots sometimes manually load and save state in turn lifecycle overrides. Remove these entirely — `AgentApplication` handles state loading and saving automatically:

```csharp
// REMOVE these overrides completely — AgentApplication does this automatically
protected override async Task OnTurnBeginAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
{
    await ConversationState.LoadAsync(turnContext, false, cancellationToken);
    await UserState.LoadAsync(turnContext, false, cancellationToken);
}

protected override async Task OnTurnEndAsync(ITurnContext turnContext, CancellationToken cancellationToken = default)
{
    await ConversationState.SaveChangesAsync(turnContext, false, cancellationToken);
    await UserState.SaveChangesAsync(turnContext, false, cancellationToken);
}
```

Also remove any `ConversationState` or `UserState` properties/fields that were injected for this purpose.

### Migrate CloudAdapter OnTurnError to AgentApplication

If a custom `CloudAdapter` subclass injects `ConversationState` only to call `DeleteStateAsync` on error, move that logic to an `AgentApplication.OnTurnError` handler instead.

**Before — in the custom adapter:**
```csharp
public MyAdapter(
    IChannelServiceClientFactory channelServiceClientFactory,
    IActivityTaskQueue activityTaskQueue,
    ILogger<CloudAdapter> logger,
    ConversationState conversationState)   // REMOVE this parameter
    : base(channelServiceClientFactory, activityTaskQueue, logger: logger)
{
    OnTurnError = async (turnContext, exception) =>
    {
        if (conversationState != null)
        {
            await conversationState.DeleteStateAsync(turnContext);  // REMOVE
        }
    };
}
```

**After — remove ConversationState from the adapter:**
```csharp
public MyAdapter(
    IChannelServiceClientFactory channelServiceClientFactory,
    IActivityTaskQueue activityTaskQueue,
    ILogger<CloudAdapter> logger)
    : base(channelServiceClientFactory, activityTaskQueue, logger: logger)
{
    // OnTurnError can remain for logging; state deletion moves to AgentApplication
}
```

**Add to the AgentApplication bot constructor:**
```csharp
public MyBot(AgentApplicationOptions options, ...) : base(options)
{
    OnTurnError(OnAgentTurnErrorAsync);
    // ... other route registrations
}

private async Task OnAgentTurnErrorAsync(ITurnContext turnContext, ITurnState turnState, Exception exception, CancellationToken cancellationToken)
{
    try
    {
        await turnState.Conversation.DeleteStateAsync(turnContext, cancellationToken);
    }
    catch(Exception ex)
    {
        Logger.LogError(ex, "Exception deleting conversation state after a turn error.");
    }
}
```

Customer-written classes that accept `ConversationState` or `UserState` as constructor parameters can remain unchanged for now. However, they will ultimately need to source their state from `ITurnState.Conversation` or `ITurnState.User` — flag this to the customer as a follow-up item.

### `application/search` invokes → `AdaptiveCards.OnSearch`

If the bot overrides `OnInvokeActivityAsync` and handles `activity.Name == "application/search"` (adaptive card typeahead/dynamic search), replace it with per-dataset `AdaptiveCards.OnSearch` registrations. The dataset name comes from `choices.data.dataset` in the card JSON. The handler returns `IList<AdaptiveCardsSearchResult>` — the framework automatically wraps results in the `application/vnd.microsoft.search.searchResponse` format and sends the invoke response.

```csharp
// Before: one OnInvokeActivityAsync checking activity.Name
protected override async Task<InvokeResponse> OnInvokeActivityAsync(ITurnContext<IInvokeActivity> turnContext, CancellationToken ct)
{
    if (turnContext.Activity.Name == "application/search")
    {
        var searchData = JsonSerializer.Deserialize<DynamicSearchCard>(turnContext.Activity.Value.ToString());
        // ... build results ...
        return new InvokeResponse { Status = 200, Body = searchResponseData };
    }
    return null;
}

// After: per-dataset OnSearch registrations
public ActivityBot(AgentApplicationOptions options) : base(options)
{
    AdaptiveCards.OnSearch("npmpackages", OnNpmSearchAsync);
    AdaptiveCards.OnSearch("cities", OnCitiesSearchAsync);
}

// Handler signature: (ITurnContext, ITurnState, Query<AdaptiveCardsSearchParams>, CT) → Task<IList<AdaptiveCardsSearchResult>>
private async Task<IList<AdaptiveCardsSearchResult>> OnNpmSearchAsync(
    ITurnContext turnContext, ITurnState turnState, Query<AdaptiveCardsSearchParams> query, CancellationToken ct)
{
    // query.Parameters.QueryText = search text typed by user
    // query.Parameters.Dataset  = "npmpackages"
    // ... fetch results ...
    return results.Select(r => new AdaptiveCardsSearchResult(r.Title, r.Value)).ToList();
}
```

**Key differences:**
- Return `IList<AdaptiveCardsSearchResult>` directly — no `InvokeResponse` wrapper
- `AdaptiveCardsSearchResult(string title, string value)` — constructor takes title and value
- `query.Parameters.QueryText` replaces `searchData.queryText`
- `query.Parameters.Dataset` contains the dataset name (same as the registration key)

> ⚠️ **Dependent dropdown — associated inputs not in `query`:** When a card uses `"associatedInputs": "auto"`, the selected values of other inputs are sent in the `data` field of the raw invoke payload — NOT in `query.Parameters`. Access them directly: `ProtocolJsonSerializer.ToObject<MyModel>(turnContext.Activity.Value)` where `MyModel` maps the top-level `data` field.
>
> ```csharp
> private Task<IList<AdaptiveCardsSearchResult>> OnCitiesSearchAsync(
>     ITurnContext turnContext, ITurnState turnState, Query<AdaptiveCardsSearchParams> query, CancellationToken ct)
> {
>     // Associated input (country selector) arrives in activity.Value's "data" field
>     var card = ProtocolJsonSerializer.ToObject<DependantDropdownCard>(turnContext.Activity.Value);
>     string country = card?.data?.choiceSelect?.ToLower() ?? "";
>     // ... return cities for country ...
> }
> ```

---

## Step 1-d: Convert TeamsActivityHandler with Teams-Specific Handlers

For bots that override Teams-specific methods (messaging extensions, task modules, etc.), use `RegisterExtension(new TeamsAgentExtension(this), ...)` inside the constructor.

### TeamsActivityHandler → TeamsAgentExtension handler mapping

**Message Extensions** — handlers return `Task<MessagingExtensionResult>` (not `MessagingExtensionResponse`):

| TeamsActivityHandler Override | TeamsAgentExtension Registration | Handler Delegate |
|-------------------------------|----------------------------------|-----------------|
| `OnTeamsMessagingExtensionQueryAsync(ctx, MessagingExtensionQuery, CT)` | `teams.MessageExtensions.OnQuery(commandId, handler)` | `(ITurnContext, ITurnState, Query<IDictionary<string,object>>, CT) → Task<MessagingExtensionResult>` |
| `OnTeamsMessagingExtensionSelectItemAsync(ctx, JsonElement, CT)` | `teams.MessageExtensions.OnSelectItem(handler)` | `(ITurnContext, ITurnState, object item, CT) → Task<MessagingExtensionResult>` |
| `OnTeamsAppBasedLinkQueryAsync(ctx, AppBasedLinkQuery, CT)` | `teams.MessageExtensions.OnQueryLink(handler)` | `(ITurnContext, ITurnState, string url, CT) → Task<MessagingExtensionResult>` |
| `OnTeamsAnonymousAppBasedLinkQueryAsync(ctx, AppBasedLinkQuery, CT)` | `teams.MessageExtensions.OnAnonymousQueryLink(handler)` | `(ITurnContext, ITurnState, string url, CT) → Task<MessagingExtensionResult>` |
| `OnTeamsMessagingExtensionFetchTaskAsync(ctx, MessagingExtensionAction, CT)` | `teams.MessageExtensions.OnFetchTask(commandId, handler)` | `(ITurnContext, ITurnState, CT) → Task<TaskModuleResponse>` |
| `OnTeamsMessagingExtensionSubmitActionAsync(ctx, MessagingExtensionAction, CT)` | `teams.MessageExtensions.OnSubmitAction(commandId, handler)` | `(ITurnContext, ITurnState, object data, CT) → Task<MessagingExtensionActionResponse>` |
| `OnTeamsMessagingExtensionAgentMessagePreviewEditAsync(ctx, MessagingExtensionAction, CT)` | `teams.MessageExtensions.OnAgentMessagePreviewEdit(commandId, handler)` | `(ITurnContext, ITurnState, IActivity activityPreview, CT) → Task<MessagingExtensionActionResponse>` |
| `OnTeamsMessagingExtensionAgentMessagePreviewSendAsync(ctx, MessagingExtensionAction, CT)` | `teams.MessageExtensions.OnAgentMessagePreviewSend(commandId, handler)` | `(ITurnContext, ITurnState, IActivity activityPreview, CT) → Task` |
| `OnTeamsMessagingExtensionConfigurationQuerySettingUrlAsync(ctx, MessagingExtensionQuery, CT)` | `teams.MessageExtensions.OnQueryUrlSetting(handler)` | `(ITurnContext, ITurnState, CT) → Task<MessagingExtensionResult>` |
| `OnTeamsMessagingExtensionConfigurationSettingAsync(ctx, JsonElement, CT)` | `teams.MessageExtensions.OnConfigureSettings(handler)` | `(ITurnContext, ITurnState, object settings, CT) → Task` |
| `OnTeamsMessagingExtensionCardButtonClickedAsync(ctx, JsonElement, CT)` | `teams.MessageExtensions.OnCardButtonClicked(handler)` | `(ITurnContext, ITurnState, object cardData, CT) → Task` |

**Task Modules:**

| TeamsActivityHandler Override | TeamsAgentExtension Registration | Handler Delegate |
|-------------------------------|----------------------------------|-----------------|
| `OnTeamsTaskModuleFetchAsync(ctx, TaskModuleRequest, CT)` | `teams.TaskModules.OnFetch(verb, handler)` | `(ITurnContext, ITurnState, object data, CT) → Task<TaskModuleResponse>` |
| `OnTeamsTaskModuleSubmitAsync(ctx, TaskModuleRequest, CT)` | `teams.TaskModules.OnSubmit(verb, handler)` | `(ITurnContext, ITurnState, object data, CT) → Task<TaskModuleResponse>` |

**⚠️ Task Module verb matching:** `OnFetch` and `OnSubmit` match by looking for a `verb` field inside `activity.Value.data`. If the submitted data has **no `verb` field**, use the `RouteSelector` overload instead:

```csharp
teams.TaskModules.OnSubmit(
    (ctx, ct) => Task.FromResult(
        string.Equals(ctx.Activity.Type, ActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase) &&
        string.Equals(ctx.Activity.Name, "task/submit", StringComparison.OrdinalIgnoreCase)),
    OnTaskModuleSubmitAsync);
```

**⚠️ `data` parameter in submit handler:** The `data` parameter passed to `SubmitHandlerAsync` is `taskModuleAction.Value` from a `CardAction` deserialization of the payload. For `task/submit` payloads where the top-level JSON has no `"value"` key (only `"data"` and `"context"`), this will be `null`. Access the submitted data via `turnContext.Activity.Value` directly:

```csharp
private async Task<TaskModuleResponse> OnTaskModuleSubmitAsync(
    ITurnContext turnContext, ITurnState turnState, object data, CancellationToken cancellationToken)
{
    // data parameter is null when payload has no top-level "value" key — use Activity.Value instead
    var request = ProtocolJsonSerializer.ToObject<TaskModuleRequest>(turnContext.Activity.Value);
    var feedback = ProtocolJsonSerializer.ToObject<MySubmitModel>(request.Data);
    // ...
    return null; // null dismisses the task module
}
```

**Meetings:**

| TeamsActivityHandler Override | TeamsAgentExtension Registration | Handler Delegate |
|-------------------------------|----------------------------------|-----------------|
| `OnTeamsMeetingStartAsync(MeetingStartEventDetails, ctx, CT)` | `teams.Meetings.OnStart(handler)` | `(ITurnContext, ITurnState, MeetingStartEventDetails, CT) → Task` |
| `OnTeamsMeetingEndAsync(MeetingEndEventDetails, ctx, CT)` | `teams.Meetings.OnEnd(handler)` | `(ITurnContext, ITurnState, MeetingEndEventDetails, CT) → Task` |
| `OnTeamsMeetingParticipantsJoinAsync(MeetingParticipantsEventDetails, ctx, CT)` | `teams.Meetings.OnParticipantsJoin(handler)` | `(ITurnContext, ITurnState, MeetingParticipantsEventDetails, CT) → Task` |
| `OnTeamsMeetingParticipantsLeaveAsync(MeetingParticipantsEventDetails, ctx, CT)` | `teams.Meetings.OnParticipantsLeave(handler)` | `(ITurnContext, ITurnState, MeetingParticipantsEventDetails, CT) → Task` |

**Messages (edit/delete):**

| TeamsActivityHandler Override | TeamsAgentExtension Registration |
|-------------------------------|----------------------------------|
| `OnTeamsMessageEditAsync(ctx, CT)` | `teams.OnMessageEdit(handler)` |
| `OnTeamsMessageUndeleteAsync(ctx, CT)` | `teams.OnMessageUndelete(handler)` |
| `OnTeamsMessageSoftDeleteAsync(ctx, CT)` | `teams.OnMessageDelete(handler)` |
| `OnTeamsReadReceiptAsync(ReadReceiptInfo, ctx, CT)` | `teams.OnTeamsReadReceipt(handler)` |

**File Consent:**

| TeamsActivityHandler Override | TeamsAgentExtension Registration |
|-------------------------------|----------------------------------|
| `OnTeamsFileConsentAcceptAsync(ctx, FileConsentCardResponse, CT)` | `teams.OnFileConsentAccept(handler)` |
| `OnTeamsFileConsentDeclineAsync(ctx, FileConsentCardResponse, CT)` | `teams.OnFileConsentDecline(handler)` |

**Bot Config:**

| TeamsActivityHandler Override | TeamsAgentExtension Registration |
|-------------------------------|----------------------------------|
| `OnTeamsConfigFetchAsync(ctx, JsonElement, CT)` | `teams.OnConfigFetch(handler)` |
| `OnTeamsConfigSubmitAsync(ctx, JsonElement, CT)` | `teams.OnConfigSubmit(handler)` |

**Channels, Teams, Members** — all route through `teams.OnConversationUpdate(eventName, handler)` with constants from `TeamsConversationUpdateEvents`:

| TeamsActivityHandler Override | Event Constant |
|-------------------------------|----------------|
| `OnTeamsMembersAddedAsync` | `ConversationUpdateEvents.MembersAdded` |
| `OnTeamsMembersRemovedAsync` | `ConversationUpdateEvents.MembersRemoved` |
| `OnTeamsChannelCreatedAsync` | `TeamsConversationUpdateEvents.ChannelCreated` |
| `OnTeamsChannelDeletedAsync` | `TeamsConversationUpdateEvents.ChannelDeleted` |
| `OnTeamsChannelRenamedAsync` | `TeamsConversationUpdateEvents.ChannelRenamed` |
| `OnTeamsChannelRestoredAsync` | `TeamsConversationUpdateEvents.ChannelRestored` |
| `OnTeamsTeamArchivedAsync` | `TeamsConversationUpdateEvents.TeamArchived` |
| `OnTeamsTeamUnarchivedAsync` | `TeamsConversationUpdateEvents.TeamUnarchived` |
| `OnTeamsTeamDeletedAsync` | `TeamsConversationUpdateEvents.TeamDeleted` |
| `OnTeamsTeamHardDeletedAsync` | `TeamsConversationUpdateEvents.TeamHardDeleted` |
| `OnTeamsTeamRenamedAsync` | `TeamsConversationUpdateEvents.TeamRenamed` |
| `OnTeamsTeamRestoredAsync` | `TeamsConversationUpdateEvents.TeamRestored` |

**Other:**

| TeamsActivityHandler Override | TeamsAgentExtension Registration |
|-------------------------------|----------------------------------|
| `OnTeamsO365ConnectorCardActionAsync(ctx, O365ConnectorCardActionQuery, CT)` | `teams.OnO365ConnectorCardAction(handler)` |
| `OnTeamsSigninVerifyStateAsync(ctx, CT)` | On `AgentApplication` directly: `OnActivity((ctx, ct) => Task.FromResult(ctx.Activity.IsType(ActivityTypes.Invoke) && Regex.IsMatch(ctx.Activity.Name, "signin/.*")), handler)` |
| `OnTeamsTabFetchAsync(ctx, TabRequest, CT)` | No direct equivalent — use `OnActivity(ActivityTypes.Invoke, handler)` with a name filter |
| `OnTeamsTabSubmitAsync(ctx, TabSubmit, CT)` | No direct equivalent — use `OnActivity(ActivityTypes.Invoke, handler)` with a name filter |

### Handler signature changes

**Query handler** — return type changes from `Task<MessagingExtensionResponse>` to `Task<MessagingExtensionResult>`:
```csharp
// Before
protected override async Task<MessagingExtensionResponse> OnTeamsMessagingExtensionQueryAsync(
    ITurnContext<IInvokeActivity> turnContext, MessagingExtensionQuery query, CancellationToken ct)
{
    var text = query?.Parameters?[0]?.Value?.ToString();  // index-based parameter access
    // ...
    return new MessagingExtensionResponse { ComposeExtension = new MessagingExtensionResult { ... } };
}

// After — registered as: teams.MessageExtensions.OnQuery("commandId", OnQueryAsync)
private async Task<MessagingExtensionResult> OnQueryAsync(
    ITurnContext turnContext, ITurnState turnState,
    Query<IDictionary<string, object>> query, CancellationToken ct)
{
    // Named parameter access — value is a JsonElement
    string text = string.Empty;
    if (query.Parameters.TryGetValue("paramName", out var val) && val is JsonElement el)
        text = el.GetString() ?? string.Empty;
    // ...
    return new MessagingExtensionResult { Type = "result", AttachmentLayout = "list", Attachments = attachments };
    // NOTE: return MessagingExtensionResult directly — no MessagingExtensionResponse wrapper
}
```

**SelectItem handler** — `item` is now `object` (cast to `JsonElement`):
```csharp
// Before
protected override Task<MessagingExtensionResponse> OnTeamsMessagingExtensionSelectItemAsync(
    ITurnContext<IInvokeActivity> turnContext, JsonElement query, CancellationToken ct)
{
    string id = query.GetProperty("packageId").GetString();
    return Task.FromResult(new MessagingExtensionResponse { ComposeExtension = new MessagingExtensionResult { ... } });
}

// After — registered as: teams.MessageExtensions.OnSelectItem(OnSelectItemAsync)
private Task<MessagingExtensionResult> OnSelectItemAsync(
    ITurnContext turnContext, ITurnState turnState, object item, CancellationToken ct)
{
    JsonElement query = (JsonElement)item;
    string id = query.GetProperty("packageId").GetString();
    return Task.FromResult(new MessagingExtensionResult { Type = "result", ... });
    // NOTE: return MessagingExtensionResult directly — no MessagingExtensionResponse wrapper
}
```

### Full registration pattern

```csharp
using Microsoft.Agents.Builder.App;             // AgentApplication
using Microsoft.Agents.Builder.App.AdaptiveCards; // Query<T>
using Microsoft.Agents.Builder.State;            // ITurnState
using Microsoft.Agents.Extensions.Teams.App;    // TeamsAgentExtension
using Microsoft.Agents.Extensions.Teams.Models; // MessagingExtensionResult, etc.
// REMOVE: using Microsoft.Agents.Extensions.Teams.Compat;

public class MyTeamsBot : AgentApplication
{
    public MyTeamsBot(AgentApplicationOptions options) : base(options)
    {
        RegisterExtension(new TeamsAgentExtension(this), teams =>
        {
            teams.MessageExtensions.OnQuery("searchCommandId", OnQueryAsync);
            teams.MessageExtensions.OnSelectItem(OnSelectItemAsync);
        });
    }
}
```

**Key differences:**
- Helper methods that returned `MessagingExtensionResponse` must be changed to return `MessagingExtensionResult` — drop the outer `new MessagingExtensionResponse { ComposeExtension = ... }` wrapper
- `MessagingExtensionQuery.Parameters[index].Value` → `Query<IDictionary<string,object>>.Parameters.TryGetValue("name", out var val)` where `val` is a `JsonElement`
- `commandId` in `OnQuery(commandId, ...)` must match the command `id` in the Teams app manifest

---

## Step 2: Update Program.cs

### Add `AddAgentApplicationOptions`

Add this call **before** `builder.AddAgent<T>()` — it is required for `AgentApplication` and was absent in ActivityHandler-based bots:

```csharp
builder.AddAgentApplicationOptions();  // ADD THIS — required for AgentApplication
builder.AddAgent<EchoBot>();           // existing line — keep
```

### Ensure IStorage Is Registered

`AgentApplication` always requires an `IStorage` registration — even if the bot has no dialogs or explicit state usage. If it is not already present, add it:

```csharp
builder.Services.AddSingleton<IStorage, MemoryStorage>(); // always required by AgentApplication
```

### Remove ConversationState and UserState from DI

`AgentApplication` manages state internally via `ITurnState`. Remove these registrations entirely:

```csharp
// REMOVE both of these — AgentApplication does not use them
// builder.Services.AddSingleton<ConversationState>();
// builder.Services.AddSingleton<UserState>();

builder.Services.AddSingleton<IStorage, MemoryStorage>(); // keep — required by AgentApplication
builder.Services.AddSingleton<MainDialog>();               // keep — dialog class itself is still needed
builder.Services.AddSingleton<IMyService, MyService>();    // keep all custom services
```

### Preserve All Other Custom DI

Keep dialogs, storage, adapters, and custom services. Only `ConversationState` and `UserState` are removed.

### TeamsSSOTokenExchangeMiddleware

If the ActivityHandler bot registered `TeamsSSOTokenExchangeMiddleware`, **remove it** — it is not required and should not be used with `AgentApplication`. Remove it from DI and from any `IMiddleware[]` array registration.

---

## Step 3: Update Endpoint Mapping

Identify which endpoint pattern the existing bot uses, then apply the corresponding change.

### Pattern A: `app.MapControllers()` + `BotController.cs` (most common in Agents SDK compat samples)

```csharp
// Before Program.cs
builder.Services.AddControllers();           // needed for MapControllers
// ...
app.MapControllers().AllowAnonymous();       // dev
app.MapControllers();                        // prod

// After Program.cs
// Remove AddControllers() ONLY IF no other MVC controllers exist in the project
// Add authentication middleware if not already present:
app.UseAuthentication();
app.UseAuthorization();
app.MapAgentRootEndpoint();                                              // optional GET "/"
app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment());
```

Delete `BotController.cs`. Check first that it contains no custom logic beyond calling `adapter.ProcessAsync` — if it does, preserve that logic.

**If the project uses Razor Pages** (`AddRazorPages()` / `MapRazorPages()`): keep both — they serve web UI pages unrelated to the bot endpoint. Remove `AddControllers()`, `AddMvc()`, and any `MapControllerRoute()` / `MapControllers()` calls, but leave the Razor Pages registrations in place:

```csharp
// Keep — serves Razor Pages for web UI
builder.Services.AddRazorPages();
// ...
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();                   // keep
app.MapAgentRootEndpoint();
app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment());
```

Also remove the dev branch `app.MapControllers().AllowAnonymous()` pattern — auth is now handled by `requireAuth` on `MapAgentApplicationEndpoints`.

### Pattern B: `app.MapAgentEndpoints()`

```csharp
// Before
app.MapAgentEndpoints(requireAuth: !app.Environment.IsDevelopment());

// After
app.MapAgentRootEndpoint();
app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment());
```

### Pattern C: `app.MapPost(...)` (older minimal API)

Offer to switch. If accepted:

```csharp
// Remove:
app.MapPost("/api/messages", async (HttpRequest req, HttpResponse res, IAgentHttpAdapter adapter, IAgent bot, CancellationToken ct)
    => await adapter.ProcessAsync(req, res, bot, ct)).AllowAnonymous();

// Add:
app.UseAuthentication();
app.UseAuthorization();
app.MapAgentRootEndpoint();
app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment());
```

### Step 4 or at the very end of the migration:  Ask user if they would like to write the learnings from this migration to a markdown file they could submit to use to help us improve the skill.

---

## Development URL

If the existing bot had `app.Urls.Add("http://localhost:3978")` inside an `IsDevelopment()` check, keep it. If it was missing, add it:

```csharp
if (app.Environment.IsDevelopment())
{
    app.Urls.Add("http://localhost:3978");
}
```

---

## Middleware Replacements

Some Bot Framework middleware has a direct equivalent in AgentApplication configuration.

### ShowTypingMiddleware → StartTypingTimer

Drop the `ShowTypingMiddleware` registration and enable the built-in typing timer instead:

```csharp
// Remove from adapter setup:
// adapter.Use(new ShowTypingMiddleware());
```

```json
// appsettings.json — add to AgentApplication section:
"AgentApplication": {
  "StartTypingTimer": true
}
```

### AutoSaveStateMiddleware

`AgentApplication` loads and saves state automatically on every turn. **Always remove `AutoSaveStateMiddleware`** — it is no longer needed and must not be used with `AgentApplication`.

```csharp
// REMOVE — always:
// new AutoSaveStateMiddleware(...)
// adapter.Use(new AutoSaveStateMiddleware(...));
```

If removing it empties the `IMiddleware[]` DI registration, remove that registration entirely as well.

### NormalizeMentionsMiddleware → NormalizeMentions / RemoveRecipientMention

Drop the `NormalizeMentionsMiddleware` registration and enable the equivalent settings instead:

```csharp
// Remove from adapter setup:
// adapter.Use(new NormalizeMentionsMiddleware());
```

```json
// appsettings.json — add to AgentApplication section:
"AgentApplication": {
  "NormalizeMentions": true,
  "RemoveRecipientMention": true
}
```

---

## Files to Delete

| File | When to delete |
|------|----------------|
| `BotController.cs` | Always when switching to `MapAgentApplicationEndpoints` — unless it has custom logic beyond `ProcessAsync` |
| `Controllers/` folder | After deleting `BotController.cs`, if no other controllers remain |

Do **not** delete `AspNetExtensions.cs` — if present it is providing the `AddAgentAspNetAuthentication` extension and must be kept.

---

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Forgot `builder.AddAgentApplicationOptions()` | Add before `builder.AddAgent<T>()` |
| Removed custom DI (dialogs, state, services) | Restore — never remove developer-added registrations |
| Added `[Agent]` attribute to AgentApplication class | Not required — `MapAgentApplicationEndpoints` discovers `AgentApplication` subclasses without it |
| Left `using Microsoft.Agents.Builder.Compat` | Remove — ActivityHandler is gone |
| Left `using Microsoft.Agents.Extensions.Teams.Compat` on main class | Remove — TeamsActivityHandler is gone; use `RegisterExtension(new TeamsAgentExtension(this), ...)` instead |
| Teams MessageExtension handler returns `MessagingExtensionResponse` | Change return type to `MessagingExtensionResult` — the framework no longer uses the `ComposeExtension` wrapper; return the inner result directly |
| Teams query parameter accessed by index (`query.Parameters[0].Value`) | Use named access: `query.Parameters.TryGetValue("paramName", out var val)` where `val` is a `JsonElement`; parameter name comes from the Teams app manifest command definition |
| Teams SelectItem handler uses `JsonElement` parameter | In `OnSelectItem`, the parameter type is `object`; cast it: `JsonElement query = (JsonElement)item` |
| Missing `Microsoft.Agents.Extensions.Teams.App` namespace for TeamsAgentExtension | Add `using Microsoft.Agents.Extensions.Teams.App;` — `TeamsAgentExtension` is not in the Compat namespace |
| Missing `Microsoft.Agents.Builder.App.AdaptiveCards` namespace for `Query<T>` | Add `using Microsoft.Agents.Builder.App.AdaptiveCards;` — required for `Query<IDictionary<string, object>>` handler parameter |
| `AdaptiveCard` is ambiguous after adding `using Microsoft.Agents.Builder.App.AdaptiveCards` | Conflict with `AdaptiveCards.AdaptiveCard` from the AdaptiveCards NuGet package. Use an alias instead: `using AgentAdaptiveCards = Microsoft.Agents.Builder.App.AdaptiveCards;` then reference `AgentAdaptiveCards.Query<IDictionary<string, object>>` in the handler signature |
| Kept `builder.Services.AddControllers()` but deleted all controllers | Remove — no longer needed without MVC controllers |
| Missing `app.UseAuthentication()` / `app.UseAuthorization()` after switching from MapControllers | Add them before `MapAgentApplicationEndpoints` |
| Injected `ConversationState` into bot constructor for dialogs | Remove from bot — use `turnState.Conversation` in `Dialog.RunAsync` calls instead |
| Left `ConversationState` registered in DI | Remove — AgentApplication manages state internally via `ITurnState` |
| Left `UserState` registered in DI | Remove — AgentApplication does not use it |
| Left `AutoSaveStateMiddleware` registered | Remove entirely — AgentApplication saves state automatically; never keep it |
| Left `ConversationState` injected in custom adapter `OnTurnError` for `DeleteStateAsync` | Remove the parameter; move `DeleteStateAsync` into an `AgentApplication.OnTurnError` handler using `turnState.Conversation.DeleteStateAsync` |
| Left `OnTurnBeginAsync`/`OnTurnEndAsync` overrides that load/save state | Remove entirely — AgentApplication handles this automatically |
| Kept generic `DialogBot<T>` base class as a separate file | Collapse both classes into a single non-generic `AgentApplication` subclass; inject the concrete dialog type directly; delete the generic base class file |
| Left `TeamsSSOTokenExchangeMiddleware` in DI or `IMiddleware[]` | Remove — not required and should not be used with AgentApplication |
| Manually calling `SaveChangesAsync` in AgentApplication handlers | Remove — state is saved automatically |
| Left `ShowTypingMiddleware` registration | Drop it; set `"AgentApplication": { "StartTypingTimer": true }` in appsettings.json |
| Left `NormalizeMentionsMiddleware` registration | Drop it; set `NormalizeMentions` and/or `RemoveRecipientMention` to `true` in `AgentApplication` appsettings |
| Handler accesses `membersAdded` as a parameter | Use `turnContext.Activity.MembersAdded` instead — it is no longer passed as a parameter |
| Used `ProjectReference` to local SDK source instead of NuGet packages | Always use NuGet packages — customers don't have access to the SDK source repo |
| Custom ada

…(truncated)
