From b1329f012cf46507fb58348ffc40a548d505602b Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:13:03 -0700 Subject: [PATCH 1/5] docs: explain configurable metadata and native attachments --- .../content/docs/content-types/attachments.md | 150 +++++++++++++++++- .../content/docs/deploy/attachment-storage.md | 14 ++ .../docs/get-started/run-the-backend.mdx | 31 +++- apps/docs/src/content/docs/sdk/groups.mdx | 131 +++++++++++++++ 4 files changed, 323 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/content/docs/content-types/attachments.md b/apps/docs/src/content/docs/content-types/attachments.md index 3562a091da..0f9ec0b89a 100644 --- a/apps/docs/src/content/docs/content-types/attachments.md +++ b/apps/docs/src/content/docs/content-types/attachments.md @@ -14,6 +14,154 @@ Use a remote attachment to send a file stored outside the message. The backend m To send several remote attachments in one message, see [Support multiple remote attachments](#support-multiple-remote-attachments). +## Native attachment uploads and downloads + +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](/deploy/attachment-storage/). Check `offered()` 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. + +### Create, send, and upload + +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. + +#### Kotlin + +```kotlin +val attachments = client.attachments() +check(attachments.offered()) { "The backend does not offer attachments" } + +val pending = attachments.create( + AttachmentSource.Path( + path = "/path/to/photo.jpg", + filename = "photo.jpg", + mimeType = "image/jpeg", + ), +) +val remote = pending.remoteAttachment() +group.send(RemoteAttachmentCodec(), remote) +pending.upload() +``` + +#### Swift + +```swift +let attachments = client.attachments() +if attachments.offered() { + let pending = try await attachments.create(source: .path( + path: "/path/to/photo.jpg", + filename: "photo.jpg", + mimeType: "image/jpeg" + )) + let remote = pending.remoteAttachment() + _ = try await group.send(RemoteAttachmentCodec(), value: remote) + try await pending.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. + +### Download to a local file + +Decode the received content with `RemoteAttachmentCodec` to get a +`RemoteAttachment`. Pass that record to `download`: + +```kotlin +val downloaded = client.attachments().download(remote) +val path = downloaded.path +val mimeType = downloaded.mimeType +val filename = downloaded.filename +``` + +```swift +let downloaded = try await client.attachments().download(remote: remote) +let path = downloaded.path +let mimeType = downloaded.mimeType +let filename = downloaded.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. + +### Status, retry, and local storage + +| Call | Use | +| --------------------- | ----------------------------------------------------------------------------------------- | +| `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](/specs/atch-remote-attachments/#7-failures) 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. + ## How remote attachments work 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. @@ -79,7 +227,7 @@ Each attachment in the attachments array contains a URL that points to an encryp 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. -## Send and receive +## Send and receive with the Agent SDK Use `ctx.sendRemoteAttachment(file)` to create, encrypt, upload, and send a file through backend attachment storage. No upload callback is required. diff --git a/apps/docs/src/content/docs/deploy/attachment-storage.md b/apps/docs/src/content/docs/deploy/attachment-storage.md index 16c1c334b7..a10d33225c 100644 --- a/apps/docs/src/content/docs/deploy/attachment-storage.md +++ b/apps/docs/src/content/docs/deploy/attachment-storage.md @@ -9,6 +9,20 @@ signs one PUT request for each upload. Clients send the ciphertext to the storage target and fetch it from `base_url`. The backend does not store the ciphertext. +Applications use the SDK's built-in attachment service to create a pending +attachment, send its remote record, and upload the staged ciphertext. The +service requests a signed PUT from the backend and sends the file directly to +the target. The recipient explicitly requests a download; message receipt +does not fetch the file. See +[Native attachment uploads and downloads](/content-types/attachments/#native-attachment-uploads-and-downloads) +for Kotlin and Swift examples, retry behavior, and local file management. + +The published upload limit bounds the encrypted envelope, including its MIME +type, filename, framing, and authentication tag. A source file exactly at the +limit can exceed it after encryption. Target retention controls how long a +remote file remains available. It does not remove the plaintext copies that +clients already downloaded. + ## Configure the target ```toml diff --git a/apps/docs/src/content/docs/get-started/run-the-backend.mdx b/apps/docs/src/content/docs/get-started/run-the-backend.mdx index 97e6b49745..aba9c53885 100644 --- a/apps/docs/src/content/docs/get-started/run-the-backend.mdx +++ b/apps/docs/src/content/docs/get-started/run-the-backend.mdx @@ -298,6 +298,16 @@ left; it denies every other type. ```toml [[application_components]] component_id = 0xC000 +name = "GROUP_CATEGORY" +component_type = "string" +insert_policy = "allow_if_admin" +update_policy = "allow_if_admin" +delete_policy = "allow_if_super_admin" +in_groups = true +in_dms = false + +[[application_components]] +component_id = 0xC001 name = "USER_PRONOUNS" component_type = "tls_map_inbox_id_string" insert_policy = "allow_if_self_or_non_member" @@ -307,10 +317,27 @@ in_groups = true in_dms = true ``` +`GROUP_CATEGORY` is one string for the group. `USER_PRONOUNS` is one string per +inbox in each group or DM. The name prefix does not grant permissions. The map +type and the three policies control how members can change their entries. +These values are encrypted conversation metadata. The backend publishes their +definitions but cannot read their values. + +IDs from `0xC000` through `0xFCFF` are mutable. IDs from `0xFD00` through +`0xFEFF` are immutable: a value can be set once, then cannot be changed or +removed. Registration leaves the value unset in either range. Application +scalars, collection keys, and collection values are limited to 8,192 bytes; +serialized collection snapshots are limited to 65,536 bytes. + +See [Configurable fields](/sdk/groups/#configurable-fields) for field discovery, +typed reads and writes, and user data examples. + Keep a published field's ID, name, type, policies, and `in_groups` and `in_dms` stable. A conversation copies the definition when it registers the ID, so a -change reaches only conversations that register it later; existing ones keep -the old definition. Retire a field by removing it and publish a replacement +change reaches only conversations that register it later; existing registered +fields keep the old definition. A client with registry write authority can +register missing eligible fields in an existing conversation when it makes a +commit. Retire a field by removing it and publish a replacement under a new ID and name. Prefix the name of a field that holds one value per inbox with `USER_`, as the diff --git a/apps/docs/src/content/docs/sdk/groups.mdx b/apps/docs/src/content/docs/sdk/groups.mdx index 929d12fa75..deb442c5c1 100644 --- a/apps/docs/src/content/docs/sdk/groups.mdx +++ b/apps/docs/src/content/docs/sdk/groups.mdx @@ -98,3 +98,134 @@ Group chats can have metadata, like names, descriptions, and images. Metadata ca | App data | `updateAppData(data, expected)` | 8,192 bytes | Read metadata from the asynchronous `group.state()` snapshot on all four SDKs. App data is a string. Pass the previous value as `expected` to update app data only if it still matches, or pass `undefined` on Browser and Node or `null`/`nil` on mobile to omit that check. Serialize structured data before you set it. See [Disappearing messages](/sdk/disappearing-messages/). + +### Configurable fields + +An operator can publish additional fields through +[`[[application_components]]`](/get-started/run-the-backend/#application_components). +These fields can hold a group value, a set, or a map. An inbox-keyed map holds +one value per user. All members who can read the conversation can read these +values. User fields are conversation data, not a global inbox profile. + +New groups and DMs register the fields selected by `in_groups` and `in_dms`. +Registration defines the type and the insert, update, and delete policies. It +does not set a value. Existing conversations can register missing fields when +a client with registry write authority makes a commit. A backend configuration +change does not replace a field already in the conversation's registry. + +Use the conversation's descriptors to build a form or validate a value: + +| Method | Result | +| --------------------------------------- | ---------------------------------------------------------------------------- | +| `metadataFields()` | Registered field descriptors, with refs, types, policies, and `isUserField`. | +| `metadataField(name)` | The descriptor with this name, or no result. | +| `metadataValue(field)` | A scalar, map, or set value, or no value if unset. | +| `metadataValues(fields)` | Field and value records in the order requested. | +| `mapValue(field, key)` | One map entry, or no value if the key is absent. | +| `updateMetadataField(field, operation)` | A typed change checked against the committed field policies. | + +A `MetadataFieldRef` identifies a field by `componentId`. Its name is a label +from the well-known fields or the client's backend configuration. A field can +have no name when the local configuration does not name its ID. Keep the ref +from the descriptor. Do not use a backend definition to assume the type or +policy of an existing field. + +Use `ComponentMutation.Replace` on Kotlin or `.replace` on Swift for a scalar. +Use `MapDelta` or `SetDelta` on Kotlin, or `.mapDelta` or `.setDelta` on Swift, +for collection changes. Map operations insert, update, or delete one key; set +operations insert or delete one key. A field in the immutable application +range can be set only once. Wrong types, unknown fields, unsupported types, and +denied writes produce typed errors. + +Browser and Node use records with `kind: "replace"`, `kind: "mapDelta"`, or +`kind: "setDelta"`. A scalar value is a `FieldValue` with `kind: "string"` or +`kind: "bytes"` and a matching `value`. + +Application scalars, collection keys, and collection values have an 8,192-byte +limit. A serialized collection has a 65,536-byte limit. These limits also apply +to `USER_DISPLAY_NAME` and `GROUP_IMAGE`. The existing string fields retain the +limits in the table above. + +### Read and update user fields + +`USER_DISPLAY_NAME` is a well-known string map keyed by inbox ID. It is +registered in new groups and DMs. Members can set or remove their own value. +The default policy also lets members remove an entry for an inbox that has +left. An application field such as `USER_PRONOUNS` can use the same shape and +policies. The `USER_` prefix is a naming convention. The inbox-keyed map type +determines whether `isUserField` is true. + +`updateUserData(values)` uses the caller's inbox ID. Each value sets that +inbox's entry; a `null` value on Kotlin or `nil` on Swift removes it. All changes +in the call use one commit. Duplicate fields, non-user fields, and type +mismatches fail the call. Clearing an absent entry makes no change. + +The following examples run with an existing `group`. The operator must publish +`GROUP_CATEGORY` and `USER_PRONOUNS` as shown in the backend guide. The group +must have registered them, and the caller must be an admin to set the category. + +#### Kotlin + +```kotlin +val category = checkNotNull(group.metadataField("GROUP_CATEGORY")).field +group.updateMetadataField( + category, ComponentMutation.Replace(FieldValue.String("engineering")), +) + +val displayName = metadataFieldRef(WellKnownMetadataField.USER_DISPLAY_NAME) +val pronouns = checkNotNull(group.metadataField("USER_PRONOUNS")).field + +group.updateUserData( + listOf( + UserFieldUpdate(displayName, FieldValue.String("Alix")), + UserFieldUpdate(pronouns, FieldValue.String("they/them")), + ), +) + +group.sync() +val profiles = group.userData(listOf(displayName, pronouns), null) +group.updateUserData(listOf(UserFieldUpdate(pronouns, null))) +``` + +#### Swift + +```swift +if let category = try await group.metadataField(name: "GROUP_CATEGORY")?.field { + try await group.updateMetadataField( + field: category, operation: .replace(.string("engineering")) + ) +} + +let displayName = metadataFieldRef(field: .userDisplayName) +if let pronouns = try await group.metadataField(name: "USER_PRONOUNS")?.field { + try await group.updateUserData(values: [ + UserFieldUpdate(field: displayName, value: .string("Alix")), + UserFieldUpdate(field: pronouns, value: .string("they/them")), + ]) + + try await group.sync() + let profiles = try await group.userData( + fields: [displayName, pronouns], inboxIds: nil + ) + try await group.updateUserData(values: [ + UserFieldUpdate(field: pronouns, value: nil), + ]) +} +``` + +`userData(fields, inboxIds)` returns a map from inbox ID to present field and +value records. Omit the filters with `undefined` on Browser and Node, +`null` on Kotlin, or `nil` on Swift to read all user fields for current members +or the two DM participants. An empty filter selects nothing. An inbox with no +selected values has an empty list. Supply inbox IDs to read entries for former +members. Sync before reads when you need changes from other members. + +### Store a group image + +`GROUP_IMAGE` holds opaque bytes. It is separate from the `GROUP_IMAGE_URL` +string and is registered in new groups. An app can upload an image with the +[attachment API](/content-types/attachments/#native-attachment-uploads-and-downloads), +serialize the returned `RemoteAttachment`, and store those bytes with +`updateMetadataField`. Agree on the serialization format with the other apps +that read the group. The SDK does not parse or fetch these bytes. A reader +decodes the descriptor and explicitly requests the attachment download. From 24bda51b1f0ecda25cee690736ab546d02879488 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:05:34 -0700 Subject: [PATCH 2/5] docs: use synced SDK tabs for native examples --- .../{attachments.md => attachments.mdx} | 20 +++++++++++++++++-- apps/docs/src/content/docs/sdk/groups.mdx | 11 ++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) rename apps/docs/src/content/docs/content-types/{attachments.md => attachments.mdx} (98%) diff --git a/apps/docs/src/content/docs/content-types/attachments.md b/apps/docs/src/content/docs/content-types/attachments.mdx similarity index 98% rename from apps/docs/src/content/docs/content-types/attachments.md rename to apps/docs/src/content/docs/content-types/attachments.mdx index 0f9ec0b89a..6607c44037 100644 --- a/apps/docs/src/content/docs/content-types/attachments.md +++ b/apps/docs/src/content/docs/content-types/attachments.mdx @@ -2,6 +2,8 @@ title: Support attachments in your app built with XMTP --- +import { Tabs, TabItem } from "@astrojs/starlight/components"; + Use the remote attachment, multiple remote attachments, or attachment content type to support attachments in your app. - Use the [remote attachment content type](#support-remote-attachments) to send one encrypted file through storage. @@ -43,7 +45,8 @@ 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. -#### Kotlin + + ```kotlin val attachments = client.attachments() @@ -61,7 +64,8 @@ group.send(RemoteAttachmentCodec(), remote) pending.upload() ``` -#### Swift + + ```swift let attachments = client.attachments() @@ -77,6 +81,9 @@ if attachments.offered() { } ``` + + + 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 @@ -93,6 +100,9 @@ to be available first. Decode the received content with `RemoteAttachmentCodec` to get a `RemoteAttachment`. Pass that record to `download`: + + + ```kotlin val downloaded = client.attachments().download(remote) val path = downloaded.path @@ -100,6 +110,9 @@ val mimeType = downloaded.mimeType val filename = downloaded.filename ``` + + + ```swift let downloaded = try await client.attachments().download(remote: remote) let path = downloaded.path @@ -107,6 +120,9 @@ let mimeType = downloaded.mimeType let filename = downloaded.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. diff --git a/apps/docs/src/content/docs/sdk/groups.mdx b/apps/docs/src/content/docs/sdk/groups.mdx index deb442c5c1..499e2a206c 100644 --- a/apps/docs/src/content/docs/sdk/groups.mdx +++ b/apps/docs/src/content/docs/sdk/groups.mdx @@ -2,6 +2,8 @@ title: Groups --- +import { Tabs, TabItem } from "@astrojs/starlight/components"; + Robust group chat permissions are key to providing users with a friendly and safe group chat experience. ## Understand the group permissions system @@ -164,7 +166,8 @@ The following examples run with an existing `group`. The operator must publish `GROUP_CATEGORY` and `USER_PRONOUNS` as shown in the backend guide. The group must have registered them, and the caller must be an admin to set the category. -#### Kotlin + + ```kotlin val category = checkNotNull(group.metadataField("GROUP_CATEGORY")).field @@ -187,7 +190,8 @@ val profiles = group.userData(listOf(displayName, pronouns), null) group.updateUserData(listOf(UserFieldUpdate(pronouns, null))) ``` -#### Swift + + ```swift if let category = try await group.metadataField(name: "GROUP_CATEGORY")?.field { @@ -213,6 +217,9 @@ if let pronouns = try await group.metadataField(name: "USER_PRONOUNS")?.field { } ``` + + + `userData(fields, inboxIds)` returns a map from inbox ID to present field and value records. Omit the filters with `undefined` on Browser and Node, `null` on Kotlin, or `nil` on Swift to read all user fields for current members From 83c6e6c890e55bef7413f0aeb4b7d8e2d1ec3f67 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:24:18 -0700 Subject: [PATCH 3/5] docs: add Browser and Node variants to SDK tabs --- apps/docs/examples/attachments-browser.ts | 44 +++++++++++++++++++ apps/docs/examples/attachments-node.ts | 43 ++++++++++++++++++ apps/docs/examples/metadata-fields-browser.ts | 29 ++++++++++++ apps/docs/examples/metadata-fields-node.ts | 29 ++++++++++++ .../docs/content-types/attachments.mdx | 36 +++++++++++++-- apps/docs/src/content/docs/sdk/groups.mdx | 17 ++++++- 6 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 apps/docs/examples/attachments-browser.ts create mode 100644 apps/docs/examples/attachments-node.ts create mode 100644 apps/docs/examples/metadata-fields-browser.ts create mode 100644 apps/docs/examples/metadata-fields-node.ts diff --git a/apps/docs/examples/attachments-browser.ts b/apps/docs/examples/attachments-browser.ts new file mode 100644 index 0000000000..392bb3ac8f --- /dev/null +++ b/apps/docs/examples/attachments-browser.ts @@ -0,0 +1,44 @@ +import { + type Client, + type Group, + type RemoteAttachment, +} from "@xmtp/browser-sdk"; +import { initPureWasm, RemoteAttachmentCodec } from "@xmtp/browser-sdk/pure"; + +export async function sendAttachment( + client: Client, + group: Group, + path: string, +) { + // #region upload + await initPureWasm(); + const attachments = client.attachments; + if (!attachments.offered) { + throw new Error("The backend does not offer attachments"); + } + + const pending = await attachments.create({ + kind: "path", + path, + filename: "photo.jpg", + mimeType: "image/jpeg", + }); + const remote = pending.remoteAttachment; + await group.send(new RemoteAttachmentCodec(), remote); + await pending.upload(); + // #endregion upload + return remote; +} + +export async function downloadAttachment( + client: Client, + remote: RemoteAttachment, +) { + // #region download + const downloaded = await client.attachments.download(remote); + const path = downloaded.path; + const mimeType = downloaded.mimeType; + const filename = downloaded.filename; + // #endregion download + return { path, mimeType, filename }; +} diff --git a/apps/docs/examples/attachments-node.ts b/apps/docs/examples/attachments-node.ts new file mode 100644 index 0000000000..e12c28c7bf --- /dev/null +++ b/apps/docs/examples/attachments-node.ts @@ -0,0 +1,43 @@ +import { + type Client, + type Group, + type RemoteAttachment, + RemoteAttachmentCodec, +} from "@xmtp/node-sdk"; + +export async function sendAttachment( + client: Client, + group: Group, + path: string, +) { + // #region upload + const attachments = client.attachments; + if (!attachments.offered) { + throw new Error("The backend does not offer attachments"); + } + + const pending = await attachments.create({ + kind: "path", + path, + filename: "photo.jpg", + mimeType: "image/jpeg", + }); + const remote = pending.remoteAttachment; + await group.send(new RemoteAttachmentCodec(), remote); + await pending.upload(); + // #endregion upload + return remote; +} + +export async function downloadAttachment( + client: Client, + remote: RemoteAttachment, +) { + // #region download + const downloaded = await client.attachments.download(remote); + const path = downloaded.path; + const mimeType = downloaded.mimeType; + const filename = downloaded.filename; + // #endregion download + return { path, mimeType, filename }; +} diff --git a/apps/docs/examples/metadata-fields-browser.ts b/apps/docs/examples/metadata-fields-browser.ts new file mode 100644 index 0000000000..958b23ca16 --- /dev/null +++ b/apps/docs/examples/metadata-fields-browser.ts @@ -0,0 +1,29 @@ +import type { Group } from "@xmtp/browser-sdk"; + +export async function updateFields(group: Group) { + // #region update + const category = await group.metadataField("GROUP_CATEGORY"); + const displayName = await group.metadataField("USER_DISPLAY_NAME"); + const pronouns = await group.metadataField("USER_PRONOUNS"); + if (!category || !displayName || !pronouns) { + throw new Error("The group has not registered the required fields"); + } + + await group.updateMetadataField(category.field, { + kind: "replace", + value: { kind: "string", value: "engineering" }, + }); + await group.updateUserData([ + { field: displayName.field, value: { kind: "string", value: "Alix" } }, + { field: pronouns.field, value: { kind: "string", value: "they/them" } }, + ]); + + await group.sync(); + const profiles = await group.userData( + [displayName.field, pronouns.field], + undefined, + ); + await group.updateUserData([{ field: pronouns.field, value: undefined }]); + // #endregion update + return profiles; +} diff --git a/apps/docs/examples/metadata-fields-node.ts b/apps/docs/examples/metadata-fields-node.ts new file mode 100644 index 0000000000..351cfaf66f --- /dev/null +++ b/apps/docs/examples/metadata-fields-node.ts @@ -0,0 +1,29 @@ +import type { Group } from "@xmtp/node-sdk"; + +export async function updateFields(group: Group) { + // #region update + const category = await group.metadataField("GROUP_CATEGORY"); + const displayName = await group.metadataField("USER_DISPLAY_NAME"); + const pronouns = await group.metadataField("USER_PRONOUNS"); + if (!category || !displayName || !pronouns) { + throw new Error("The group has not registered the required fields"); + } + + await group.updateMetadataField(category.field, { + kind: "replace", + value: { kind: "string", value: "engineering" }, + }); + await group.updateUserData([ + { field: displayName.field, value: { kind: "string", value: "Alix" } }, + { field: pronouns.field, value: { kind: "string", value: "they/them" } }, + ]); + + await group.sync(); + const profiles = await group.userData( + [displayName.field, pronouns.field], + undefined, + ); + await group.updateUserData([{ field: pronouns.field, value: undefined }]); + // #endregion update + return profiles; +} diff --git a/apps/docs/src/content/docs/content-types/attachments.mdx b/apps/docs/src/content/docs/content-types/attachments.mdx index 6607c44037..565c057854 100644 --- a/apps/docs/src/content/docs/content-types/attachments.mdx +++ b/apps/docs/src/content/docs/content-types/attachments.mdx @@ -24,8 +24,10 @@ 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](/deploy/attachment-storage/). Check `offered()` before you -show the upload action. Downloads can use any permitted HTTPS host, including +[attachment storage](/deploy/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. @@ -43,9 +45,23 @@ 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. +a readable file in the application's filesystem. Browser paths name OPFS entries. + + +```ts source="attachments-browser.ts" region="upload" + +``` + + + + +```ts source="attachments-node.ts" region="upload" + +``` + + ```kotlin @@ -101,6 +117,20 @@ Decode the received content with `RemoteAttachmentCodec` to get a `RemoteAttachment`. Pass that record to `download`: + + +```ts source="attachments-browser.ts" region="download" + +``` + + + + +```ts source="attachments-node.ts" region="download" + +``` + + ```kotlin diff --git a/apps/docs/src/content/docs/sdk/groups.mdx b/apps/docs/src/content/docs/sdk/groups.mdx index 499e2a206c..6c1b9b388d 100644 --- a/apps/docs/src/content/docs/sdk/groups.mdx +++ b/apps/docs/src/content/docs/sdk/groups.mdx @@ -158,7 +158,8 @@ policies. The `USER_` prefix is a naming convention. The inbox-keyed map type determines whether `isUserField` is true. `updateUserData(values)` uses the caller's inbox ID. Each value sets that -inbox's entry; a `null` value on Kotlin or `nil` on Swift removes it. All changes +inbox's entry; an `undefined` value on Browser and Node, `null` on Kotlin, +or `nil` on Swift removes it. All changes in the call use one commit. Duplicate fields, non-user fields, and type mismatches fail the call. Clearing an absent entry makes no change. @@ -167,6 +168,20 @@ The following examples run with an existing `group`. The operator must publish must have registered them, and the caller must be an admin to set the category. + + +```ts source="metadata-fields-browser.ts" region="update" + +``` + + + + +```ts source="metadata-fields-node.ts" region="update" + +``` + + ```kotlin From c65669d9fd85ef3aa746f1661e9851c0577e5e12 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:31:43 -0700 Subject: [PATCH 4/5] docs: weight repeated SDK examples below guide text --- apps/docs/src/content/docs/content-types/attachments.mdx | 8 ++++++++ apps/docs/src/content/docs/sdk/groups.mdx | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/apps/docs/src/content/docs/content-types/attachments.mdx b/apps/docs/src/content/docs/content-types/attachments.mdx index 565c057854..08c2e7f1c2 100644 --- a/apps/docs/src/content/docs/content-types/attachments.mdx +++ b/apps/docs/src/content/docs/content-types/attachments.mdx @@ -47,6 +47,9 @@ 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. +{/* Repeated SDK examples have less search weight than guide text. */} + +
@@ -99,6 +102,7 @@ if attachments.offered() { +
For an in-memory file, use `AttachmentSource.Bytes(bytes, filename, mimeType)` on Kotlin or `.bytes(bytes:filename:mimeType:)` on Swift. Browser path sources @@ -116,6 +120,9 @@ to be available first. Decode the received content with `RemoteAttachmentCodec` to get a `RemoteAttachment`. Pass that record to `download`: +{/* Repeated SDK examples have less search weight than guide text. */} + +
@@ -152,6 +159,7 @@ let filename = downloaded.filename +
`DownloadedAttachment.path` names a file with the original content bytes, not the encoded envelope or ciphertext. The returned MIME type and filename come diff --git a/apps/docs/src/content/docs/sdk/groups.mdx b/apps/docs/src/content/docs/sdk/groups.mdx index 6c1b9b388d..cc776b4837 100644 --- a/apps/docs/src/content/docs/sdk/groups.mdx +++ b/apps/docs/src/content/docs/sdk/groups.mdx @@ -167,6 +167,9 @@ The following examples run with an existing `group`. The operator must publish `GROUP_CATEGORY` and `USER_PRONOUNS` as shown in the backend guide. The group must have registered them, and the caller must be an admin to set the category. +{/* Repeated SDK examples have less search weight than guide text. */} + +
@@ -234,6 +237,7 @@ if let pronouns = try await group.metadataField(name: "USER_PRONOUNS")?.field { +
`userData(fields, inboxIds)` returns a map from inbox ID to present field and value records. Omit the filters with `undefined` on Browser and Node, From 631bf8fb1c2f199e5b6fd17a56e63cea5e05e898 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Wed, 7 Oct 2026 13:40:39 -0700 Subject: [PATCH 5/5] docs: keep guide title ranking stable with SDK tabs --- apps/docs/scripts/search-config.mjs | 2 +- apps/docs/src/content/docs/content-types/attachments.mdx | 4 ---- apps/docs/src/content/docs/sdk/groups.mdx | 2 -- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/docs/scripts/search-config.mjs b/apps/docs/scripts/search-config.mjs index efc14b823e..1c036d229d 100644 --- a/apps/docs/scripts/search-config.mjs +++ b/apps/docs/scripts/search-config.mjs @@ -9,5 +9,5 @@ export const searchRanking = { termSaturation: 2, termSimilarity: 9, // Keep guide title matches above repeated terms in longer guide bodies. - metaWeights: { title: 0, guideTitle: 100 }, + metaWeights: { title: 0, guideTitle: 1000 }, }; diff --git a/apps/docs/src/content/docs/content-types/attachments.mdx b/apps/docs/src/content/docs/content-types/attachments.mdx index 08c2e7f1c2..f72cc56302 100644 --- a/apps/docs/src/content/docs/content-types/attachments.mdx +++ b/apps/docs/src/content/docs/content-types/attachments.mdx @@ -47,8 +47,6 @@ 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. -{/* Repeated SDK examples have less search weight than guide text. */} -
@@ -120,8 +118,6 @@ to be available first. Decode the received content with `RemoteAttachmentCodec` to get a `RemoteAttachment`. Pass that record to `download`: -{/* Repeated SDK examples have less search weight than guide text. */} -
diff --git a/apps/docs/src/content/docs/sdk/groups.mdx b/apps/docs/src/content/docs/sdk/groups.mdx index cc776b4837..e9bf714f7a 100644 --- a/apps/docs/src/content/docs/sdk/groups.mdx +++ b/apps/docs/src/content/docs/sdk/groups.mdx @@ -167,8 +167,6 @@ The following examples run with an existing `group`. The operator must publish `GROUP_CATEGORY` and `USER_PRONOUNS` as shown in the backend guide. The group must have registered them, and the caller must be an admin to set the category. -{/* Repeated SDK examples have less search weight than guide text. */} -