Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
Note
For a language-agnostic overview of proactive scenarios and patterns, see Proactive Messaging Overview.
Proactive messaging lets your agent send messages or run logic in an existing conversation without waiting for the user to speak first. Common uses include notifications, reminders, background job results, and any scenario where an external event needs to reach a user.
AgentApplication.Proactive provides this surface. It's available only to agents built on
AgentApplication (not ActivityHandler).
Concepts
Conversation
Before you can send a proactive message, you need to know which conversation to target.
A Conversation object holds a ConversationReference (including channel, service URL, and user ID)
plus the JSON Web Token (JWT) claims needed to authenticate the outbound call.
You acquire a Conversation in one of two ways:
| Approach | When to use |
|---|---|
Store it during a normal turn with Proactive.StoreConversationAsync |
The user speaks to your agent and you want to reach them later |
Build one with ConversationBuilder, or receive one via HTTP |
An external system supplies the reference, or you're creating a new conversation |
Three proactive operations
| Operation | What it does |
|---|---|
| SendActivity | Sends a single IActivity to a conversation. No TurnState, no route handlers, just a fire-and-forget message. |
| ContinueConversation | Runs a RouteHandler delegate inside the conversation context, giving full ITurnContext, ITurnState, middleware, and optional OAuth tokens. |
| CreateConversation | Asks the channel to create a brand-new conversation, then optionally continues it immediately. |
Setup
1. Verify storage is registered
Proactive messaging requires persistent storage so that conversation references survive agent restarts. Use MemoryStorage in development and a durable store like Azure Cosmos DB or blob storage in production.
Here's an example of how it works in Program.cs:
builder.AddAgentApplicationOptions(); // reads AgentApplicationOptions from config
builder.AddAgent<MyAgent>();
// IStorage is used by both TurnState and Proactive
builder.Services.AddSingleton<IStorage, MemoryStorage>();
builder.Services.AddAgentAspNetAuthentication(builder.Configuration);
AgentApplicationOptions automatically wires ProactiveOptions from the Proactive config section when IStorage is registered in DI.
2. (Optional) Map HTTP endpoints
If external systems need to trigger proactive actions over HTTP, add
MapAgentProactiveEndpoints after MapAgentApplicationEndpoints:
app.MapAgentApplicationEndpoints();
// Exposes /proactive/sendactivity, /proactive/continue, /proactive/create
app.MapAgentProactiveEndpoints<MyAgent>();
See Triggering proactive actions over HTTP for the full endpoint reference.
Scenario 1: Send a simple proactive message
Step 1: Store the conversation
During any normal turn, call StoreConversationAsync to persist the conversation for later:
public class MyAgent : AgentApplication
{
public MyAgent(AgentApplicationOptions options) : base(options)
{
OnMessage("/subscribe", async (ctx, state, ct) =>
{
string conversationId = await Proactive.StoreConversationAsync(ctx, ct);
await ctx.SendActivityAsync(
$"You're subscribed! Conversation ID: {conversationId}",
cancellationToken: ct);
});
}
}
Note
StoreConversationAsync returns the conversation's ID string. Use this ID later to continue the conversation.
Step 2: Send the message later
When the external event arrives (a webhook, a background job, a timer), use the stored ID:
// adapter and agent.Proactive resolved via DI / the host
await agent.Proactive.SendActivityAsync(
adapter,
conversationId,
MessageFactory.Text("Your report is ready!"),
cancellationToken);
Scenario 2: Continue a conversation with full agent logic
ContinueConversationAsync runs a handler inside the full AgentApplication pipeline,
giving the handler access to ITurnState, middleware, streaming responses, and cached OAuth
tokens.
Define continuation handlers with [ContinueConversation]
Decorate a method with [ContinueConversationAttribute] to mark it as a proactive handler.
The optional key distinguishes multiple handlers on the same agent, and
autoSignInHandlers names the OAuth connections that must be signed in before the handler
runs:
public class MyAgent : AgentApplication
{
public MyAgent(AgentApplicationOptions options) : base(options) { }
// Default handler, no key, reachable via code or POST /proactive/continue/{conversationId}
[ContinueConversation(autoSignInHandlers: "graph")]
public async Task OnDailyReminderAsync(
ITurnContext turnContext,
ITurnState turnState,
CancellationToken cancellationToken)
{
// TurnState is fully available
var lastSeen = turnState.User.Get<DateTime>("lastSeen");
// OAuth token available because "graph" is listed in autoSignInHandlers
var token = await turnContext.GetTurnTokenAsync(cancellationToken: cancellationToken);
await turnContext.SendActivityAsync(
$"Daily reminder, you last visited on {lastSeen:d}.",
cancellationToken: cancellationToken);
turnState.User.Set("lastReminderSent", DateTime.UtcNow);
}
// Named handler, POST /proactive/continue/report/{conversationId}
[ContinueConversation("report")]
public async Task OnReportReadyAsync(
ITurnContext turnContext,
ITurnState turnState,
CancellationToken cancellationToken)
{
await turnContext.SendActivityAsync(
"Your scheduled report is ready. Check your email.",
cancellationToken: cancellationToken);
}
}
Invoke from code
Pass the decorated method to ContinueConversationAsync. The OAuth handler names are read
automatically from the attribute:
// Token handlers ("graph") are picked up from the [ContinueConversation] attribute
await Proactive.ContinueConversationAsync(
adapter,
conversationId,
OnDailyReminderAsync,
cancellationToken: ct);
You can also override them at the call site:
await Proactive.ContinueConversationAsync(
adapter,
conversationId,
OnDailyReminderAsync,
autoSignInHandlers: ["graph", "sharepoint"],
cancellationToken: ct);
Pass custom data to the handler
Use a custom continuationActivity to carry a payload in via turnContext.Activity.Value:
var continuation = conversation.Reference.GetContinuationActivity();
continuation.Value = new { ReportId = 42, Title = "Q1 Summary" };
await Proactive.ContinueConversationAsync(
adapter,
conversation,
async (ctx, state, ct) =>
{
dynamic data = ctx.Activity.Value;
await ctx.SendActivityAsync(
$"Report #{data.ReportId} '{data.Title}' is ready.",
cancellationToken: ct);
},
continuationActivity: continuation,
cancellationToken: ct);
Scenario 3: Create a new conversation
Use CreateConversationAsync when the user never spoke to your agent and you need to
reach them directly, for example, to send an onboarding message or open a Teams chat.
Build the options
var createOptions = CreateConversationOptionsBuilder
.Create("your-agent-clientId", channelId: Channels.Msteams)
.WithTenantId("your-tenant-id")
.WithUser(userId: "aad-user-object-id")
.WithTopicName("Welcome to Contoso Agent")
.WithActivity(MessageFactory.Text("Hi! I'm here to help you get started."))
.Build();
Create only
Create a new conversation and persist the returned ID for future proactive messages:
Conversation newConversation = await Proactive.CreateConversationAsync(
adapter, createOptions, cancellationToken: ct);
// Persist the new ID for future proactive messages
string newConversationId = newConversation.Reference.Conversation.Id;
Create and immediately continue
To run agent logic right after the conversation is created, supply a handler. The handler is called with a "CreateConversation" event activity:
[ContinueConversation]
public async Task OnNewConversationAsync(
ITurnContext turnContext,
ITurnState turnState,
CancellationToken cancellationToken)
{
await turnContext.SendActivityAsync(
"Welcome! Here is what I can do for you...",
cancellationToken: cancellationToken);
}
// Create then immediately run OnNewConversationAsync
var newConversation = await Proactive.CreateConversationAsync(
adapter,
createOptions,
continuationHandler: OnNewConversationAsync,
cancellationToken: ct);
Note
If WithActivity(...) and a continuationHandler are both specified, the channel receives
the initial activity first, then the handler is invoked.
Autostore the new conversation
Call WithStoreConversation(true) to automatically persist the reference:
var createOptions = CreateConversationOptionsBuilder
.Create("your-bot-app-id", Channels.Msteams)
.WithTenantId("your-tenant-id")
.WithUser("user-id")
.WithStoreConversation(true)
.Build();
var conv = await Proactive.CreateConversationAsync(adapter, createOptions, ct);
// conv.Reference.Conversation.Id can now be used directly with SendActivityAsync using the conversationId
Scenario 4: Long-running external task
Use this pattern when the user requests an operation that takes an extended amount of time, with an external service performing the work.
Here's the basic flow:
User asks the agent to start a long-running job.
Agent captures the conversation and sends it, along with task details, to the external service.
The external service does the work, then calls back to the agent over HTTP with the result and the original
Conversation.Agent runs its
[ContinueConversation]handler and delivers the result.
public class MyAgent : AgentApplication
{
public MyAgent(AgentApplicationOptions options) : base(options)
{
OnMessage("analyze", async (turnContext, turnState, cancellationToken) =>
{
var conversation = new Conversation(turnContext);
// Send task + conversation to the external service
await _externalService.SubmitJobAsync(new
{
DocumentId = turnContext.Activity.Text,
Conversation = conversation.ToJson(),
CallbackUrl = "https://myagent.example.com/proactive/continue/analysis"
}, cancellationToken);
await turnContext.SendActivityAsync(
"Analysis started. I'll notify you when it's done.",
cancellationToken: cancellationToken);
});
}
// External service calls POST /proactive/continue/analysis
// with the result in the request body, which arrives as turnContext.Activity.Value.
[ContinueConversation("analysis")]
public async Task OnAnalysisCompleteAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
dynamic result = turnContext.Activity.Value;
await turnContext.SendActivityAsync(
$"Analysis complete: {result.Summary}",
cancellationToken: cancellationToken);
}
}
Note
For the external callback to work, you must call MapAgentProactiveEndpoints<MyAgent>() in Program.cs.
See Triggering proactive actions over HTTP.
Scenario 5: Background task
Use IBackgroundTaskQueue when the work happens inside the same process as the agent. This
is a hosted service provided by Microsoft.Agents.Hosting.AspNetCore.
Inject IBackgroundTaskQueue into the agent and capture the Conversation before the turn
ends. The turn context isn't valid after the turn completes:
public class MyAgent : AgentApplication
{
private readonly IBackgroundTaskQueue _backgroundTaskQueue;
public MyAgent(AgentApplicationOptions options, IBackgroundTaskQueue backgroundTaskQueue)
: base(options)
{
_backgroundTaskQueue = backgroundTaskQueue;
OnMessage("process", OnProcessRequestAsync);
}
private Task OnProcessRequestAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// Capture conversation before the turn ends. Do not close over turnContext.
var conversation = new Conversation(turnContext);
var adapter = turnContext.Adapter;
_backgroundTaskQueue.QueueBackgroundWorkItem(async ct =>
{
// Perform the long-running work
await Task.Delay(TimeSpan.FromSeconds(5), ct);
// Send a simple message back to the user
await Proactive.SendActivityAsync(
adapter,
conversation,
MessageFactory.Text("Processing complete!"),
ct);
});
return turnContext.SendActivityAsync(
"Working on it. I'll let you know when it's done.",
cancellationToken: cancellationToken);
}
}
Note
Capture turnContext.Adapter and new Conversation(turnContext) before the lambda, not
inside it. The ITurnContext is disposed after the turn completes and isn't safe to use in
a background thread.
Context inside ContinueConversation handlers
A ContinueConversation handler receives a fully initialized turn context scoped to the
original conversation. This context means:
TurnStateloads relative to the original conversation and user, the same state the user's reactive turns see.- OAuth tokens obtained during prior reactive turns are available if their connection names
are listed in
autoSignInHandlers. If any listed connection isn't signed in,ContinueConversationAsyncthrowsUserNotSignedInby default (configurable viaProactiveOptions.FailOnUnsignedInConnections). - The handler automatically saves state after it completes, as in a reactive turn.
[ContinueConversation(autoSignInHandlers: "graph")]
public async Task OnProactiveAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
{
// User-scoped state from prior turns is available
var name = turnState.User.Get<string>("preferredName");
// Token for "graph" was previously acquired during a reactive sign-in turn
var token = await turnContext.GetTurnTokenAsync(cancellationToken: cancellationToken);
await turnContext.SendActivityAsync(
$"Hi {name}, here is your update...",
cancellationToken: cancellationToken);
// State changes are persisted after the handler returns
turnState.User.Set("lastNotified", DateTime.UtcNow);
}
Multiple ContinueConversation handlers
An agent can define multiple [ContinueConversation] handlers, each identified by an
optional key. Each handler has its own HTTP endpoint when MapAgentProactiveEndpoints is
enabled.
// Default handler, POST /proactive/continue OR /proactive/continue/{conversationId}
[ContinueConversation(autoSignInHandlers: "graph")]
public async Task OnContinueConversationAsync(
ITurnContext turnContext,
ITurnState turnState,
CancellationToken cancellationToken)
{
var graphToken = await turnContext.GetTurnTokenAsync("graph");
// ...
}
// Named handler, POST /proactive/continue/notify OR /proactive/continue/notify/{conversationId}
[ContinueConversation("notify")]
public async Task OnNotifyAsync(
ITurnContext turnContext,
ITurnState turnState,
CancellationToken cancellationToken)
{
// ...
}
Invoke a specific handler from code by passing its method reference. The autoSignInHandlers
from the attribute are picked up automatically:
// Invokes the default handler; picks up autoSignInHandlers: "graph"
await Proactive.ContinueConversationAsync(
adapter, conversationId, OnContinueConversationAsync, cancellationToken: ct);
// Invokes the named handler
await Proactive.ContinueConversationAsync(
adapter, conversationId, OnNotifyAsync, cancellationToken: ct);
Triggering proactive actions over HTTP
After you call MapAgentProactiveEndpoints<MyAgent>(), the following endpoints are available.
They require the same authentication as /api/messages unless you pass requireAuth: false
to MapAgentProactiveEndpoints.
Each operation comes in two variants depending on whether the conversation was previously stored in the SDK's built-in store:
| Variant | How the conversation is identified |
|---|---|
| By stored ID | The conversation was saved with StoreConversationAsync. Supply the returned ID in the URL path. |
By inline Conversation |
The full Conversation object is supplied in the request body. No prior storage required. |
Send an activity by stored ID
Request:
POST /proactive/sendactivity/{conversationId}
Request body, an IActivity:
{
"type": "message",
"text": "Your job finished successfully."
}
Response 200:
{ "id": "activity-id" }
Send an activity by inline conversation
Request:
POST /proactive/sendactivity
Request body, a Conversation object plus the activity to send:
{
"conversation": {
"reference": {
"channelId": "msteams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"bot": { "id": "your-bot-app-id" },
"user": { "id": "29:user-aad-object-id" },
"conversation": { "id": "a:conv-id", "isGroup": false }
},
"claims": {
"aud": "your-bot-app-id",
"tid": "your-tenant-id"
}
},
"activity": {
"type": "message",
"text": "Your job finished successfully."
}
}
Response 200:
{ "id": "activity-id" }
Continue a conversation by stored ID
For each [ContinueConversation] handler, register two endpoints - one for each variant.
If query string parameters are present, forward them to the handler as turnContext.Activity.Value
(a key/value dictionary) with turnContext.Activity.ValueType set to
Proactive.ContinueConversationValueType.
Default handler ([ContinueConversation]):
POST /proactive/continue/{conversationId}
POST /proactive/continue/{conversationId}?result=success&jobId=42
Named handler ([ContinueConversation("report")]):
POST /proactive/continue/report/{conversationId}
No request body is required or read. Response 200 has no body.
Continue a conversation by inline Conversation
Default handler:
POST /proactive/continue
Named handler:
POST /proactive/continue/report
Body, the Conversation object:
{
"reference": {
"channelId": "msteams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"bot": { "id": "your-bot-app-id" },
"user": { "id": "29:user-aad-object-id" },
"conversation": { "id": "a:conv-id", "isGroup": false }
},
"claims": {
"aud": "your-bot-app-id",
"tid": "your-tenant-id"
}
}
Forward query string parameters in the same way as the by-ID variant.
Response 200 has no body.
Create a new conversation
Create doesn't have a by-ID variant. The conversation doesn't exist yet.
Default handler:
POST /proactive/create
Named handler:
POST /proactive/create/onboarding
Request body:
{
"agentClientId": "your-bot-app-id",
"channelId": "msteams",
"tenantId": "your-tenant-id",
"user": { "id": "aad-user-object-id" },
"topicName": "Proactive Chat",
"activity": { "type": "message", "text": "Hello from the server!" },
"teamsChannelId": null,
"channelData": null,
"isGroup": null,
"storeConversation": false,
"continueConversation": false
}
Request body parameter descriptions:
| Field | Required | Description |
|---|---|---|
channelId |
Yes | Target channel (for example, "msteams", "webchat"). |
user |
Yes | The target user. For Teams, use the Microsoft Entra ID object ID. |
agentClientId |
No | The agent's app (client) ID. If omitted, the authenticated caller's identity is used. |
tenantId |
No | Required for Teams. |
topicName |
No | Conversation topic/display name. |
activity |
No | Initial activity sent at conversation creation time. |
teamsChannelId |
No | Teams channel ID (19:...@thread.tacv2) to post into a specific channel. |
channelData |
No | Channel-specific data passed to the channel. |
isGroup |
No | Overrides the group conversation flag. Inferred automatically when teamsChannelId is set. |
storeConversation |
No | true to persist the new conversation in the SDK store. Defaults to false. |
continueConversation |
No | true to invoke the matching [ContinueConversation] handler immediately after creation. Defaults to false. |
Query string parameters, if present, are forwarded to the continuation handler the same way as
the continue endpoints.
Response 200 returns the newly created Conversation object:
{
"reference": {
"channelId": "msteams",
"serviceUrl": "https://smba.trafficmanager.net/amer/",
"bot": { "id": "your-bot-app-id" },
"user": { "id": "29:user-aad-object-id" },
"conversation": { "id": "a:new-conv-id", "isGroup": false }
},
"claims": {
"aud": "your-bot-app-id",
"tid": "your-tenant-id"
}
}
OAuth and sign-in in proactive contexts
When you specify autoSignInHandlers, ContinueConversationAsync checks whether each
listed OAuth connection is already signed in before it invokes the handler. ProactiveOptions.FailOnUnsignedInConnections controls the behavior when a token is missing:
| Setting | Effect |
|---|---|
true (default) |
Throws UserNotSignedIn if any token is missing |
false |
Calls the handler anyway; missing tokens resolve to null |
Handle the sign-in gap gracefully:
try
{
await Proactive.ContinueConversationAsync(
adapter, conversationId, OnDailyReminderAsync, cancellationToken: ct);
}
catch (UserNotSignedIn)
{
// The user hasn't signed in yet. Send a nudge, or queue for later.
await Proactive.SendActivityAsync(
adapter,
conversationId,
MessageFactory.Text("Sign in so I can send you daily summaries."),
ct);
}
Error handling
| Exception | Cause |
|---|---|
KeyNotFoundException |
The conversationId wasn't found in storage. The conversation was never stored, or was deleted with DeleteConversationAsync. |
UserNotSignedIn |
autoSignInHandlers was specified but one or more tokens aren't cached and FailOnUnsignedInConnections is true. |
ArgumentNullException |
A required argument (adapter, activity, conversation) was null. |
ArgumentException |
Builder validation failed, typically a missing agentClientId, channelId, or user ID. |
Microsoft Teams specifics
Teams requires a tenant ID and, for conversation creation, a service URL. If you don't have the service URL from an inbound activity, use the regional constants:
// Public cloud
string serviceUrl = TeamsProactiveServiceEndpoints.publicGlobal;
// Government clouds
string serviceUrl = TeamsProactiveServiceEndpoints.gcc;
string serviceUrl = TeamsProactiveServiceEndpoints.gccHigh;
string serviceUrl = TeamsProactiveServiceEndpoints.dod;
Personal (1:1) chat
var createOptions = CreateConversationOptionsBuilder
.Create(
agentClientId: "your-bot-app-id",
channelId: Channels.Msteams,
serviceUrl: TeamsProactiveServiceEndpoints.publicGlobal)
.WithTenantId("your-tenant-id")
.WithUser(userId: "aad-user-object-id")
.WithTopicName("Contoso Notification")
.WithActivity(MessageFactory.Text("You have a new approval request."))
.Build();
var conv = await Proactive.CreateConversationAsync(adapter, createOptions, ct);
Channel post
To post into a specific Teams channel, supply the internal Teams channel ID by using
WithTeamsChannelId. This action automatically sets isGroup = true:
// teamsChannelId is the "19:...@thread.tacv2" identifier of the Teams channel,
// not the Channels.Msteams string.
var createOptions = CreateConversationOptionsBuilder
.Create("your-bot-app-id", Channels.Msteams)
.WithTenantId("your-tenant-id")
.WithUser("aad-user-object-id")
.WithTeamsChannelId("19:abc123@thread.tacv2")
.WithTopicName("Daily Digest")
.WithActivity(MessageFactory.Text("Here is today's digest."))
.Build();
await Proactive.CreateConversationAsync(adapter, createOptions, ct);
app.proactive provides this surface. It's available only to agents built on AgentApplication (not ActivityHandler).
Concepts
Conversation
Before you can send a proactive message, you need to know which conversation to target.
A Conversation object holds a ConversationReference (channel, service URL, user IDs)
plus the JWT claims needed to authenticate the outbound call.
You acquire a Conversation in one of two ways:
| Approach | When to use |
|---|---|
Store it during a normal turn with app.proactive.storeConversation(ctx) |
The user already spoke to your agent and you want to reach them later |
Build one with ConversationBuilder, or receive one via HTTP |
An external system supplies the reference, or you're creating a new conversation |
Three proactive operations
| Operation | What it does |
|---|---|
| sendActivity | Sends a single Activity to a conversation. No TurnState, no route handlers, just a fire-and-forget message. |
| continueConversation | Runs a handler inside the conversation context, giving full TurnContext, TurnState, middleware, and optional OAuth tokens. |
| createConversation | Asks the channel to create a brand-new conversation, then optionally continues it immediately. |
Setup
1. Configure storage
Proactive messaging requires persistent storage so that conversation references survive
agent restarts. Use MemoryStorage in development and a durable store (Azure Cosmos DB,
Blob Storage) in production.
import { AgentApplication, MemoryStorage, TurnState } from '@microsoft/agents-hosting'
const app = new AgentApplication<TurnState>({
storage: new MemoryStorage() // swap for BlobsStorage or CosmosDbPartitionedStorage in production
})
app.proactive is automatically available once you configure storage. Proactive operations use
the same storage instance. You can also pass a separate storage backend via the proactive
option:
const app = new AgentApplication<TurnState>({
storage: new MemoryStorage(),
proactive: {
storage: new BlobsStorage(connectionString, containerName)
}
})
Scenario 1: Send a simple proactive message
Step 1: Store the conversation
During any normal turn, call storeConversation to persist the conversation for later:
import { AgentApplication, TurnContext, TurnState } from '@microsoft/agents-hosting'
class MyAgent extends AgentApplication<TurnState> {
constructor() {
super({ storage: new MemoryStorage() })
this.onMessage('/subscribe', async (ctx: TurnContext, state: TurnState) => {
const conversationId = await this.proactive.storeConversation(ctx)
await ctx.sendActivity(`You're subscribed! Conversation ID: ${conversationId}`)
})
}
}
Note
storeConversation returns the conversation's ID string. Use this ID later to send
activities or continue the conversation.
Step 2: Send the message later
When the external event arrives (a webhook, a background job, a timer), use the stored ID:
// adapter resolved via startServer() or your manual Express setup
await app.proactive.sendActivity(
adapter,
conversationId,
{ type: 'message', text: 'Your report is ready!' }
)
Scenario 2: Continue a conversation with full agent logic
continueConversation runs a handler inside the full AgentApplication pipeline,
giving the handler access to TurnState, middleware, streaming responses, and cached OAuth
tokens.
await app.proactive.continueConversation(
adapter,
conversationId,
async (ctx: TurnContext, state: TurnState) => {
// TurnState is fully available, reads the same state as the user's reactive turns
const lastSeen = state.getValue('user.lastSeen') as string | undefined
await ctx.sendActivity(`Daily reminder, you last visited on ${lastSeen ?? 'never'}.`)
state.setValue('user.lastReminderSent', new Date().toISOString())
// TurnState is saved automatically after the handler returns
}
)
Pass custom data to the handler
Supply a continuationActivity to carry a payload via ctx.activity.value:
await app.proactive.continueConversation(
adapter,
conversationId,
async (ctx: TurnContext, state: TurnState) => {
const data = ctx.activity.value as { reportId: number; title: string }
await ctx.sendActivity(`Report #${data.reportId} '${data.title}' is ready.`)
},
undefined, // no autoSignInHandlers
{
value: { reportId: 42, title: 'Q1 Summary' },
valueType: Proactive.ContinueConversationValueType
}
)
Scenario 3: Create a new conversation
Use createConversation when the user never spoke to your agent and you need to
reach them directly, for example, to send an onboarding message or open a Teams chat.
Build the options
import { CreateConversationOptionsBuilder } from '@microsoft/agents-hosting'
const createOptions = CreateConversationOptionsBuilder
.create(process.env.CLIENT_ID!, 'msteams')
.withTenantId('your-tenant-id')
.withUser('aad-user-object-id')
.withTopicName('Welcome to Contoso Agent')
.withActivity({ type: 'message', text: 'Hi! I\'m here to help you get started.' })
.build()
Create only
Create a new conversation and persist the returned ID for future proactive messages:
const newConversation = await app.proactive.createConversation(adapter, createOptions)
// Persist the new ID for future proactive messages
const newConversationId = newConversation.reference.conversation.id
Create and immediately continue
Supply a handler as the third argument to run agent logic right after the conversation is created:
const newConversation = await app.proactive.createConversation(
adapter,
createOptions,
async (ctx: TurnContext, state: TurnState) => {
await ctx.sendActivity('Welcome! Here is what I can do for you...')
}
)
Note
If you specify both withActivity(...) and a continuation handler, the channel receives
the initial activity first, then the handler is invoked.
Autostore the new conversation
Call storeConversation(true) on the builder to automatically persist the reference:
const createOptions = CreateConversationOptionsBuilder
.create(process.env.CLIENT_ID!, 'msteams')
.withTenantId('your-tenant-id')
.withUser('user-id')
.storeConversation(true)
.build()
const conv = await app.proactive.createConversation(adapter, createOptions)
// conv.reference.conversation.id can now be used with sendActivity
Scenario 4: Long-running external task
Use this pattern when the user requests an operation that takes an extended amount of time, and an external service performs the work.
Here's the basic flow:
User asks the agent to start a long-running job.
Agent captures the conversation and sends it, along with task details, to the external service.
The external service does the work, then calls back to the agent over HTTP with the result.
Agent runs the continuation handler and delivers the result.
class MyAgent extends AgentApplication<TurnState> {
constructor() {
super({ storage: new MemoryStorage() })
this.onMessage('analyze', async (ctx: TurnContext, state: TurnState) => {
const conversation = new Conversation(ctx)
// Send task + conversation to the external service
await externalService.submitJob({
documentId: ctx.activity.text,
conversation: conversation.toJson(),
callbackUrl: 'https://myagent.example.com/api/proactive/continue/analysis'
})
await ctx.sendActivity('Analysis started. I\'ll notify you when it\'s done.')
})
}
}
Register the callback endpoint on the Express server:
const server = startServer(app)
// External service calls POST /api/proactive/continue/analysis with the result in the body
server.post('/api/proactive/continue/analysis', async (req, res) => {
const conversationId = req.body.conversationId as string
try {
await app.proactive.continueConversation(
adapter,
conversationId,
async (ctx: TurnContext, state: TurnState) => {
const result = ctx.activity.value as { summary: string }
await ctx.sendActivity(`Analysis complete: ${result.summary}`)
},
undefined,
{ value: req.body, valueType: Proactive.ContinueConversationValueType }
)
res.sendStatus(200)
} catch (err) {
res.status(500).json({ error: String(err) })
}
})
Scenario 5: Background task
Use a standard Promise-based background task when the work happens inside the same
process as the agent. Capture the Conversation and adapter before the turn ends.
The TurnContext isn't valid after the turn completes.
this.onMessage('process', async (ctx: TurnContext, state: TurnState) => {
// Capture the conversation before the turn ends. Do NOT close over ctx.
const conversation = new Conversation(ctx)
// Fire-and-forget the background work
void (async () => {
try {
// Perform the long-running work
await doLongRunningWork()
// Send a simple message back to the user
await this.proactive.sendActivity(
this.adapter,
conversation,
{ type: 'message', text: 'Processing complete!' }
)
} catch (err) {
console.error('Background task failed:', err)
}
})()
await ctx.sendActivity('Working on it. I\'ll let you know when it\'s done.')
})
Capture
new Conversation(ctx)before the async lambda, not inside it. TheTurnContextis disposed after the turn completes and isn't safe to use afterwards.this.adapterandthis.proactiveare stable class references and can be accessed anywhere.
Context inside continueConversation handlers
A continueConversation handler receives a fully initialized turn context scoped to the
original conversation. This context means:
TurnStateloads relative to the original conversation and user, the same state the user's reactive turns see.- OAuth tokens obtained during prior reactive turns are available if their handler names
are listed in
autoSignInHandlers. If any listed handler isn't signed in,continueConversationthrows by default (configurable viaproactive.failOnUnsignedInConnections). - The handler automatically saves state after it completes, as in a reactive turn.
await app.proactive.continueConversation(
adapter,
conversationId,
async (ctx: TurnContext, state: TurnState) => {
// User-scoped state from prior turns is available
const name = state.getValue('user.preferredName') as string
await ctx.sendActivity(`Hi ${name}, here is your update...`)
// State changes are persisted after the handler returns
state.setValue('user.lastNotified', new Date().toISOString())
},
['graph'] // autoSignInHandlers, token for 'graph' was acquired in a prior reactive turn
)
Multiple continuation handlers
Because TypeScript uses inline functions rather than decorated methods, multiple
continuation handlers are distinct functions or closures passed to
continueConversation. Register separate HTTP endpoints for each:
// Handler for daily reminders
const onDailyReminder = async (ctx: TurnContext, state: TurnState) => {
await ctx.sendActivity('Here is your daily summary...')
}
// Handler for report notifications
const onReportReady = async (ctx: TurnContext, state: TurnState) => {
await ctx.sendActivity('Your scheduled report is ready. Check your email.')
}
// Invoke from code
await app.proactive.continueConversation(adapter, conversationId, onDailyReminder, ['graph'])
await app.proactive.continueConversation(adapter, conversationId, onReportReady)
// Separate HTTP endpoints
server.post('/api/proactive/reminder/:conversationId', async (req, res) => {
await app.proactive.continueConversation(adapter, req.params.conversationId, onDailyReminder, ['graph'])
res.sendStatus(200)
})
server.post('/api/proactive/report/:conversationId', async (req, res) => {
await app.proactive.continueConversation(adapter, req.params.conversationId, onReportReady)
res.sendStatus(200)
})
Triggering proactive actions over HTTP
The TypeScript SDK doesn't register proactive HTTP endpoints automatically. You register
them on the Express server returned by startServer(). Apply the same authentication
middleware used for /api/messages if the endpoints should be protected.
A complete proactive Express setup:
import { startServer } from '@microsoft/agents-hosting-express'
import { AgentApplication, CloudAdapter, Conversation, CreateConversationOptionsBuilder, MemoryStorage, Proactive, TurnState } from '@microsoft/agents-hosting'
const app = new AgentApplication<TurnState>({ storage: new MemoryStorage() })
const server = startServer(app)
// Use the adapter that startServer created, same instance that processes inbound messages
const adapter = app.adapter as CloudAdapter
// --- Send activity to a stored conversation ---
server.post('/api/proactive/send/:conversationId', async (req, res) => {
try {
const result = await app.proactive.sendActivity(
adapter,
req.params.conversationId,
req.body
)
res.json({ id: result.id })
} catch (err) {
res.status(500).json({ error: String(err) })
}
})
// --- Continue a stored conversation ---
server.post('/api/proactive/continue/:conversationId', async (req, res) => {
try {
await app.proactive.continueConversation(
adapter,
req.params.conversationId,
async (ctx, state) => {
await ctx.sendActivity(`Proactive: ${(req.body as any).message}`)
},
undefined,
{ value: req.body, valueType: Proactive.ContinueConversationValueType }
)
res.sendStatus(200)
} catch (err) {
res.status(500).json({ error: String(err) })
}
})
// --- Continue using an inline Conversation object (no prior storage) ---
server.post('/api/proactive/continue', async (req, res) => {
const { reference, claims } = req.body.conversation
const conversation = new Conversation(claims, reference)
try {
await app.proactive.continueConversation(
adapter,
conversation,
async (ctx, state) => {
await ctx.sendActivity(`Inline proactive: ${(req.body as any).message}`)
}
)
res.sendStatus(200)
} catch (err) {
res.status(500).json({ error: String(err) })
}
})
// --- Create a new conversation ---
server.post('/api/proactive/create', async (req, res) => {
const { userId, tenantId } = req.body as { userId: string; tenantId: string }
const opts = CreateConversationOptionsBuilder
.create(process.env.CLIENT_ID!, 'msteams')
.withTenantId(tenantId)
.withUser(userId)
.storeConversation(true)
.build()
try {
const conv = await app.proactive.createConversation(adapter, opts)
res.json({ conversationId: conv.reference.conversation.id })
} catch (err) {
res.status(500).json({ error: String(err) })
}
})
OAuth and sign-in in proactive contexts
When you specify autoSignInHandlers, continueConversation checks whether each listed
OAuth handler is already signed in before invoking the handler. ProactiveOptions.failOnUnsignedInConnections controls the behavior when a token is missing:
| Setting | Effect |
|---|---|
true (default) |
Throws if any token is missing |
false |
Calls the handler anyway; missing tokens resolve to null |
Handle the sign-in gap gracefully:
import { AgentApplication, MemoryStorage, TurnState } from '@microsoft/agents-hosting'
const app = new AgentApplication<TurnState>({
storage: new MemoryStorage(),
proactive: { failOnUnsignedInConnections: true } // default, explicit for clarity
})
try {
await app.proactive.continueConversation(
adapter,
conversationId,
onDailyReminder,
['graph']
)
} catch (err) {
// User hasn't signed in yet. Send a nudge, or queue for later.
await app.proactive.sendActivity(
adapter,
conversationId,
{ type: 'message', text: 'Sign in so I can send you daily summaries.' }
)
}
Error handling
| Error | Cause |
|---|---|
Error: Conversation not found |
The conversationId wasn't found in storage. The conversation was never stored, or was deleted by using deleteConversation. |
Error: User not signed in |
You specified autoSignInHandlers but one or more tokens aren't cached and failOnUnsignedInConnections is true. |
TypeError |
A required argument (adapter, activity, conversation) is null or undefined. |
| Validation error | ConversationBuilder.build() or CreateConversationOptionsBuilder.build() failed, typically due to a missing agentClientId, channelId, or user ID. |
Important
Adapter callbacks swallow exceptions internally. The proactive methods
capture and re-throw errors automatically, but if you call adapter.continueConversation
directly, use the capture-and-rethrow pattern:
let capturedError: Error | undefined
await adapter.continueConversation(identity, ref, async (ctx) => {
try {
await doWork(ctx)
} catch (err) {
capturedError = err as Error
}
})
if (capturedError) throw capturedError
Microsoft Teams specifics
Teams requires a tenant ID and, for conversation creation, a service URL. If you don't have
the service URL from an inbound activity, use the well-known constants exported from
@microsoft/agents-hosting:
import { TeamsServiceEndpoints } from '@microsoft/agents-hosting'
// Public cloud
const serviceUrl = TeamsServiceEndpoints.publicGlobal
// Government clouds
const serviceUrl = TeamsServiceEndpoints.gcc
const serviceUrl = TeamsServiceEndpoints.gccHigh
const serviceUrl = TeamsServiceEndpoints.dod
Personal (1:1) chat
const createOptions = CreateConversationOptionsBuilder
.create(
process.env.CLIENT_ID!,
'msteams',
TeamsServiceEndpoints.publicGlobal
)
.withTenantId('your-tenant-id')
.withUser('aad-user-object-id')
.withTopicName('Contoso Notification')
.withActivity({ type: 'message', text: 'You have a new approval request.' })
.build()
const conv = await app.proactive.createConversation(adapter, createOptions)
Channel post
To post into a specific Teams channel, supply the internal Teams channel ID by using
withTeamsChannelId. This action automatically sets isGroup = true:
// teamsChannelId is the "19:...@thread.tacv2" identifier of the Teams channel,
// not the string 'msteams'.
const createOptions = CreateConversationOptionsBuilder
.create(process.env.CLIENT_ID!, 'msteams')
.withTenantId('your-tenant-id')
.withUser('aad-user-object-id')
.withTeamsChannelId('19:abc123@thread.tacv2')
.withTopicName('Daily Digest')
.withActivity({ type: 'message', text: 'Here is today\'s digest.' })
.build()
await app.proactive.createConversation(adapter, createOptions)