Skip to main content

Receiving Files

Teams users can send files to agents by attaching them to chat messages. When users send files to an agent in 1:1 chat, they arrive as part of the context of the received message activity. context.Files provides lazy access to the metadata and bytes of each attached file.

An attached file is not delivered inline with the message. Teams stores the file in the sender's OneDrive or SharePoint and hands your agent a small metadata attachment describing it, including a short-lived, pre-authorized URL in personal scopes. Reading the contents means fetching the bytes from that URL over the network, so the SDK defers that work until you ask for it.

This API covers file downloads in personal (1:1) chats. In channels, the attached file isn't delivered to the bot on the activity, so those files follow a separate, Microsoft Graph-based retrieval path. Group-chat behavior varies by how the file is stored. See Conversation scope support.

Enable file support in your manifest

Teams only offers users the option to attach a file in 1:1 with a bot when your app manifest opts in. Set supportsFiles to true on your bot entry; without it the compose box shows no attach affordance for your agent, no file reaches your handler, and context.Files.ListAsync() is always empty:

"bots": [
{
"botId": "${{BOT_ID}}",
"scopes": ["personal"],
"supportsFiles": true
}
]

Access attached files

The accessor context.Files is available on every activity context, but only inbound message activities carry attachments, so it is empty everywhere else. Use ListAsync() to get all files attached to the current message, or FirstAsync() for the common single-file case. Both hand back the file's metadata rather than its contents, so listing is cheap even when the attached files are large:

teamsApp.OnMessage(async (context, cancellationToken) =>
{
IList<IncomingFile> attached = await context.Files.ListAsync(cancellationToken);

if (attached.Count == 0)
{
await context.ReplyAsync("Send me a file and I will read it.", cancellationToken);
return;
}

string names = string.Join(", ", attached.Select(f => f.Name));
await context.ReplyAsync($"You sent {attached.Count} file(s): {names}", cancellationToken);
});

Listing with ListAsync() returns the attached files in the same order they arrived. It never throws: activities with no attachments (or non-message activities) return an empty list, and malformed file entries are skipped rather than failing the whole batch.

IncomingFile? file = await context.Files.FirstAsync(cancellationToken);

if (file is not null)
{
await context.ReplyAsync($"Reading {file.Name}...", cancellationToken);
}

File metadata

Each IncomingFile carries metadata describing the file, populated from what the platform reports:

PropertyDescription
UniqueIdThe OneDrive/SharePoint drive-item id, when the platform provides it. Present only for files backed by ODSP storage.
NameFile name including its extension (e.g. report.pdf).
ExtensionFile extension without the dot (e.g. pdf), from the platform-supplied fileType. Absent when the platform omits it.
ContentTypeThe file's MIME type, when the source provides one. Always unset for files received from a bot activity (every file today): the file.download.info attachment carries no MIME type, only the extension surfaced as Extension. To learn the type of the bytes you actually received, read ContentType on the downloaded file, which is resolved from the download response.
ScopeThe conversation scope the file arrived in (personal, groupChat, or channel).
SourceWhere the SDK found the file. Currently always botActivity.
WebUrlA browsable link to the file in OneDrive/SharePoint, when known. Not a fetchable download URL.
RawThe original wire attachment (the metadata object, not the bytes) — see Access the raw attachment.

Read a file

The most common path is DownloadAsync(), which fetches the whole file and buffers it into an in-memory copy you own:

IncomingFile? file = await context.Files.FirstAsync(cancellationToken);

if (file is not null)
{
DownloadedFile downloaded = await file.DownloadAsync(cancellationToken);

await context.ReplyAsync($"Downloaded {downloaded.Filename} ({downloaded.Bytes.Length} bytes, {downloaded.ContentType})", cancellationToken);
}

For text files, TextAsync() is a shortcut for DownloadAsync() followed by decoding:

IncomingFile? file = await context.Files.FirstAsync(cancellationToken);

if (file is not null)
{
string contents = await file.TextAsync(cancellationToken: cancellationToken);
await context.ReplyAsync($"The file starts with: {contents[..Math.Min(100, contents.Length)]}", cancellationToken);
}
note

Decoding with TextAsync() reads the downloaded bytes as UTF-8 by default, and is lossy: bytes that are not valid for the encoding become the replacement character (U+FFFD) instead of throwing. To decode a different encoding, pass an Encoding (e.g. file.TextAsync(Encoding.Latin1)). For binary files, read the raw bytes instead.

To save a file to disk without holding it all in memory, use SaveAsAsync(), which streams straight to the path:

