Skip to main content

Sending Messages

Sending messages is a core part of an agent's functionality. With all activity handlers, a send method is provided which allows your handlers to send a message back to the user to the relevant conversation.

SDK 2.1

In SDK 2.1, the per-turn context.Send, context.Reply, and context.Quote helpers work the same way. The main difference is streaming β€” see the tabbed examples below.

teams.OnMessage(async (context, cancellationToken) =>
{
await context.SendAsync($"you said: {context.Activity.Text}", cancellationToken);
});

In the above example, the handler gets a message activity, and uses the send method to send a reply to the user.

var flow = teams.GetOAuthFlow("graph");
flow.OnSignInComplete(async (context, tokenResponse, cancellationToken) =>
{
await context.SendAsync("You have successfully signed in!", cancellationToken);
});

You are not restricted to only replying to message activities. In the above example, the handler is listening to OnSignInComplete events, which are sent when a user successfully signs in.

tip

This shows an example of sending a text message. Additionally, you are able to send back things like adaptive cards by using the same send method. Look at the adaptive card section for more details.

Streaming​

You may also stream messages to the user which can be useful for long messages, or AI generated messages. The SDK makes this simple for you by providing a stream function which you can use to send messages in chunks.

Streaming changes how the response arrives, not how fast your agent produces it. Text appears incrementally as it is generated, so the user sees progress rather than a blank screen, and you can show a thinking or status indicator before any real output exists. A streamed response is also stoppable: when the user stops it, the SDK surfaces the cancellation to your handler so you can abandon the remaining work. The gain is in perceived time to first output, which is why streaming suits long or model generated replies and adds little to a short one you could simply send.

The 2.1 streams through a TeamsStreamingWriter. Create one from the turn context, push informative updates and response chunks, then finalize:

teams.OnMessage(async (context, cancellationToken) =>
{
TeamsStreamingWriter writer = TeamsStreamingWriter.CreateFromContext(context);

await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken);

await writer.AppendResponseAsync("hello", cancellationToken);
await writer.AppendResponseAsync(", ", cancellationToken);
await writer.AppendResponseAsync("world!", cancellationToken);

// flush the accumulated text as the final message: "hello, world!"
await writer.FinalizeResponseAsync(cancellationToken: cancellationToken);
});
note

Streaming is currently only supported in 1:1 conversations, not group chats or channels. Teams also supports only one concurrent streaming response per chat at a time.

Every inbound activity gets its own stream, and the SDK closes that stream at the end of the turn. It does not serialize turns, though, and handlers can run concurrently, so a second activity can arrive while the first is still streaming. Streaming therefore brings message handling concurrency concerns that do not exist when you just send a reply, and resolving them may be implemented in your custom code by serializing turns per conversation or by finalizing the in-flight stream before starting the next one.

Animated image showing agent response text incrementally appearing in the chat window.

Chunk pacing and retries​

You do not need to throttle your own chunks. Rapid writer.AppendResponseAsync() calls are coalesced and spaced roughly 500 milliseconds apart, so a token-by-token loop becomes a couple of network calls per second rather than one call per token. Every streamed chunk carries the full text accumulated so far rather than just the newest delta, so collapsing several chunks into one send never loses content. Wrapping your own throttle or sleep around each chunk mostly slows the response down, since it would be pacing a path that is already paced.

This describes the SDK 2.1 TeamsStreamingWriter.

writer.AppendResponseAsync() sends inline rather than queueing: it is awaited, and it makes the HTTP call itself. Every call appends your chunk to the accumulated buffer, but if less than 500ms has passed since the last chunk went out it returns without sending, and that gate is what keeps a fast loop from flooding the streaming API.

Because the gate has no trailing timer, a burst that ends inside that 500ms window leaves its final delta buffered but not yet on screen. It goes out with the next AppendResponseAsync() that clears the gate, or with FinalizeResponseAsync(), so no text is lost, though the tail of a short response can appear only once the stream finalizes.

TeamsStreamingWriter does not retry. Each chunk is a single attempt, so a transient failure is not retried for you. A timed out or cancelled stream is handled internally, and any other streaming error is thrown to your handler.

None of this extends the two minute window, which runs on wall clock from the first chunk. Pacing keeps a stream under the streaming API's rate limit, but does not buy more time. It also applies only to the streaming path: the plain activity updates go out exactly when they are sent, so that cadence stays yours to manage.

The two minute streaming limit​

Teams keeps a message stream open for two minutes, measured from its first chunk. After that the service rejects further chunks for that message with a 403 and the error code ContentStreamNotAllowed (Content stream finished due to exceeded streaming time.).

However, hitting the limit does not mean the response is lost. The SDK marks the stream as timed out and stops pushing chunks, but it keeps buffering everything your agent produces, and writer.FinalizeResponseAsync() finalizes by updating the original message in place with the full buffered text.

warning

