diff --git a/lib/shared/dm-ops/add-members-spec.js b/lib/shared/dm-ops/add-members-spec.js
new file mode 100644
--- /dev/null
+++ b/lib/shared/dm-ops/add-members-spec.js
@@ -0,0 +1,140 @@
+// @flow
+
+import invariant from 'invariant';
+import uuid from 'uuid';
+
+import {
+  createRoleAndPermissionForThickThreads,
+  createThickRawThreadInfo,
+} from './create-thread-spec.js';
+import type {
+  DMOperationSpec,
+  ProcessDMOperationUtilities,
+} from './dm-op-spec.js';
+import type { DMAddMembersOperation } from '../../types/dm-ops.js';
+import { messageTypes } from '../../types/message-types-enum.js';
+import {
+  messageTruncationStatus,
+  type RawMessageInfo,
+} from '../../types/message-types.js';
+import {
+  minimallyEncodeMemberInfo,
+  type ThickRawThreadInfo,
+} from '../../types/minimally-encoded-thread-permissions-types.js';
+import { joinThreadSubscription } from '../../types/subscription-types.js';
+import type { ThickMemberInfo } from '../../types/thread-types.js';
+import { updateTypes } from '../../types/update-types-enum.js';
+import type { ClientUpdateInfo } from '../../types/update-types.js';
+import { values } from '../../utils/objects.js';
+import { roleIsDefaultRole, userIsMember } from '../thread-utils.js';
+
+const addMembersSpec: DMOperationSpec<DMAddMembersOperation> = Object.freeze({
+  processDMOperation: async (
+    dmOperation: DMAddMembersOperation,
+    viewerID: string,
+    utilities: ProcessDMOperationUtilities,
+  ) => {
+    const { editorID, time, messageID, addedUserIDs, existingThreadDetails } =
+      dmOperation;
+    const addMembersMessage = {
+      type: messageTypes.ADD_MEMBERS,
+      id: messageID,
+      threadID: existingThreadDetails.threadID,
+      creatorID: editorID,
+      time,
+      addedUserIDs: [...addedUserIDs],
+    };
+
+    const viewerIsAdded = addedUserIDs.includes(viewerID);
+    const updateInfos: Array<ClientUpdateInfo> = [];
+    const rawMessageInfos: Array<RawMessageInfo> = [];
+    if (viewerIsAdded) {
+      const newThread = createThickRawThreadInfo(
+        {
+          ...existingThreadDetails,
+          allMemberIDs: [
+            ...existingThreadDetails.allMemberIDs,
+            ...addedUserIDs,
+          ],
+        },
+        viewerID,
+      );
+      updateInfos.push(
+        {
+          type: updateTypes.JOIN_THREAD,
+          id: uuid.v4(),
+          time,
+          threadInfo: newThread,
+          rawMessageInfos: [addMembersMessage],
+          truncationStatus: messageTruncationStatus.EXHAUSTIVE,
+          rawEntryInfos: [],
+        },
+        {
+          type: updateTypes.UPDATE_THREAD_READ_STATUS,
+          id: uuid.v4(),
+          time,
+          threadID: existingThreadDetails.threadID,
+          unread: true,
+        },
+      );
+    } else {
+      const currentThreadInfoOptional =
+        utilities.threadInfos[existingThreadDetails.threadID];
+      if (!currentThreadInfoOptional || !currentThreadInfoOptional.thick) {
+        // We can't perform this operation now. It should be queued for later.
+        return {
+          rawMessageInfos: [],
+          updateInfos: [],
+        };
+      }
+      const currentThreadInfo: ThickRawThreadInfo = currentThreadInfoOptional;
+      const defaultRoleID = values(currentThreadInfo.roles).find(role =>
+        roleIsDefaultRole(role),
+      )?.id;
+      invariant(defaultRoleID, 'Default role ID must exist');
+      const { membershipPermissions } = createRoleAndPermissionForThickThreads(
+        currentThreadInfo.type,
+        currentThreadInfo.id,
+        defaultRoleID,
+      );
+      const newMembers = addedUserIDs
+        .filter(userID => !userIsMember(currentThreadInfo, userID))
+        .map(userID =>
+          minimallyEncodeMemberInfo<ThickMemberInfo>({
+            id: userID,
+            role: defaultRoleID,
+            permissions: membershipPermissions,
+            isSender: editorID === viewerID,
+            subscription: joinThreadSubscription,
+          }),
+        );
+
+      const newThreadInfo = {
+        ...currentThreadInfo,
+        members: [...currentThreadInfo.members, ...newMembers],
+      };
+      updateInfos.push(
+        {
+          type: updateTypes.UPDATE_THREAD,
+          id: uuid.v4(),
+          time,
+          threadInfo: newThreadInfo,
+        },
+        {
+          type: updateTypes.UPDATE_THREAD_READ_STATUS,
+          id: uuid.v4(),
+          time,
+          threadID: existingThreadDetails.threadID,
+          unread: true,
+        },
+      );
+      rawMessageInfos.push(addMembersMessage);
+    }
+    return {
+      rawMessageInfos,
+      updateInfos,
+    };
+  },
+});
+
+export { addMembersSpec };
diff --git a/lib/shared/dm-ops/create-thread-spec.js b/lib/shared/dm-ops/create-thread-spec.js
--- a/lib/shared/dm-ops/create-thread-spec.js
+++ b/lib/shared/dm-ops/create-thread-spec.js
@@ -26,10 +26,37 @@
   minimallyEncodeThreadCurrentUserInfo,
 } from '../../types/minimally-encoded-thread-permissions-types.js';
 import { joinThreadSubscription } from '../../types/subscription-types.js';
