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 ({ files, send }) => {
const attached = await files.list();
if (attached.length === 0) {
await send('Send me a file and I will read it.');
return;
}
await send(`You sent ${attached.length} file(s): ${attached.map((f) => f.name).join(', ')}`);
});
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.
const file = await files.first();
if (file) {
await send(`Reading ${file.name}...`);
}
File metadata​
Each IIncomingFile carries metadata describing the file, populated from what the platform reports:
| Property | Description |
|---|---|
uniqueId | The OneDrive/SharePoint drive-item id, when the platform provides it. Present only for files backed by ODSP storage. |
name | File name including its extension (e.g. report.pdf). |
extension | File extension without the dot (e.g. pdf), from the platform-supplied fileType. Absent when the platform omits it. |
contentType | The 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. |
scope | The conversation scope the file arrived in (personal, groupChat, or channel). |
source | Where the SDK found the file. Currently always botActivity. |
webUrl | A browsable link to the file in OneDrive/SharePoint, when known. Not a fetchable download URL. |
raw | The 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:
const file = await files.first();
if (file) {
const downloaded = await file.download();
await send(`Downloaded ${downloaded.filename} (${downloaded.bytes.length} bytes, ${downloaded.contentType})`);
}
For text files, text() is a shortcut for download() followed by decoding:
const file = await files.first();
if (file) {
const contents = await file.text();
await send(`The file starts with: ${contents.slice(0, 100)}`);
}
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 saveAs(), which streams straight to the path:
await file.saveAs('./downloads/report.pdf');
For large files or streaming pipelines, stream() hands you the raw byte stream so you can process it as it arrives:
const stream = await file.stream();
for await (const chunk of stream) {
// process each chunk (e.g. pipe to a parser)
}
An IIncomingFile handle holds no cached bytes: each call to download(), text(), stream(), or saveAs() 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:
const downloaded = await file.download();
const text = downloaded.text(); // decode as UTF-8
const buffer = downloaded.arrayBuffer(); // the raw bytes
await downloaded.saveAs('./copy.bin'); // write to disk, no re-fetch
| Property / method | Description |
|---|---|
bytes | The buffered file bytes as a Uint8Array. |
contentType | MIME 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. |
filename | The resolved file name. |
sourceUrl | The URL the bytes were fetched from. |
text(encoding?) | Decode the bytes as UTF-8 (or a given encoding). Lossy; never throws. |
arrayBuffer() | Return the bytes as an ArrayBuffer. |
saveAs(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:
import { FileScopeNotSupportedError, FileUrlExpiredError } from '@microsoft/teams.apps';
- 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 {
const downloaded = await file.download();
// ...
} catch (err) {
if (err instanceof FileUrlExpiredError && err.reason === 'firstFetch') {
await send('That file link has expired before it could be read.');
} else if (err instanceof FileScopeNotSupportedError) {
await send(`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:
const file = await 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.
log.debug('raw file attachment', 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 IIncomingFile. 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 saveAs() 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​
- See Files in Teams bots for the platform-side file model.