The two minute clock starts on the first streamed chunk, and an informative update (writer.SendInformativeUpdateAsync()) is a chunk. An agent that posts a status update immediately and then thinks for three minutes has spent its entire window before emitting a single token. When the slow work happens before you have any output, do that work first and open the stream once real content starts.

Running longer than two minutes​

To outlast the window, hand off before it closes: stream normally, then finalize the stream and continue the response growing with plain activity updates. No streaming window applies to plain updates, so the answer can keep arriving for as long as your agent runs.

This pattern uses the SDK 2.1 TeamsStreamingWriter. There is no SDK 2.0 (Legacy) variant on this page.

FinalizeResponseAsync does not return the finalized message, so the id of the streamed message is not available to the caller. The continuation is therefore sent as one follow-up message, which is then edited in place for the rest of the run. That follow-up carries only the text produced after the handoff, because the streamed message already published everything before it, so the two messages read in sequence.

teams.OnMessage(async (context, cancellationToken) =>
{
TeamsStreamingWriter writer = TeamsStreamingWriter.CreateFromContext(context);
string conversationId = context.Activity.Conversation.Id;

StringBuilder continuation = new();
DateTimeOffset? opened = null;
bool handedOff = false;
string? messageId = null;
DateTimeOffset lastEdit = DateTimeOffset.MinValue;

await foreach (string chunk in RunAgentAsync(context.Activity.Text, cancellationToken))
{
// Phase 1: real streaming. The window opens on the first streamed chunk,
// not when the handler starts, so the clock starts here.
if (!handedOff && (opened is null || DateTimeOffset.UtcNow - opened < TimeSpan.FromSeconds(110)))
{
opened ??= DateTimeOffset.UtcNow;
await writer.AppendResponseAsync(chunk, cancellationToken);
continue;
}

// This chunk was never streamed, so it belongs to the continuation.
continuation.Append(chunk);

// Hand off once: finalize the streamed message, then open the follow-up that carries the rest.
if (!handedOff)
{
await writer.FinalizeResponseAsync(cancellationToken: cancellationToken);
handedOff = true;

SendActivityResponse? sent = await context.SendAsync(
new MessageActivityInput().WithText(continuation.ToString()), cancellationToken);

messageId = sent?.Id;
lastEdit = DateTimeOffset.UtcNow;
continue;
}

// Phase 2: plain edits on the follow-up. No two minute ceiling.
if (messageId is not null && DateTimeOffset.UtcNow - lastEdit > TimeSpan.FromSeconds(3))
{
await context.Api.Conversations.UpdateActivityAsync(
conversationId,
messageId,
new MessageActivityInput().WithText(continuation.ToString()),
cancellationToken: cancellationToken);

lastEdit = DateTimeOffset.UtcNow;
}
}

if (!handedOff)
{
await writer.FinalizeResponseAsync(cancellationToken: cancellationToken);
}
else if (messageId is not null)
{
await context.Api.Conversations.UpdateActivityAsync(
conversationId,
messageId,
new MessageActivityInput().WithText(continuation.ToString()),
cancellationToken: cancellationToken);
}
});

handedOff decides whether the stream still needs finalizing, which is why it is tracked separately from messageId. The follow-up is sent exactly once, in the same iteration that sets handedOff, so a missing id cannot cause a second send. It does mean the continuation stops updating at that point: SendAsync returns SendActivityResponse? with a nullable Id, and if no id comes back there is no way to edit that message, nor does the SDK expose the finalized streamed message's id as a fallback.

context.SendAsync always creates a new activity, even when the activity carries an id, so plain edits go through context.Api.Conversations.UpdateActivityAsync. Use writer.TimedOut to check whether the stream tripped the limit before you handed off.

Plain updates are subject to ordinary activity throttling rather than the streaming path's allowance, so pace them at a cadence measured in seconds rather than sending one per token.

Starting another streamed message​

Calling writer.AppendResponseAsync() again after the stream has been closed posts a second, separate message in the conversation by reusing the SDK object in your custom handler; it clears its buffered state and starts the new message. The second message gets its own stream id, so it also gets its own fresh two minute window. The message already finalized stays finalized and is not reopened or extended. Use this when your agent has a genuinely separate second answer to stream, not as a way to give one response more time.

@Mention​

Sending a message at @mentions a user is as simple including the details of the user using the AddMention method

teams.OnMessage(async (context, cancellationToken) =>
{
await context.SendAsync(
new MessageActivityInput()
.WithText("hi!")
.AddMention(context.Activity.From),
cancellationToken);
});

Targeted Messages​

Targeted messages, also known as ephemeral messages, are delivered to a specific user in a shared conversation. From a single user's perspective, they appear as regular inline messages in a conversation. Other participants won't see these messages, making them useful for authentication flows, help or error responses, personal reminders, or sharing contextual information without cluttering the group conversation.

To send a targeted message when responding to an incoming activity, use the WithRecipient method with the recipient account and set the targeting flag to true.