+import type { ThreadPermissionsInfo } from '../../types/thread-permission-types.js';
+import type { ThickThreadType } from '../../types/thread-types-enum.js';
 import type { ThickMemberInfo } from '../../types/thread-types.js';
 import { updateTypes } from '../../types/update-types-enum.js';
 import { generatePendingThreadColor } from '../color-utils.js';
 
+function createRoleAndPermissionForThickThreads(
+  threadType: ThickThreadType,
+  threadID: string,
+  roleID: string,
+): { +role: RoleInfo, +membershipPermissions: ThreadPermissionsInfo } {
+  const rolePermissions = getThickThreadRolePermissionsBlob(threadType);
+  const membershipPermissions = getAllThreadPermissions(
+    makePermissionsBlob(rolePermissions, null, threadID, threadType),
+    threadID,
+  );
+  const role: RoleInfo = {
+    ...minimallyEncodeRoleInfo({
+      id: roleID,
+      name: 'Members',
+      permissions: rolePermissions,
+      isDefault: true,
+    }),
+    specialRole: specialRoles.DEFAULT_ROLE,
+  };
+  return {
+    membershipPermissions,
+    role,
+  };
+}
+
 type MutableThickRawThreadInfo = { ...ThickRawThreadInfo };
 function createThickRawThreadInfo(
   input: CreateThickRawThreadInfoInput,
@@ -56,20 +83,8 @@
 
   const threadColor = color ?? generatePendingThreadColor(allMemberIDs);
 
-  const rolePermissions = getThickThreadRolePermissionsBlob(threadType);
-  const membershipPermissions = getAllThreadPermissions(
-    makePermissionsBlob(rolePermissions, null, threadID, threadType),
-    threadID,
-  );
-  const role: RoleInfo = {
-    ...minimallyEncodeRoleInfo({
-      id: roleID,
-      name: 'Members',
-      permissions: rolePermissions,
-      isDefault: true,
-    }),
-    specialRole: specialRoles.DEFAULT_ROLE,
-  };
+  const { membershipPermissions, role } =
+    createRoleAndPermissionForThickThreads(threadType, threadID, roleID);
 
   const newThread: MutableThickRawThreadInfo = {
     thick: true,
@@ -174,4 +189,8 @@
     },
   });
 
-export { createThickRawThreadInfo, createThreadSpec };
+export {
+  createThickRawThreadInfo,
+  createThreadSpec,
+  createRoleAndPermissionForThickThreads,
+};
diff --git a/lib/shared/dm-ops/dm-op-spec.js b/lib/shared/dm-ops/dm-op-spec.js
--- a/lib/shared/dm-ops/dm-op-spec.js
+++ b/lib/shared/dm-ops/dm-op-spec.js
@@ -2,10 +2,12 @@
 
 import type { DMOperation, DMOperationResult } from '../../types/dm-ops.js';
 import type { RawMessageInfo } from '../../types/message-types.js';
