diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js
--- a/keyserver/src/endpoints.js
+++ b/keyserver/src/endpoints.js
@@ -28,6 +28,7 @@
   reactionMessageCreationResponder,
   editMessageCreationResponder,
   fetchPinnedMessagesResponder,
+  searchMessagesResponder,
 } from './responders/message-responders.js';
 import { updateRelationshipsResponder } from './responders/relationship-responders.js';
 import {
@@ -183,6 +184,10 @@
     responder: entryRestorationResponder,
     requiredPolicies: baseLegalPolicies,
   },
+  search_messages: {
+    responder: searchMessagesResponder,
+    requiredPolicies: baseLegalPolicies,
+  },
   search_users: {
     responder: userSearchResponder,
     requiredPolicies: baseLegalPolicies,
diff --git a/keyserver/src/responders/message-responders.js b/keyserver/src/responders/message-responders.js
--- a/keyserver/src/responders/message-responders.js
+++ b/keyserver/src/responders/message-responders.js
@@ -23,6 +23,8 @@
   type SendEditMessageResponse,
   type FetchPinnedMessagesRequest,
   type FetchPinnedMessagesResult,
+  type SearchMessagesResponse,
+  type SearchMessagesRequest,
 } from 'lib/types/message-types.js';
 import type { EditMessageData } from 'lib/types/messages/edit.js';
 import type { ReactionMessageData } from 'lib/types/messages/reaction.js';
@@ -44,6 +46,8 @@
   fetchMessageInfoByID,
   fetchThreadMessagesCount,
   fetchPinnedMessageInfos,
+  searchMessagesInSingleChat,
+  searchMessagesPageSize,
 } from '../fetchers/message-fetchers.js';
 import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js';
 import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js';
@@ -401,6 +405,34 @@
   return await fetchPinnedMessageInfos(viewer, request);
 }
 
+const searchMessagesResponderInputValidator = tShape({
+  query: t.String,
+  threadID: t.String,
+  cursor: t.maybe(t.String),
+});
+
+async function searchMessagesResponder(
+  viewer: Viewer,
+  input: any,
+): Promise<SearchMessagesResponse> {
+  const request: SearchMessagesRequest = input;
+  await validateInput(viewer, searchMessagesResponderInputValidator, input);
+  const messages = await searchMessagesInSingleChat(
+    request.query,
+    request.threadID,
+    viewer,
+    request.cursor,
+  );
+  if (messages.length < searchMessagesPageSize) {
+    return {
+      messages,
+      endReached: true,
+    };
+  }
+
+  return { messages: messages.slice(0, -1), endReached: false };
+}
+
 export {
   textMessageCreationResponder,
   messageFetchResponder,
@@ -408,4 +440,5 @@
   reactionMessageCreationResponder,
   editMessageCreationResponder,
   fetchPinnedMessagesResponder,
+  searchMessagesResponder,
 };
diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js
--- a/lib/types/endpoints.js
+++ b/lib/types/endpoints.js
@@ -88,6 +88,7 @@
   SIWE_NONCE: 'siwe_nonce',
   SIWE_AUTH: 'siwe_auth',
   UPDATE_USER_AVATAR: 'update_user_avatar',
+  SEARCH_MESSAGES: 'search_messages',
 });
 type SocketPreferredEndpoint = $Values<typeof socketPreferredEndpoints>;
 
diff --git a/lib/types/message-types.js b/lib/types/message-types.js
--- a/lib/types/message-types.js
+++ b/lib/types/message-types.js
@@ -666,3 +666,14 @@
 export type FetchPinnedMessagesResult = {
   +pinnedMessages: $ReadOnlyArray<RawMessageInfo>,
 };
+
+export type SearchMessagesRequest = {
+  +query: string,
+  +threadID: string,
+  +cursor?: string,
+};
+
+export type SearchMessagesResponse = {
+  +messages: $ReadOnlyArray<RawMessageInfo>,
+  +endReached: boolean,
+};