Skip to content

Support attachments in your app built with XMTP

Use the remote attachment, multiple remote attachments, or attachment content type to support attachments in your app.

One remote attachment of any size can be sent in a message using the RemoteAttachmentCodec and a storage provider.

To send multiple remote attachments of any size in a single message, see Support multiple remote attachments of any size.

XMTP messages have a maximum size limit. 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.

This approach keeps messages lightweight while supporting files of any size. To send an attachment, you need three things:

  1. A file to attach (image, document, etc.)
  2. A storage provider to host the encrypted file (any service that supports HTTPS GET requests)
  3. An upload callback that tells the SDK how to upload the encrypted bytes and return a download URL

The SDK encrypts the attachment before the upload callback runs. The host stores an encoded Attachment, not the raw file.

PropertyValue
CipherAES-256-GCM
Key derivationHKDF-SHA256 with a random 32-byte secret and 32-byte salt
NonceRandom, 12 bytes
Authentication tag16 bytes, appended to the ciphertext
IntegrityHex SHA-256 contentDigest of the encrypted bytes

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.

Type ID: xmtp.org/attachment:1.0. Its fallback is Can't display <filename>. This app doesn't support attachments.. shouldPush defaults to true on all four platforms.

FieldTypeWire locationRequired
filenamestringfilename parameterNo
mimeTypestringmimeType parameterYes
contentbytesMessage contentYes

Type ID: xmtp.org/remoteStaticAttachment:1.0. Its fallback names the unsupported file. shouldPush defaults to true on all four platforms.

FieldTypeWire locationRequired
urlstringMessage content, UTF-8Yes
contentDigesthex SHA-256contentDigest parameterYes
secrethex string, 32 bytessecret parameterYes
salthex string, 32 bytessalt parameterYes
noncehex string, 12 bytesnonce parameterYes
schemestringscheme parameterYes when encoding
contentLengthintegercontentLength parameter; encrypted payload lengthNo
filenamestringfilename parameterNo

Type ID: xmtp.org/multiRemoteStaticAttachment:1.0. The payload is a MultiRemoteAttachment protobuf with repeated RemoteAttachmentInfo entries. The fallback says that the app does not support multiple remote attachments. shouldPush defaults to true on all four platforms.

Each RemoteAttachmentInfo contains url, contentDigest, secret, salt, nonce, scheme, optional encrypted contentLength, and optional filename. Each entry has separate encryption material.

Each attachment in the attachments array contains a URL that points to an encrypted EncodedContent object. The content must be accessible by an HTTP GET request to the URL.

Support multiple remote attachments of any size

Section titled “Support multiple remote attachments of any size”

Multiple remote attachments of any size can be sent in a single message using the MultiRemoteAttachmentCodec and a storage provider.

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:

import {
class CommandRouter<ContentTypes = unknown>

Routes slash commands and unmatched text to agent handlers.

CommandRouter
, type
type AttachmentUploadCallback = (attachment: EncryptedAttachment) => Promise<string>

Uploads encrypted attachment bytes and returns their public URL.

AttachmentUploadCallback
} from '@xmtp/agent-sdk';
import {
class PinataSDK
PinataSDK
} from 'pinata';
const
const router: CommandRouter<unknown>
router
= new
new CommandRouter<unknown>(config?: CommandRouterConfig): CommandRouter<unknown>

Create a router. A help command is registered when configured.

CommandRouter
();
const router: CommandRouter<unknown>
router
.
CommandRouter<unknown>.command(command: string, handler: AgentMessageHandler<string>): CommandRouter<unknown> (+1 overload)

Register a slash command. Commands are matched case-insensitively.

