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. ctx.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 ctx.files.list() is always empty:

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

Access attached files​

The accessor ctx.files is available on every activity context, but only inbound message activities carry attachments, so it is empty everywhere else. Use list() to get all files attached to the current message, or first() 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:

@app.on_message
async def handle_message(ctx: ActivityContext[MessageActivity]) -> None:
attached = await ctx.files.list()

if not attached:
await ctx.reply("Send me a file and I will read it.")
return

names = ", ".join(f.name for f in attached)
await ctx.reply(f"You sent {len(attached)} file(s): {names}")

Listing with list() 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.

file = await ctx.files.first()

if file:
await ctx.reply(f"Reading {file.name}...")

File metadata​

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

PropertyDescription
unique_idThe 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 file_type. Absent when the platform omits it.
content_typeThe 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 content_type 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.
web_urlA 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 download(), which fetches the whole file and buffers it into an in-memory copy you own:

file = await ctx.files.first()

if file:
downloaded = await file.download()

await ctx.reply(f"Downloaded {downloaded.filename} ({len(downloaded.bytes)} bytes, {downloaded.content_type})")

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

file = await ctx.files.first()

if file:
contents = await file.text()
await ctx.reply(f"The file starts with: {contents[:100]}")
note

Decoding with text() 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 name (e.g. file.text("latin1")). For binary files, read the raw bytes instead.

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

await file.save_as("./downloads/report.pdf")

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

async for chunk in file.stream():
... # process each chunk (e.g. pipe to a parser)

An IncomingFile handle holds no cached bytes: each call to download(), text(), stream(), or save_as() 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 download() once and reuse the downloaded file it returns, which is a point-in-time copy whose readers never re-fetch:

downloaded = await file.download()

text = downloaded.text() # decode as UTF-8
data = downloaded.bytes # the raw bytes
await downloaded.save_as("./copy.bin") # write to disk, no re-fetch
Property / methodDescription
bytesThe buffered file bytes.
content_typeMIME 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.
source_urlThe URL the bytes were fetched from.
text(encoding="utf-8")Decode the bytes as UTF-8 (or a given encoding). Lossy; never throws.
save_as(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:

from microsoft_teams.apps import FileScopeNotSupportedError, FileUrlExpiredError
  • 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: download() 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:
downloaded = await file.download()
# ...
except FileUrlExpiredError as err:
if err.reason == "first_fetch":
await ctx.reply("That file link has expired before it could be read.")
except FileScopeNotSupportedError as err:
await ctx.reply(f"Downloading files from {err.scope} conversations is not supported yet.")

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:

file = await ctx.files.first()

if file:
# `raw` is the untyped wire attachment: the escape hatch when you need a
# field the typed surface does not expose. Here we log the whole attachment
# to inspect exactly what the platform sent.
ctx.logger.debug("raw file attachment: %s", file.raw)

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

Conversation scope support​

Listing with list() 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 list() 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 download(), stream(), text(), or save_as() 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 download() with the same behavior.

Next steps​