mcpbeat Sign in

Agents SDK Dotnet Activityhandler Migration Agent Skill

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.

11k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
1022
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/microsoft/Agents --skill agents-sdk-dotnet-activityhandler-migration

The instruction itself

33 sections, as written by the author

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):

// 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
// 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
// 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:

// 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:

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:

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:

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.

// 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:

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:

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>:

// 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 handleritem is now object (cast to JsonElement):

// 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

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].ValueQuery<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:

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:

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:

// 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)

// 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:

// 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()

// 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:

// 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:

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:

// Remove from adapter setup:
// adapter.Use(new ShowTypingMiddleware());
// 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.

// 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:

// Remove from adapter setup:
// adapter.Use(new NormalizeMentionsMiddleware());
// 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 adapter constructor uses ILogger<IAgentHttpAdapter> | Change to ILogger<CloudAdapter>CloudAdapter base constructor requires the concrete type, not the interface |

| Changed business logic during migration | Do not — only change structure/registration/routing |

| Missing IStorage registration | Add builder.Services.AddSingleton<IStorage, MemoryStorage>(); — always required by AgentApplication, not just for dialog bots |

| Used OnActivity(ActivityTypes.Invoke, ...) for adaptiveCard/action verbs | Use AdaptiveCards.OnActionExecute(verb, handler) instead — it's built into AgentApplication, matches by verb, and handles the invoke response automatically |

| Called CreateInvokeResponse(...) in an OnActionExecute handler | CreateInvokeResponse is a compat-layer helper — it doesn't exist on AgentApplication. Return AdaptiveCardInvokeResponse directly; the framework wraps it |

| Deserialized data as AdaptiveCardInvokeValue in OnActionExecute handler | The data parameter is already invokeValue.Action.Data — deserialize directly as your data model, not as the full invoke value wrapper |

| teams.TaskModules.OnSubmit(verb, ...) never triggers | Default filter matches activity.Value.data.verb — if the submitted data has no verb field, use the RouteSelector overload with a custom selector that checks ctx.Activity.Name == "task/submit" |

| data parameter is null in SubmitHandlerAsync | The data parameter is taskModuleAction.Value (CardAction), which is null when the payload has no top-level "value" key. Parse the submitted data directly: ProtocolJsonSerializer.ToObject<TaskModuleRequest>(turnContext.Activity.Value).Data |

| Removed AddRazorPages() / MapRazorPages() when deleting BotController | Keep them — they serve web UI pages unrelated to the bot; only remove AddControllers(), AddMvc(), and MapControllers()/MapControllerRoute() |

| Used OnActivity(ActivityTypes.Invoke, ...) for application/search | Use AdaptiveCards.OnSearch(dataset, handler) instead — routes by choices.data.dataset value, handles invoke response automatically, handler returns IList<AdaptiveCardsSearchResult> |

| OnSearch handler returns InvokeResponse | Return IList<AdaptiveCardsSearchResult> — the framework wraps results in application/vnd.microsoft.search.searchResponse and sends the invoke response |

| Looked for associated input values (dependent dropdown) in query.Parameters | Associated inputs from "associatedInputs": "auto" arrive in the data field of the raw invoke payload, not in query.Parameters. Read them via ProtocolJsonSerializer.ToObject<MyModel>(turnContext.Activity.Value) |

Other skills for the same job

different authors, same section of the catalogue
MCP Builder
by anthropics
vendor ×13

Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).

30k tokens scripts
Changelog Generator
by frostant
×9

Automatically creates user-facing changelogs from git commits by analyzing commit history, categorizing changes, and transforming technical commits into clear, customer-friendly release notes. Turns hours of manual changelog writing into minutes of automated generation.

774 tokens
Finishing A Development Branch
by ZhanlinCui
×7

Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup

1k tokens
MCP Builder
by JayZeeDesign
×7

Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).

37k tokens scripts
Vercel React Native Skills
by vercel-labs
vendor ×6

React Native and Expo best practices for building performant mobile apps. Use when building React Native components, optimizing list performance, implementing animations, or working with native modules. Triggers on tasks involving React Native, Expo, mobile performance, or native platform APIs.

39k tokens
Vercel React Best Practices
by ratacat
×5

React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.

34k tokens
Next Best Practices
by vercel-labs
vendor ×4

Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling

20k tokens
Using Git Worktrees
by ZhanlinCui
×4

Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification

1k tokens

How to use it

Copy the folder

Take microsoft/agents-sdk-dotnet-activityhandler-migration from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.