command
('/send-image', async (
ctx: MessageContext<string, unknown>
ctx
) => {
const
const file: File
file
=
function createImageFile(): File
createImageFile
();
const
const uploadCallback: AttachmentUploadCallback
uploadCallback
:
type AttachmentUploadCallback = (attachment: EncryptedAttachment) => Promise<string>

Uploads encrypted attachment bytes and returns their public URL.

AttachmentUploadCallback
= async (
attachment: EncryptedAttachment
attachment
) => {
const
const pinata: PinataSDK
pinata
= new
new PinataSDK(config?: PinataConfig): PinataSDK
PinataSDK
({
pinataJwt?: string | undefined
pinataJwt
: `${
var process: NodeJS.Process
process
.
NodeJS.Process.env: NodeJS.ProcessEnv

The process.env property returns an object containing the user environment. See environ(7).

An example of this object looks like:

{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}

It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other Worker threads. In other words, the following example would not work:

Terminal window
node -e 'process.env.foo = "bar"' &#x26;&#x26; echo $foo

While the following will:

import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);

Assigning a property on process.env will implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.

import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'

Use delete to delete a property from process.env.

import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined

On Windows operating systems, environment variables are case-insensitive.

import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1

Unless explicitly specified when creating a Worker instance, each Worker thread has its own copy of process.env, based on its parent thread's process.env, or whatever was specified as the env option to the Worker constructor. Changes to process.env will not be visible across Worker threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of process.env on a Worker instance operates in a case-sensitive manner unlike the main thread.

@sincev0.1.27

env
.
string | undefined
PINATA_JWT
}`,
pinataGateway?: string | undefined
pinataGateway
: `${
var process: NodeJS.Process
process
.
NodeJS.Process.env: NodeJS.ProcessEnv

The process.env property returns an object containing the user environment. See environ(7).

An example of this object looks like:

{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}

It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other Worker threads. In other words, the following example would not work:

Terminal window
node -e 'process.env.foo = "bar"' &#x26;&#x26; echo $foo

While the following will:

import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);

Assigning a property on process.env will implicitly convert the value to a string. This behavior is deprecated. Future versions of Node.js may throw an error when the value is not a string, number, or boolean.

import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'

Use delete to delete a property from process.env.

import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined

On Windows operating systems, environment variables are case-insensitive.

import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1

Unless explicitly specified when creating a Worker instance, each Worker thread has its own copy of process.env, based on its parent thread's process.env, or whatever was specified as the env option to the Worker constructor. Changes to process.env will not be visible across Worker threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of process.env on a Worker instance operates in a case-sensitive manner unlike the main thread.

@sincev0.1.27

env
.
string | undefined
PINATA_GATEWAY
}`,
});
const
const mimeType: "application/octet-stream"
mimeType
= 'application/octet-stream';
const
const encryptedBlob: Blob
encryptedBlob
= new
var Blob: new (blobParts?: BlobPart[], options?: BlobPropertyBag) => Blob

The Blob interface represents a blob, which is a file-like object of immutable, raw data; they can be read as text or binary data, or converted into a ReadableStream so its methods can be used for processing the data.

MDN Reference

Blob class is a global reference for import { Blob } from 'node:buffer' https://nodejs.org/api/buffer.html#class-blob

@sincev18.0.0

Blob
([
var Buffer: BufferConstructor
Buffer
.
BufferConstructor.from(array: WithImplicitCoercion<ArrayLike<number>>): Buffer<ArrayBuffer> (+3 overloads)

Allocates a new Buffer using an array of bytes in the range 0255. Array entries outside that range will be truncated to fit into it.

import { Buffer } from 'node:buffer';
// Creates a new Buffer containing the UTF-8 bytes of the string 'buffer'.
const buf = Buffer.from([0x62, 0x75, 0x66, 0x66, 0x65, 0x72]);

If array is an Array-like object (that is, one with a length property of type number), it is treated as if it is an array, unless it is a Buffer or a Uint8Array. This means all other TypedArray variants get treated as an Array. To create a Buffer from the bytes backing a TypedArray, use Buffer.copyBytesFrom().

A TypeError will be thrown if array is not an Array or another type appropriate for Buffer.from() variants.

Buffer.from(array) and Buffer.from(string) may also use the internal Buffer pool like Buffer.allocUnsafe() does.

@sincev5.10.0

from
(
attachment: EncryptedAttachment
attachment
.
EncryptedAttachment.payload: Uint8Array<ArrayBufferLike>

The encrypted bytes to upload to the remote server

payload
)], {
BlobPropertyBag.type?: string | undefined
type
:
const mimeType: "application/octet-stream"
mimeType
,
});
const
const encryptedFile: File
encryptedFile
= new
var File: new (fileBits: BlobPart[], fileName: string, options?: FilePropertyBag) => File

The File interface provides information about files and allows JavaScript in a web page to access their content.

MDN Reference

File class is a global reference for import { File } from 'node:buffer' https://nodejs.org/api/buffer.html#class-file

@sincev20.0.0

File
(
[
const encryptedBlob: Blob
encryptedBlob
],
attachment: EncryptedAttachment
attachment
.
EncryptedAttachment.filename?: string | undefined

The filename of the attachment

filename
|| 'untitled',
{
BlobPropertyBag.type?: string | undefined
type
:
const mimeType: "application/octet-stream"
mimeType
,
},
);
const
const upload: UploadResponse
upload
= await
const pinata: PinataSDK
pinata
.
PinataSDK.upload: Upload
upload
.
Upload.public: PublicUpload
public
.
PublicUpload.file(file: File, options?: UploadOptions): UploadBuilder<UploadResponse>
file
(
const encryptedFile: File
encryptedFile
);
return
const pinata: PinataSDK
pinata
.
PinataSDK.gateways: Gateways
gateways
.
Gateways.public: PublicGateways
public
.
PublicGateways.convert(url: string, gatewayPrefix?: string): Promise<string>
convert
(`${
const upload: UploadResponse
upload
.
cid: string
cid
}`);
};
await
ctx: MessageContext<string, unknown>
ctx
.
ConversationContext<unknown, Conversation<unknown>>.sendRemoteAttachment(unencryptedFile: File, uploadCallback: AttachmentUploadCallback): Promise<void>

Encrypt and send a remote attachment through the supplied upload callback.

sendRemoteAttachment
(
const file: File
file
,
const uploadCallback: AttachmentUploadCallback
uploadCallback
);
});

