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.

Use a remote attachment to send a file stored outside the message. The backend must offer attachment storage for SDK-managed uploads. An app can also use its own storage through an upload callback.

To send several remote attachments in one message, see Support multiple remote attachments.

The SDK has built-in file transfers through client.attachments on Browser and Node, and client.attachments() on Kotlin and Swift. These calls encrypt, stage, upload, verify, and decrypt files. An upload callback is not required. The Agent SDK uses the same transfer service for ctx.sendRemoteAttachment.

For uploads, the operator must configure attachment storage. Check client.attachments.offered on Browser and Node or client.attachments().offered() on Kotlin and Swift before you show the upload action. Downloads can use any permitted HTTPS host, including another deployment or app-owned storage. They do not require an attachment offer from the recipient’s backend.

  1. Call create with a path or bytes, a MIME type, and an optional filename.
  2. Get the RemoteAttachment from the returned pending attachment.
  3. Send that record with RemoteAttachmentCodec.
  4. Call pending.upload() and handle its result.

Creation reads the source once and stores both a local plaintext file and staged ciphertext. It returns before any upload request. After creation, the source can move or be removed without affecting the upload. Prefer a path source for large native files. Path creation and transfers stream the file; a byte source already holds the file in application memory.

The following examples use an existing client and group. The path must name a readable file in the application’s filesystem. Browser paths name OPFS entries.

await
function initPureWasm(wasm?: URL): Promise<void>
initPureWasm
();
const
const attachments: Attachments
attachments
=
client: Client
client
.
ClientMembers.attachments: Attachments
attachments
;
if (!
const attachments: Attachments
attachments
.
Attachments.offered: boolean
offered
) {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("The backend does not offer attachments");
}
const
const pending: PendingAttachment
pending
= await
const attachments: Attachments
attachments
.
Attachments.create(source: AttachmentSource): Promise<PendingAttachment>
create
({
kind: "path"
kind
: "path",
path: string
path
,
filename?: string | undefined
filename
: "photo.jpg",
mimeType: string
mimeType
: "image/jpeg",
});
const
const remote: RemoteAttachment
remote
=
const pending: PendingAttachment
pending
.
PendingAttachment.remoteAttachment: RemoteAttachment
remoteAttachment
;
await
group: Group
group
.
Group.send<RemoteAttachment>(codec: ContentCodec<RemoteAttachment>, value: NoInfer<RemoteAttachment>, options?: SendOptions): Promise<MessageId> (+1 overload)
send
(new
new RemoteAttachmentCodec(): RemoteAttachmentCodec
RemoteAttachmentCodec
(),
const remote: RemoteAttachment
remote
);
await
const pending: PendingAttachment
pending
.
PendingAttachment.upload(): Promise<void>
upload
();

For an in-memory file, use AttachmentSource.Bytes(bytes, filename, mimeType) on Kotlin or .bytes(bytes:filename:mimeType:) on Swift. Browser path sources name OPFS entries. Browser byte sources copy the caller’s bytes at the SDK boundary.

Sending the record does not start the upload. With the sequence above, a recipient can receive the message before the file is available. Show a pending state and allow another download attempt if the first returns not_found. An app can also complete the upload before sending when it needs the object to be available first.

Decode the received content with RemoteAttachmentCodec to get a RemoteAttachment. Pass that record to download:

const
const downloaded: DownloadedAttachment
downloaded
= await
client: Client
client
.
ClientMembers.attachments: Attachments
attachments
.
Attachments.download(remote: RemoteAttachment): Promise<DownloadedAttachment>
download
(
remote: RemoteAttachment
remote
);
const
const path: string
path
=
const downloaded: DownloadedAttachment
downloaded
.
path: string
path
;
const
const mimeType: string | undefined
mimeType
=
const downloaded: DownloadedAttachment
downloaded
.
mimeType?: string | undefined
mimeType
;
const
const filename: string | undefined
filename
=
const downloaded: DownloadedAttachment
downloaded
.
filename?: string | undefined
filename
;

DownloadedAttachment.path names a file with the original content bytes, not the encoded envelope or ciphertext. The returned MIME type and filename come from the decrypted envelope. Use the local path to open or display the file. The SDK does not create a preview or thumbnail.

The SDK checks the ciphertext digest, decrypts and authenticates the envelope, and checks its attachment type before it puts the plaintext at its final path. A failed download leaves no partial file there. If a file already exists at that path, the SDK returns it without a network request. Receiving a message does not download its attachment. Request downloads when the user opens the file, or when your app explicitly chooses to fetch it.

