|
1 | 1 | # Support attachments with your agent built with XMTP |
2 | 2 |
|
3 | | -Use the remote attachment content type (`RemoteAttachmentCodec`) and a storage provider to send one remote attachment of any size. |
| 3 | +The Agent SDK package provides an XMTP content type (`RemoteAttachmentCodec`) to support sending file attachments through conversations. |
4 | 4 |
|
5 | 5 | ::::tip[Quickstart] |
6 | 6 | To learn more, see the [Attachment example](https://github.com/xmtplabs/xmtp-agent-examples/tree/main/examples/xmtp-attachments) in the xmtp-agent-examples repo. |
7 | 7 | :::: |
8 | 8 |
|
9 | | -The remote attachment content type is built into the Agent SDK. No installation is required. |
| 9 | +## How remote attachments work |
10 | 10 |
|
11 | | -## Send a remote attachment |
| 11 | +XMTP messages have a [maximum size limit](/chat-apps/intro/faq#does-xmtp-have-a-maximum-message-size). Files that exceed this limit can't be sent inline and are instead handled as **remote attachments**. For this, the file is encrypted, uploaded to an external storage provider, and a reference URL is sent in the message. The recipient then downloads and decrypts the file using the metadata from the message. |
12 | 12 |
|
13 | | -Load the file. This example uses a web browser to load the file: |
14 | | - |
15 | | -```tsx [Node] |
16 | | -//image is the uploaded event.target.files[0]; |
17 | | -const data = await new Promise((resolve, reject) => { |
18 | | - const reader = new FileReader(); |
19 | | - reader.onload = () => { |
20 | | - if (reader.result instanceof ArrayBuffer) { |
21 | | - resolve(reader.result); |
22 | | - } else { |
23 | | - reject(new Error('Not an ArrayBuffer')); |
24 | | - } |
25 | | - }; |
26 | | - reader.readAsArrayBuffer(image); |
27 | | -}); |
28 | | -``` |
| 13 | +This approach keeps messages lightweight while supporting files of any size. To send an attachment, you need three things: |
29 | 14 |
|
30 | | -Create an attachment object: |
| 15 | +1. **A file** to attach (image, document, etc.) |
| 16 | +2. **A storage provider** to host the encrypted file (any service that supports HTTPS GET requests) |
| 17 | +3. **An upload callback** that tells the SDK how to upload the encrypted bytes and return a download URL |
31 | 18 |
|
32 | | -```tsx [Node] |
33 | | -// Local file details |
34 | | -const attachment = { |
35 | | - filename: image?.name, |
36 | | - mimeType: image?.type, |
37 | | - data: new Uint8Array(data), |
38 | | -}; |
39 | | -``` |
| 19 | +### IPFS as a storage provider |
40 | 20 |
|
41 | | -Use `encryptAttachment` to encrypt an attachment: |
| 21 | +[IPFS](https://en.wikipedia.org/wiki/InterPlanetary_File_System) (InterPlanetary File System) is a decentralized storage network that's a natural fit for XMTP attachments. Instead of relying on a single server, IPFS distributes files across a peer-to-peer network. In IPFS, every file receives a unique [content identifier](https://docs.pinata.cloud/ipfs-101/what-are-cids) (CID) derived from its contents. This ensures data integrity, enables decentralized serving across multiple nodes, and guarantees availability as long as at least one node pins the file. |
42 | 22 |
|
43 | | -```tsx [Node] |
44 | | -import { encryptAttachment } from '@xmtp/agent-sdk'; |
| 23 | +In practice, most developers use an **IPFS pinning service** like [Pinata](https://pinata.cloud/) or [Filebase](https://filebase.com/) rather than running their own IPFS node. Pinning services provide a simple API to upload files and a reliable gateway to serve them over HTTPS, which is exactly what the XMTP remote attachment flow needs. |
45 | 24 |
|
46 | | -const encryptedAttachment = await encryptAttachment(attachment); |
| 25 | +The good news is that the XMTP Agent SDK handles the encryption, metadata, and decryption for you. You just have to provide a callback that uploads the encrypted file and returns a download URL. |
| 26 | + |
| 27 | +## Set up Pinata as your storage provider |
| 28 | + |
| 29 | +All the examples below use [Pinata](https://pinata.cloud/) as the IPFS storage provider. First, install the [Pinata SDK](https://www.npmjs.com/package/pinata): |
| 30 | + |
| 31 | +```bash |
| 32 | +npm install pinata |
47 | 33 | ``` |
48 | 34 |
|
49 | | -Upload an encrypted attachment to a location where it will be accessible via an HTTPS GET request. This location will depend on which storage provider you use based on your needs. |
| 35 | +You'll need two environment variables from your [Pinata dashboard](https://app.pinata.cloud/): |
50 | 36 |
|
51 | | -Now that you have a `url`, you can create a `RemoteAttachment`: |
| 37 | +1. `PINATA_JWT` — your API authentication token |
| 38 | +2. `PINATA_GATEWAY` — your dedicated gateway URL (e.g., `your-gateway.mypinata.cloud`) |
52 | 39 |
|
53 | | -```tsx [Node] |
54 | | -import type { RemoteAttachment } from '@xmtp/agent-sdk'; |
| 40 | +## Create a file to send |
55 | 41 |
|
56 | | -const remoteAttachment: RemoteAttachment = { |
57 | | - url: url, |
58 | | - contentDigest: encryptedAttachment.contentDigest, |
59 | | - salt: encryptedAttachment.salt, |
60 | | - nonce: encryptedAttachment.nonce, |
61 | | - secret: encryptedAttachment.secret, |
62 | | - scheme: 'https://', |
63 | | - filename: encryptedAttachment.filename, |
64 | | - contentLength: encryptedAttachment.contentLength, |
65 | | -}; |
| 42 | +You can send any file as an attachment. Here's an example that programmatically creates a PNG image using the [canvas](https://www.npmjs.com/package/canvas) library: |
| 43 | + |
| 44 | +```bash |
| 45 | +npm install canvas |
66 | 46 | ``` |
67 | 47 |
|
68 | | -Now that you have a remote attachment, you can send it: |
| 48 | +```ts |
| 49 | +import { createCanvas } from 'canvas'; |
| 50 | + |
| 51 | +const createImageFile = () => { |
| 52 | + const canvas = createCanvas(400, 300); |
| 53 | + const canvasCtx = canvas.getContext('2d'); |
69 | 54 |
|
70 | | -```tsx [Node] |
71 | | -await ctx.conversation.sendRemoteAttachment(remoteAttachment); |
| 55 | + canvasCtx.fillStyle = 'blue'; |
| 56 | + canvasCtx.fillRect(0, 0, 400, 300); |
| 57 | + canvasCtx.fillStyle = 'white'; |
| 58 | + canvasCtx.font = '30px Arial'; |
| 59 | + canvasCtx.fillText('Hello XMTP!', 100, 150); |
| 60 | + |
| 61 | + const buffer = canvas.toBuffer('image/png'); |
| 62 | + return new File([new Uint8Array(buffer)], 'hello-xmtp.png', { |
| 63 | + type: 'image/png', |
| 64 | + }); |
| 65 | +}; |
72 | 66 | ``` |
73 | 67 |
|
74 | | -## Receive, decode, and decrypt a remote attachment |
| 68 | +This produces a simple blue image with white text. In a real application, this would be any [File](https://developer.mozilla.org/docs/Web/API/File) object. |
75 | 69 |
|
76 | | -Now that you can send a remote attachment, you need a way to receive it. For example: |
| 70 | +## Send a remote attachment |
77 | 71 |
|
78 | | -```ts [Node] |
79 | | -import { decryptAttachment } from '@xmtp/agent-sdk'; |
80 | | -import type { RemoteAttachment } from '@xmtp/agent-sdk'; |
| 72 | +Use `ctx.sendRemoteAttachment` to send a file as an encrypted remote attachment. You provide the file and an upload callback that handles storing the encrypted bytes: |
| 73 | + |
| 74 | +```ts |
| 75 | +import { CommandRouter, type AttachmentUploadCallback } from '@xmtp/agent-sdk'; |
| 76 | +import { PinataSDK } from 'pinata'; |
| 77 | + |
| 78 | +const router = new CommandRouter(); |
| 79 | + |
| 80 | +router.command('/send-image', async (ctx) => { |
| 81 | + const file = createImageFile(); |
| 82 | + |
| 83 | + const uploadCallback: AttachmentUploadCallback = async (attachment) => { |
| 84 | + const pinata = new PinataSDK({ |
| 85 | + pinataJwt: `${process.env.PINATA_JWT}`, |
| 86 | + pinataGateway: `${process.env.PINATA_GATEWAY}`, |
| 87 | + }); |
| 88 | + |
| 89 | + const mimeType = 'application/octet-stream'; |
| 90 | + const encryptedBlob = new Blob([Buffer.from(attachment.payload)], { |
| 91 | + type: mimeType, |
| 92 | + }); |
| 93 | + const encryptedFile = new File( |
| 94 | + [encryptedBlob], |
| 95 | + attachment.filename || 'untitled', |
| 96 | + { |
| 97 | + type: mimeType, |
| 98 | + } |
| 99 | + ); |
| 100 | + const upload = await pinata.upload.public.file(encryptedFile); |
| 101 | + |
| 102 | + return pinata.gateways.public.convert(`${upload.cid}`); |
| 103 | + }; |
81 | 104 |
|
82 | | -if (ctx.isRemoteAttachment()) { |
83 | | - const remoteAttachment: RemoteAttachment = ctx.message.content; |
84 | | - // Download encrypted bytes from remoteAttachment.url |
85 | | - const encryptedBytes = await fetch(remoteAttachment.url).then(r => r.arrayBuffer()); |
86 | | - const attachment = await decryptAttachment( |
87 | | - new Uint8Array(encryptedBytes), |
88 | | - remoteAttachment |
89 | | - ); |
90 | | -} |
| 105 | + await ctx.sendRemoteAttachment(file, uploadCallback); |
| 106 | +}); |
91 | 107 | ``` |
92 | 108 |
|
93 | | -You now have the original attachment: |
| 109 | +Here's what happens under the hood when you call `sendRemoteAttachment`: |
94 | 110 |
|
95 | | -```ts [Node] |
96 | | -attachment.filename; // => "screenshot.png" |
97 | | -attachment.mimeType; // => "image/png", |
98 | | -attachment.data; // => [the PNG data] |
99 | | -``` |
| 111 | +1. The SDK encrypts the file contents |
| 112 | +2. Your `uploadCallback` receives the encrypted payload and uploads it to Pinata's IPFS network |
| 113 | +3. Pinata returns a CID, which is converted to a gateway URL |
| 114 | +4. The SDK sends a message containing the URL and decryption metadata (salt, nonce, secret, content digest) |
| 115 | + |
| 116 | +The `attachment.payload` contains the already-encrypted bytes, so what gets stored on IPFS is unreadable without the decryption keys, which are only shared within the XMTP message. |
| 117 | + |
| 118 | +## Receive and decrypt a remote attachment |
100 | 119 |
|
101 | | -Once you've created the attachment object, you can create a preview to show in the message input field before sending: |
| 120 | +When your agent receives an attachment, use `downloadRemoteAttachment` to download and decrypt it in one step: |
102 | 121 |
|
103 | | -```tsx [Node] |
104 | | -const objectURL = URL.createObjectURL( |
105 | | - new Blob([Buffer.from(attachment.data)], { |
106 | | - type: attachment.mimeType, |
107 | | - }) |
108 | | -); |
| 122 | +```ts |
| 123 | +import { downloadRemoteAttachment } from '@xmtp/agent-sdk/util'; |
109 | 124 |
|
110 | | -const img = document.createElement('img'); |
111 | | -img.src = objectURL; |
112 | | -img.title = attachment.filename; |
| 125 | +agent.on('attachment', async (ctx) => { |
| 126 | + const receivedAttachment = await downloadRemoteAttachment( |
| 127 | + ctx.message.content |
| 128 | + ); |
| 129 | + console.log(`Received: ${receivedAttachment.filename}`); |
| 130 | + console.log(`Type: ${receivedAttachment.mimeType}`); |
| 131 | + // receivedAttachment.content contains the decrypted file bytes |
| 132 | +}); |
113 | 133 | ``` |
| 134 | + |
| 135 | +The `downloadRemoteAttachment` utility handles fetching the encrypted bytes from the remote URL and decrypting them using the metadata from the message. You get back the original file with its filename, MIME type, and data. |
0 commit comments