await file.SaveAsAsync("./downloads/report.pdf", cancellationToken);

For large files or streaming pipelines, StreamAsync() hands you the raw byte stream so you can process it as it arrives:

await using Stream stream = await file.StreamAsync(cancellationToken);

byte[] buffer = new byte[8192];
int read;
while ((read = await stream.ReadAsync(buffer, cancellationToken)) > 0)
{
// process only the bytes read this iteration (e.g. pipe to a parser)
ReadOnlyMemory<byte> chunk = buffer.AsMemory(0, read);
}

An IncomingFile handle holds no cached bytes: each call to DownloadAsync(), TextAsync(), StreamAsync(), or SaveAsAsync() fetches the file again. This matters because a file's download link is short-lived: the URL embeds its own pre-authorized tempauth credential, which can expire between reads. To read the same file several ways, call DownloadAsync() once and reuse the downloaded file it returns, which is a point-in-time copy whose readers never re-fetch:

DownloadedFile downloaded = await file.DownloadAsync(cancellationToken);

string text = downloaded.Text(); // decode as UTF-8
byte[] bytes = downloaded.Bytes; // the raw bytes
await downloaded.SaveAsAsync("./copy.bin", cancellationToken); // write to disk, no re-fetch
Property / methodDescription
BytesThe buffered file bytes as a byte[].
ContentTypeMIME type resolved from the download response header, or the incoming file's metadata type if the response omits one. Falls back to application/octet-stream when neither provides a type, so this is never empty.
FilenameThe resolved file name.
SourceUrlThe URL the bytes were fetched from.
Text(encoding?)Decode the bytes as UTF-8 (or a given encoding). Lossy; never throws.
SaveAsAsync(path)Write the buffered bytes to a local path (no re-fetch).

Handle errors

Byte retrieval can fail for a few well-defined reasons. Both error types are exported from the SDK:

using Microsoft.Teams.Apps.Files;
  • File URL expired: the file's short-lived download link expired before the bytes could be fetched. Its reason distinguishes the two cases:
    • first fetch: the link had already lapsed on the first read attempt, so no bytes were retrieved.
    • re-read: an earlier download succeeded, but a later re-read through the same handle came too late. Avoid this by downloading once and reusing the downloaded file.
  • File scope not supported: DownloadAsync() was called on a file from a conversation scope this API doesn't fetch bytes for (anything other than personal 1:1). Exposes the scope.
try
{
DownloadedFile downloaded = await file.DownloadAsync(cancellationToken);
// ...
}
catch (FileUrlExpiredException err) when (err.Reason == FileUrlExpiredReason.FirstFetch)
{
await context.ReplyAsync("That file link has expired before it could be read.", cancellationToken);
}
catch (FileScopeNotSupportedException err)
{
await context.ReplyAsync($"Downloading files from {err.Scope} conversations is not supported yet.", cancellationToken);
}

Access the raw attachment

Every file the accessor returns retains its original wire attachment on Raw, for diagnostics or for the rare case where you need a field the typed surface does not expose:

using System.Text.Json;

IncomingFile? file = await context.Files.FirstAsync(cancellationToken);

if (file is not null)
{
// `Raw` is the untyped wire attachment: the escape hatch when you need a
// field the typed surface does not expose. Here we serialize the whole
// attachment to inspect exactly what the platform sent.
string wire = JsonSerializer.Serialize(file.Raw);
await context.ReplyAsync($"raw attachment: {wire}", cancellationToken);
}

Attachments that are not files (Adaptive Cards, mentions, link previews), and malformed file entries the accessor skips, are not returned by ListAsync() and therefore have no IncomingFile. Reach those directly through context.Activity.Attachments.

Conversation scope support

Listing with ListAsync() surfaces the files Teams delivers on the incoming activity. Today that is the personal (1:1) chat path, where the file arrives with a short-lived, pre-authorized download link.

In channels, the attached file isn't delivered to the bot on the activity (Teams drops the attachment), so ListAsync() doesn't surface it; channel files follow a separate, Microsoft Graph-based retrieval path. Group-chat behavior varies by how the file is stored. When DownloadAsync(), StreamAsync(), TextAsync(), or SaveAsAsync() is called on a surfaced file whose scope isn't personal (1:1), it raises the file-scope-not-supported error.

Downloading in personal (1:1) chats is identity-agnostic: the file arrives with a pre-authorized link that needs no token, so a traditional bot and an agentic user call the same DownloadAsync() with the same behavior.

Next steps