CallUse
pending.status()Read waiting, uploading, complete, or failed. A failed status includes its cause.
listPending()Find incomplete uploads after an application restart.
pending(remote)Recover the pending handle for a known remote attachment.
pending.upload()Start an upload, join a running upload, or return success for a completed upload.
localPath(remote)Derive the local path without fetching the object or checking that the file exists.
listLocal()List local plaintext files. Each record’s path is relative to the attachment directory.
deleteLocal(remote)Remove local plaintext, staged ciphertext, and attachment records.

Kotlin uses the calls in the table without argument labels. Swift uses pending(remote:), localPath(remote:), and deleteLocal(remote:). Browser and Node expose remoteAttachment as a property on the pending handle; Kotlin and Swift use remoteAttachment().

Pending records survive a restart when the app keeps the same database and attachment directory. The default maximum pending age is 86,400 seconds. AttachmentOptions.maxPendingAgeSeconds changes it. Expired records and staged ciphertext are removed at client creation and by the background cleanup worker. Successful uploads release staged ciphertext and disappear from listPending. The local plaintext remains until the app deletes it.

The SDK does not retry failed transfers automatically. Handle the typed attachment error and its retryable detail. A failed status also carries an AttachmentFailure with a cause and, when available, an HTTP status. A network failure can succeed after a delay. Invalid data, an oversized file, or unusable staged ciphertext needs a corrected input. A credential failure needs the credential action reported by the error. See the failure causes before you retry.

Subscribe to client events to show transfer state. Upload and download events report started, completed, and failed transfers; attachment.deleted reports local deletion. These are state events, not byte progress updates. Each client reports only the changes it makes.

Keep the whole client storage directory when you need to resume transfers. Local deletion does not delete the remote object or its XMTP message. It ends transfers managed by this client for the deleted attachment. A later download can fetch the object again while storage still serves it. An unchanged attachment forwarded in another message shares the same local file.

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.

The upload and download limits still apply. SDK-managed uploads use the backend’s maxUploadBytes setting, which defaults to 100 MiB. Downloads use the app’s maxDownloadBytes limit, or the backend’s upload limit when the app sets none. Without an attachment offer, the default download limit is 100 MiB. A download is also bounded by the advertised contentLength and the 32-bit length limit.

For app-owned storage, you need three things:

  1. A file to attach (image, document, etc.)
  2. A storage provider to host the encrypted file at an HTTPS URL that answers GET with status 200
  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 encrypted EncodedContent envelope of type xmtp.org/attachment:1.0, which contains the file bytes and metadata.

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 upload callback’s attachment.payload contains encrypted bytes. The download URL and decryption metadata travel in the XMTP message. A storage host that does not have that metadata cannot decrypt the file.

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
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 new encrypted file has separate encryption material. The SDK record uses byte arrays for secret, salt, and nonce; the single-attachment codec writes those arrays as hexadecimal parameters.

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.

Use MultiRemoteAttachmentCodec with an attachments array of remote attachment records. Each file has its own download and size checks. The message that contains the array must fit the backend’s message envelope limit.

Use ctx.sendRemoteAttachment(file) to create, encrypt, upload, and send a file through backend attachment storage. No upload callback is required.

To use app-owned storage, supply an upload callback. This example uploads encrypted bytes to Pinata:

import {
class Agent<ContentTypes = unknown>

Event-driven XMTP agent that routes conversations and messages to middleware.

Agent
,
class CommandRouter<ContentTypes = unknown>

Routes slash commands and unmatched text to agent handlers.

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

Upload encrypted bytes to app-owned storage and return its URL.

AttachmentUploadCallback
,
} from "@xmtp/agent-sdk";
import {
class PinataSDK
PinataSDK
} from "pinata";
const
const agent: Agent<unknown>
agent
= await
class Agent<ContentTypes = unknown>

Event-driven XMTP agent that routes conversations and messages to middleware.

Agent
.
Agent<ContentTypes = unknown>.createFromEnv<[]>(options?: Partial<AgentCreateOptions<[]>> | undefined): Promise<Agent<unknown>>

Create an agent from XMTP_* variables. XMTP_BACKEND_URL overrides options.backend; one must be supplied. Pass backend.credentials in options for backend authentication.

createFromEnv
();
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
const 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.

@since ― v0.1.27

env
.
string | undefined
PINATA_JWT
;
const
const 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.

@since ― v0.1.27