+import type { RawThreadInfos } from '../../types/thread-types.js';
 
 export type ProcessDMOperationUtilities = {
   // Needed to fetch sidebar source messages
   +fetchMessage: (messageID: string) => Promise<?RawMessageInfo>,
+  +threadInfos: RawThreadInfos,
 };
 
 export type DMOperationSpec<DMOp: DMOperation> = {
diff --git a/lib/shared/dm-ops/dm-op-specs.js b/lib/shared/dm-ops/dm-op-specs.js
--- a/lib/shared/dm-ops/dm-op-specs.js
+++ b/lib/shared/dm-ops/dm-op-specs.js
@@ -1,5 +1,6 @@
 // @flow
 
+import { addMembersSpec } from './add-members-spec.js';
 import { createSidebarSpec } from './create-sidebar-spec.js';
 import { createThreadSpec } from './create-thread-spec.js';
 import type { DMOperationSpec } from './dm-op-spec.js';
@@ -16,4 +17,5 @@
   [dmOperationTypes.SEND_TEXT_MESSAGE]: sendTextMessageSpec,
   [dmOperationTypes.SEND_REACTION_MESSAGE]: sendReactionMessageSpec,
   [dmOperationTypes.SEND_EDIT_MESSAGE]: sendEditMessageSpec,
+  [dmOperationTypes.ADD_MEMBERS]: addMembersSpec,
 });
diff --git a/lib/shared/dm-ops/process-dm-ops.js b/lib/shared/dm-ops/process-dm-ops.js
--- a/lib/shared/dm-ops/process-dm-ops.js
+++ b/lib/shared/dm-ops/process-dm-ops.js
@@ -9,15 +9,18 @@
   type DMOperation,
   processDMOpsActionType,
 } from '../../types/dm-ops.js';
-import { useDispatch } from '../../utils/redux-utils.js';
+import { useDispatch, useSelector } from '../../utils/redux-utils.js';
 
 function useProcessDMOperation(): (dmOp: DMOperation) => Promise<void> {
   const fetchMessage = useGetLatestMessageEdit();
+  const threadInfos = useSelector(state => state.threadStore.threadInfos);
+
   const utilities = React.useMemo(
     () => ({
       fetchMessage,
+      threadInfos,
     }),
-    [fetchMessage],
+    [fetchMessage, threadInfos],
   );
 
   const dispatch = useDispatch();
diff --git a/lib/types/dm-ops.js b/lib/types/dm-ops.js
--- a/lib/types/dm-ops.js
+++ b/lib/types/dm-ops.js
@@ -1,7 +1,6 @@
 // @flow
 
-import type { TInterface, TUnion } from 'tcomb';
-import t from 'tcomb';
+import t, { type TInterface, type TUnion } from 'tcomb';
 
 import { clientAvatarValidator, type ClientAvatar } from './avatar-types.js';
 import type { RawMessageInfo } from './message-types.js';
@@ -22,6 +21,7 @@
   SEND_TEXT_MESSAGE: 'send_text_message',
   SEND_REACTION_MESSAGE: 'send_reaction_message',
   SEND_EDIT_MESSAGE: 'send_edit_message',
+  ADD_MEMBERS: 'add_members',
 });
 export type DMOperationType = $Values<typeof dmOperationTypes>;
 
@@ -171,18 +171,38 @@
     text: t.String,
   });
 
+export type DMAddMembersOperation = {
+  +type: 'add_members',
+  +editorID: string,
+  +time: number,
+  +messageID: string,
+  +addedUserIDs: $ReadOnlyArray<string>,
+  +existingThreadDetails: CreateThickRawThreadInfoInput,
+};
+export const dmAddMembersOperationValidator: TInterface<DMAddMembersOperation> =
+  tShape<DMAddMembersOperation>({
+    type: tString(dmOperationTypes.ADD_MEMBERS),
+    editorID: tUserID,
+    time: t.Number,
+    messageID: t.String,
+    addedUserIDs: t.list(tUserID),
+    existingThreadDetails: createThickRawThreadInfoInputValidator,
+  });
+
 export type DMOperation =
   | DMCreateThreadOperation
   | DMCreateSidebarOperation
   | DMSendTextMessageOperation
   | DMSendReactionMessageOperation
-  | DMSendEditMessageOperation;
+  | DMSendEditMessageOperation
+  | DMAddMembersOperation;
 export const dmOperationValidator: TUnion<DMOperation> = t.union([
   dmCreateThreadOperationValidator,
   dmCreateSidebarOperationValidator,
   dmSendTextMessageOperationValidator,
   dmSendReactionMessageOperationValidator,
   dmSendEditMessageOperationValidator,
+  dmAddMembersOperationValidator,
 ]);
 
 export type DMOperationResult = {