teams.OnMessage(async (context, cancellationToken) =>
{
// Using WithRecipient with isTargeted=true explicitly targets the specified recipient
await context.SendAsync(
new MessageActivityInput()
.WithText("This message is only visible to you!")
.WithRecipient(context.Activity.From, isTargeted: true),
cancellationToken
);
});

Prompt Preview​

Coming Soon

Prompt Preview is coming soon in June 2026.

Prompt Preview shows a compact, collapsible preview of the targeted message your agent is replying to, helping carry context from a private user-to-agent message into the reply.

Prompt Preview in targeted reply​

In a targeted (private) reply, both the prompt preview and the bot response are visible only to the targeted user.

Prompt Preview in a targeted reply.

Prompt Preview in public reply​

In a public reply, the same prompt preview appears above the bot response and is visible to everyone in the conversation.

Prompt Preview in a public reply.

In reactive scenarios, when replying to an inbound targeted activity through Send() or Reply(), the SDK automatically includes targeted message info.

For proactive scenarios (using app.SendAsync()), attach targeted message info using the targeted message ID you are replying to.

var targetedMessageId = "1772050244572";
var conversationId = "19:groupchat-id@thread.v2";
var userAccount = new Account
{
Id = "29:1AbCDef...",
Name = "Adele Vance"
};

var targetedMessage = new MessageActivity("Here is the result!")
.AddTargetedMessageInfo(targetedMessageId)
.WithRecipient(userAccount, isTargeted: true);

// Targeted reply (only the user sees it)
await app.Send(conversationId, targetedMessage);

// OR public reply (everyone sees it)
var publicMessage = new MessageActivity("Here is the result!")
.AddTargetedMessageInfo(targetedMessageId);
await app.Send(conversationId, publicMessage);

Reactions​

Reactions allow your agent to add or remove emoji reactions on messages in a conversation, and to receive reactions added by users. See the Message Reactions guide for full coverage.

Threading​

In Teams channels, messages can be organized into threads. The SDK provides helpers to simplify working with threads.

Reactive Threading (Within a Handler)​

When your agent receives a message in a thread, the conversation context already carries the thread ID. Use Send() to send a message in the same thread without quoting, or Reply() to send with a visual quote of the inbound message.

teams.OnMessage(async (context, cancellationToken) =>
{
// Send in the same thread, no quote
await context.SendAsync("Acknowledged", cancellationToken);

// Send in the same thread with a visual quote of the inbound message
await context.Reply("Got it!", cancellationToken);
});

For proactive threading (sending to a thread outside of a handler), see Proactive Messaging.

Quoted Replies​

Quoted replies let your agent reference a previous message in the conversation. When a user sends a message that quotes another message, your agent receives structured metadata about the quoted content. Your agent can also send messages that quote previous messages.

Receiving Quoted Replies​

When a user quotes a message and sends it to your agent, the quoted reply metadata is available on the inbound activity. Use the GetQuotedMessages() method to access all quoted reply entities.

teams.OnMessage(async (context, cancellationToken) =>
{
var quotes = context.Activity.GetQuotedMessages();

if (quotes.Count > 0)
{
var quote = quotes[0].QuotedReply;
await context.Reply(
$"You quoted message {quote.MessageId} from {quote.SenderName}: \"{quote.Preview}\"",
cancellationToken);
}
});

Each quoted reply entity contains the quoted message's ID, sender information, a preview of the quoted text, and whether the quoted message has been deleted.

Sending a Quoted Reply​

When your agent calls Reply(), the SDK automatically stamps a quoted reply entity referencing the inbound message. The reply will appear as a quoted reply in Teams.

teams.OnMessage(async (context, cancellationToken) =>
{
// Reply() automatically quotes the inbound message
await context.Reply("Got it!", cancellationToken);
});

To quote a different message in the same conversation (not the inbound message), use the Quote() method with the message ID you want to quote.

teams.OnMessage(async (context, cancellationToken) =>
{
// Quote a specific message by its ID
var parentMessageId = "1772050244572";
await context.Quote(parentMessageId, "Referencing an earlier message", cancellationToken);
});

Building Quoted Replies for Proactive Send​

For proactive scenarios (using app.Send()) or when quoting multiple messages, use the AddQuote() method on a message activity. Pass the message ID and an optional response text.

var parentMessageId = "1772050244572";
var firstMessageId = "1772050244573";
var secondMessageId = "1772050244574";

// Single quote with response below it
var msg = new MessageActivity()
.AddQuote(parentMessageId, "Here is my response");
await app.Send(conversationId, msg);

// Multiple quotes with interleaved responses
msg = new MessageActivity()
.AddQuote(firstMessageId, "response to first")
.AddQuote(secondMessageId, "response to second");
await app.Send(conversationId, msg);

// Grouped quotes β€” omit response to group quotes together
msg = new MessageActivity("see below for previous messages")
.AddQuote(firstMessageId)
.AddQuote(secondMessageId, "response to both");
await app.Send(conversationId, msg);