env
.
string | undefined
PINATA_GATEWAY
;
if (!
const pinataJwt: string | undefined
pinataJwt
|| !
const pinataGateway: string | undefined
pinataGateway
) {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Set PINATA_JWT and PINATA_GATEWAY");
}
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-file", async (
ctx: MessageContext<string, unknown>
ctx
) => {
const
const file: File
file
= 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
(["Hello from XMTP"], "hello.txt", {
BlobPropertyBag.type?: string | undefined
type
: "text/plain",
});
const
const uploadCallback: AttachmentUploadCallback
uploadCallback
:
type AttachmentUploadCallback = (attachment: HostedAttachment) => Promise<string>

Upload encrypted bytes to app-owned storage and return its URL.

AttachmentUploadCallback
= async (
attachment: HostedAttachment
attachment
) => {
const
const pinata: PinataSDK
pinata
= new
new PinataSDK(config?: PinataConfig): PinataSDK
PinataSDK
({
pinataJwt?: string | undefined
pinataJwt
,
pinataGateway?: string | undefined
pinataGateway
,
});
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
([
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 0 – 255. 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.

@since ― v5.10.0

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

Encrypted bytes to upload.

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
(
[
const encryptedBlob: Blob
encryptedBlob
],
attachment: HostedAttachment
attachment
.
filename?: string | undefined

Original file name, when supplied.

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>.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
);
});
const agent: Agent<unknown>
agent
.
Agent<unknown>.use(...middleware: (AgentMiddleware<unknown> | AgentMiddleware<unknown>[])[]): Agent<unknown>

Add message middleware. Middleware runs in registration order.

use
(
const router: CommandRouter<unknown>
router
.
CommandRouter<unknown>.middleware(): AgentMiddleware<unknown>

Return middleware that routes text messages to registered handlers.

middleware
());
await
const agent: Agent<unknown>
agent
.
Agent<unknown>.start(options?: AgentStreamingOptions): Promise<void>

Start conversation and message streams. Calling this while running is a no-op.

start
();

With the callback in this example:

  1. The SDK encodes the attachment envelope and encrypts it
  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(client: Client, remoteAttachment: RemoteAttachment): Promise<Attachment>

Download and verify an attachment through Rust.

downloadRemoteAttachment
} from "@xmtp/agent-sdk/util";
const agent: Agent<unknown>
agent
.
NodeJS.EventEmitter<EventHandlerMap<unknown>>.on<"attachment">(eventName: keyof EventEmitterEventMap | "actions" | "attachment" | "conversation" | "group-update" | "dm" | "group" | "inline-attachment" | "intent" | "leave-request" | "markdown" | "message" | "multi-attachment" | "reaction" | "read-receipt" | "reply" | "start" | "stop" | "text" | "transaction-reference" | "unhandledError" | "unknownMessage" | "wallet-send-calls", listener: (ctx: MessageContext<RemoteAttachment, unknown>) => void): Agent<unknown>

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

@since ― v0.1.101

@param ― eventName The name of the event.

@param ― listener The callback function

on
("attachment", async (
ctx: MessageContext<RemoteAttachment, unknown>
ctx
) => {
const
const receivedAttachment: Attachment
receivedAttachment
= await
function downloadRemoteAttachment(client: Client, remoteAttachment: RemoteAttachment): Promise<Attachment>

Download and verify an attachment through Rust.

downloadRemoteAttachment
(
ctx: MessageContext<RemoteAttachment, unknown>
ctx
.
ClientContext<unknown>.client: Client

Return the wrapped XMTP client.

client
,
ctx: MessageContext<RemoteAttachment, unknown>
ctx
.
MessageContext<RemoteAttachment, unknown>.content: RemoteAttachment

Replace the middleware value without changing the SDK message.

The decoded value selected by the content type.

content
,
);
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

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

The console.log() static method outputs a message to the console.

MDN Reference

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

The downloadRemoteAttachment utility uses client.attachments.download(). Core checks the URL, size, digest, and authentication tag before it returns the local file. The utility reads that file and returns its filename, MIME type, and bytes.

Downloads require HTTPS. Loopback HTTP is permitted only with allowPrivateNetwork enabled. Native clients check resolved addresses and redirects. Browser downloads require CORS support and reject redirects. See Remote attachments specification for the full transfer rules.

Use remote attachments for files near the message limit. Inline files share that limit with the content envelope and MLS framing.

The backend’s default message envelope limit is 1 MiB. Use the deployment’s current maxEnvelopeBytes limit. A file smaller than 1 MiB can still exceed the limit after encoding and MLS framing.

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

See Fallback and unsupported content.