Here’s what happens under the hood when you call sendRemoteAttachment:

  1. The SDK encrypts the file contents
  2. Your uploadCallback receives the encrypted payload and uploads it to Pinata’s IPFS network
  3. Pinata returns a CID, which is converted to a gateway URL
  4. The SDK sends a message containing the URL and decryption metadata (salt, nonce, secret, content digest)

When your agent receives an attachment, use downloadRemoteAttachment to download and decrypt it in one step:

import {
function downloadRemoteAttachment(remoteAttachment: RemoteAttachment): Promise<Attachment>

Downloads and decrypts a remote attachment.

@paramremoteAttachment - The remote attachment metadata containing the downloadd URL and encryption keys

@returnsA promise that resolves with the decrypted attachment

downloadRemoteAttachment
} from '@xmtp/agent-sdk/util';
const agent: Agent<BuiltInContentTypes>
agent
.
NodeJS.EventEmitter<EventHandlerMap<BuiltInContentTypes>>.on<"attachment">(eventName: keyof EventHandlerMap<BuiltInContentTypes>, listener: (ctx: MessageContext<RemoteAttachment, BuiltInContentTypes>) => void): Agent<BuiltInContentTypes>

Adds the listener function to the end of the listeners array for the event named eventName. No checks are made to see if the listener has already been added. Multiple calls passing the same combination of eventName and listener will result in the listener being added, and called, multiple times.

server.on('connection', (stream) => {
console.log('someone connected!');
});

Returns a reference to the EventEmitter, so that calls can be chained.

By default, event listeners are invoked in the order they are added. The emitter.prependListener() method can be used as an alternative to add the event listener to the beginning of the listeners array.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('attachment', async (
ctx: MessageContext<RemoteAttachment, BuiltInContentTypes>
ctx
) => {
const
const receivedAttachment: Attachment
receivedAttachment
= await
function downloadRemoteAttachment(remoteAttachment: RemoteAttachment): Promise<Attachment>

Downloads and decrypts a remote attachment.

@paramremoteAttachment - The remote attachment metadata containing the downloadd URL and encryption keys

@returnsA promise that resolves with the decrypted attachment

downloadRemoteAttachment
(
ctx: MessageContext<RemoteAttachment, BuiltInContentTypes>
ctx
.
MessageContext<RemoteAttachment, BuiltInContentTypes>.message: DecodedMessageWithContent<RemoteAttachment>

Return the decoded message.

message
.
content: RemoteAttachment

The decoded content after the presence check.

content
,
);
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(`Received: ${
const receivedAttachment: Attachment
receivedAttachment
.
Attachment.filename?: string | undefined
filename
}`);
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@sincev0.1.100

log
(`Type: ${
const receivedAttachment: Attachment
receivedAttachment
.
Attachment.mimeType: string
mimeType
}`);
// receivedAttachment.content contains the decrypted file bytes
});

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.

An encoded envelope is capped at 1 MiB. MLS framing uses part of this limit. Use a remote attachment for a file near the cap.

To handle unsupported content types, refer to the fallback section.

See Fallback and unsupported content.