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 site and hands your agent a small metadata attachment describing it, including a short-lived, pre-authorized URL in personal scopes for traditional bots, while agentic users must make a Graph call via the contentUrl. Reading the contents means fetching the bytes from either URL over the network, so the download itself is deferred until explicitly called.

This API covers file downloads in personal (1:1) chats. Today, channels and group chats are not explicitly supported by this API. See Conversation scope support.

Enable file support in your manifest for traditional bots

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
}
]

Enable file support on the blueprint for Agentic Users

Agentic Users need a Graph file permission consented on the agent's blueprint by an administrator in order to access files. See inheritable permissions for how an agent acquires those scopes.

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.
content_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")

The path is resolved relative to the process working directory, overwrites an existing file, and does not create missing parent directories.

warning

A file's name comes off the wire and is set by the uploader, so treat it as untrusted input rather than a destination. It may contain / or \, a drive or UNC prefix, or .. segments, and no sanitization is performed on the developer's behalf. Therefore, no assumptions are made related to the destination like illegal characters based on operating system or naming collisions.

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. All of them share a common base error, so a single check catches any of them and a new one can be added later without callers changing:

from microsoft_teams.apps import (
FileAccessError,
FileCredentialError,
FileError,
FileScopeNotSupportedError,
FileUrlExpiredError,
)
ErrorRaised whenCarries
File URL expiredThe short-lived, pre-authorized download link lapsed before the bytes were fetched.reason:
first fetch, the link had already lapsed so no bytes were retrieved;
re-read, an earlier download succeeded, but a later read through the same handle came after expiry. Avoid this by downloading once and reusing the downloaded file.
No Graph credentialThe Graph read an agentic user's file needs was ruled out before the request, because no credential was available.actor2: the identity a credential was being resolved for.
cause: why acquiring the token failed, when it failed rather than simply returning nothing.
File access deniedThe Graph read an agentic user's file needs was refused by the storage service.status: 401 when the token itself was rejected, 403 when the identity lacks the grant1.
actor2: the identity that was refused.
details: what the service said, if anything.
File scope not supportedDownload was called on a file from a conversation scope this API does not yet support, meaning anything other than personal (1:1).scope: the conversation scope.

1 A 403 deliberately collapses causes the SDK cannot differentiate: an unconsented permission, a file the identity was never granted, and an item that does not exist all arrive the same way. There is no separate "file not found" case to branch on; details carries the service's own text for diagnosis. A 401 is a different problem, which is why the status is reported rather than folded in: the token was rejected, so the fix is the token rather than the sharing.

2 actor names which identity the read was attempted as. The SDK chooses it, and today there are two:

  • Agentic user, chosen whenever the inbound activity carries an agentic identity. An agentic user reads as itself rather than as an app. Its Graph scopes come from its blueprint's inheritable permissions set by a tenant administrator or from a direct grant by a user with permissions to share it.
  • App, chosen for every other activity. Graph file retrieval is supported for agentic users, which read as their own identity; an app identity and user-delegated permissions may be used, but are not supported through the SDK at this time.

The actor is absent when the failure came before any identity was selected, which is the case when the app has no Graph route configured at all.

A transport or service failure, such as a Graph 5xx, is not one of these and surfaces as an ordinary error.

You may add error handling based on the type of error:

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 FileCredentialError as err:
await ctx.reply(f"Could not read that file as {err.actor or 'the identity used'}: no Graph credential was available.")
except FileAccessError as err:
await ctx.reply(f"Could not read that file as {err.actor or 'the identity used'}: the service returned {err.status}.")
except FileScopeNotSupportedError as err:
await ctx.reply(f"Downloading files from {err.scope} conversations is not supported yet.")
except FileError:
# Any future inbound-file failure lands here rather than escaping unhandled.
await ctx.reply("That file could not be read.")

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 (inline images, 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. For image attachments, see Receiving Inline Images.

Conversation scope support

Listing with list() surfaces the files Teams delivers on the incoming activity. Today that is the personal (1:1) chat path.

In channels, the attached file isn't delivered to a traditional bot on the activity (the attachment is dropped), so list() doesn't surface it. Group-chat behavior varies by how the file is stored. Agentic Users will have attachments on the activity but are not explicitly supported by the API today.

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 depends on the URL the platform sends, not on who is reading. A traditional bot receives a short-lived, pre-authorized download link, which the SDK fetches directly with no token attached.

An agentic user receives no pre-authorized link. Its attachment data carries only a contentUrl locating the item in OneDrive or SharePoint, so the SDK resolves the bytes through Microsoft Graph's /shares endpoint using the agent's own credential. The call behavior is unchanged from above; only the downloading route underneath differs.

If an Agentic User does not have sufficient permissions, the read fails with the file-retrieval error, which names the identity that was refused rather than reporting the file as missing.

Next steps