| name | agents-sdk-dotnet-activityhandler-migration |
| description | 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. |
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(...) | |
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):
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;
}
public MyBot(AgentApplicationOptions options) : base(options)
{
AdaptiveCards.OnActionExecute("approve", OnApproveAsync);
AdaptiveCards.OnActionExecute("reject", OnRejectedAsync);
}
private Task<AdaptiveCardInvokeResponse> OnApproveAsync(
ITurnContext turnContext, ITurnState turnState, object data, CancellationToken ct)
{
var actionData = ProtocolJsonSerializer.ToObject<MyDataModel>(data);
return Task.FromResult(new AdaptiveCardInvokeResponse
{
StatusCode = ,
Type = ,
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
using Microsoft.Agents.Builder.Compat;
using Microsoft.Agents.Extensions.Teams.Compat;
using Microsoft.Agents.Builder;
using Microsoft.Agents.Core.Models;
public class EchoBot : ActivityHandler
{
protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
{
}
}
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);
}
private async Task OnMessageAsync(ITurnContext ctx, ITurnState state, CancellationToken ct)
{ }
}
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
using Microsoft.Agents.Builder.Compat;
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);
}
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);
OnActivity(
(ctx, ct) => Task.FromResult(ctx.Activity.IsType(ActivityTypes.Invoke) && ctx.Activity.Name != null && Regex.IsMatch(ctx.Activity.Name, "signin/.*")),
OnSigninInvokeStateAsync);
}
private Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
=> _dialog.RunAsync(turnContext, turnState.Conversation, 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:
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)
: base(channelServiceClientFactory, activityTaskQueue, logger: logger)
{
OnTurnError = async (turnContext, exception) =>
{
if (conversationState != null)
{
await conversationState.DeleteStateAsync(turnContext);
}
};
}
After — remove ConversationState from the adapter:
public MyAdapter(
IChannelServiceClientFactory channelServiceClientFactory,
IActivityTaskQueue activityTaskQueue,
ILogger<CloudAdapter> logger)
: base(channelServiceClientFactory, activityTaskQueue, logger: logger)
{
}
Add to the AgentApplication bot constructor:
public MyBot(AgentApplicationOptions options, ...) : base(options)
{
OnTurnError(OnAgentTurnErrorAsync);
}
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.
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());
return new InvokeResponse { Status = 200, Body = searchResponseData };
}
return null;
}
public ActivityBot(AgentApplicationOptions options) : base(options)
{
AdaptiveCards.OnSearch("npmpackages", OnNpmSearchAsync);
AdaptiveCards.OnSearch("cities", OnCitiesSearchAsync);
}
private async Task<IList<AdaptiveCardsSearchResult>> OnNpmSearchAsync(
ITurnContext turnContext, ITurnState turnState, Query<AdaptiveCardsSearchParams> query, CancellationToken ct)
{
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.
private Task<IList<AdaptiveCardsSearchResult>> OnCitiesSearchAsync(
ITurnContext turnContext, ITurnState turnState, Query<AdaptiveCardsSearchParams> query, CancellationToken ct)
{
var card = ProtocolJsonSerializer.ToObject<DependantDropdownCard>(turnContext.Activity.Value);
string country = card?.data?.choiceSelect?.ToLower() ?? "";
}
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 |
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)
{
var request = ProtocolJsonSerializer.ToObject<TaskModuleRequest>(turnContext.Activity.Value);
var feedback = ProtocolJsonSerializer.ToObject<MySubmitModel>(request.Data);
return null;
}
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>:
protected override async Task<MessagingExtensionResponse> OnTeamsMessagingExtensionQueryAsync(
ITurnContext<IInvokeActivity> turnContext, MessagingExtensionQuery query, CancellationToken ct)
{
var text = query?.Parameters?[0]?.Value?.ToString();
return new MessagingExtensionResponse { ComposeExtension = new MessagingExtensionResult { ... } };
}
private async Task<MessagingExtensionResult> OnQueryAsync(
ITurnContext turnContext, ITurnState turnState,
Query<IDictionary<string, object>> query, CancellationToken ct)
{
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 };
}
SelectItem handler — item is now object (cast to JsonElement):
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 { ... } });
}
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", ... });
}
Full registration pattern
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.App.AdaptiveCards;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Extensions.Teams.App;
using Microsoft.Agents.Extensions.Teams.Models;
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:
builder.AddAgentApplicationOptions();
builder.AddAgent<EchoBot>();
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>();
Remove ConversationState and UserState from DI
AgentApplication manages state internally via ITurnState. Remove these registrations entirely:
builder.Services.AddSingleton<IStorage, MemoryStorage>();
builder.Services.AddSingleton<MainDialog>();
builder.Services.AddSingleton<IMyService, MyService>();
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)
builder.Services.AddControllers();
app.MapControllers().AllowAnonymous();
app.MapControllers();
app.UseAuthentication();
app.UseAuthorization();
app.MapAgentRootEndpoint();
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:
builder.Services.AddRazorPages();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
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()
app.MapAgentEndpoints(requireAuth: !app.Environment.IsDevelopment());
app.MapAgentRootEndpoint();
app.MapAgentApplicationEndpoints(requireAuth: !app.Environment.IsDevelopment());
Pattern C: app.MapPost(...) (older minimal API)
Offer to switch. If accepted:
app.MapPost("/api/messages", async (HttpRequest req, HttpResponse res, IAgentHttpAdapter adapter, IAgent bot, CancellationToken ct)
=> await adapter.ProcessAsync(req, res, bot, ct)).AllowAnonymous();
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:
"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.
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:
"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 |