diff --git a/keyserver/src/creators/role-creator.js b/keyserver/src/creators/role-creator.js
--- a/keyserver/src/creators/role-creator.js
+++ b/keyserver/src/creators/role-creator.js
@@ -1,11 +1,23 @@
 // @flow
 
 import { getRolePermissionBlobs } from 'lib/permissions/thread-permissions.js';
+import {
+  universalCommunityPermissions,
+  userSurfacedPermissionsSet,
+  configurableCommunityPermissions,
+  threadPermissions,
+} from 'lib/types/thread-permission-types.js';
 import type { ThreadType } from 'lib/types/thread-types-enum.js';
-import type { RoleInfo } from 'lib/types/thread-types.js';
+import type {
+  RoleInfo,
+  RoleModificationRequest,
+} from 'lib/types/thread-types.js';
+import { ServerError } from 'lib/utils/errors.js';
 
 import createIDs from './id-creator.js';
 import { dbQuery, SQL } from '../database/database.js';
+import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js';
+import type { Viewer } from '../session/viewer.js';
 
 type InitialRoles = {
   +default: RoleInfo,
@@ -59,4 +71,55 @@
   };
 }
 
-export { createInitialRolesForNewThread };
+async function modifyRole(
+  viewer: Viewer,
+  request: RoleModificationRequest,
+): Promise<void> {
+  const hasPermission = await checkThreadPermission(
+    viewer,
+    request.community,
+    threadPermissions.CHANGE_ROLE,
+  );
+  if (!hasPermission) {
+    throw new ServerError('invalid_credentials');
+  }
+
+  const { community, name, permissions, action } = request;
+
+  for (const permission of permissions) {
+    if (!userSurfacedPermissionsSet.has(permission)) {
+      throw new ServerError('invalid_parameters');
+    }
+  }
+
+  const [id] = await createIDs('roles', 1);
+  const time = Date.now();
+
+  const configuredPermissions = permissions
+    .map(permission => [...configurableCommunityPermissions[permission]])
+    .flat();
+
+  const rolePermissions = [
+    ...universalCommunityPermissions,
+    ...configuredPermissions,
+  ];
+  const permissionsBlob = JSON.stringify(
+    Object.fromEntries(rolePermissions.map(permission => [permission, true])),
+  );
+
+  const row = [id, community, name, permissionsBlob, time];
+
+  let query = SQL``;
+  if (action === 'create_role') {
+    query = SQL`
+      INSERT INTO roles (id, thread, name, permissions, creation_time)
+      VALUES (${row})
+    `;
+  } else if (action === 'edit_role') {
+    throw new ServerError("unimplemented: can't edit roles yet");
+  }
+
+  await dbQuery(query);
+}
+
+export { createInitialRolesForNewThread, modifyRole };
diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js
--- a/keyserver/src/endpoints.js
+++ b/keyserver/src/endpoints.js
@@ -59,6 +59,7 @@
   threadFetchMediaResponder,
   threadJoinResponder,
   toggleMessagePinResponder,
+  roleModificationResponder,
 } from './responders/thread-responders.js';
 import {
   userSubscriptionUpdateResponder,
@@ -203,6 +204,10 @@
     responder: logOutResponder,
     requiredPolicies: [],
   },
+  modify_community_role: {
+    responder: roleModificationResponder,
+    requiredPolicies: baseLegalPolicies,
+  },
   policy_acknowledgment: {
     responder: policyAcknowledgmentResponder,
     requiredPolicies: [],
diff --git a/keyserver/src/responders/thread-responders.js b/keyserver/src/responders/thread-responders.js
--- a/keyserver/src/responders/thread-responders.js
+++ b/keyserver/src/responders/thread-responders.js
@@ -9,6 +9,7 @@
   rawMessageInfoValidator,
   messageTruncationStatusesValidator,
 } from 'lib/types/message-types.js';
+import { userSurfacedPermissionValidator } from 'lib/types/thread-permission-types.js';
 import { threadTypes } from 'lib/types/thread-types-enum.js';
 import {
   type ThreadDeletionRequest,
@@ -26,6 +27,7 @@
   type ThreadFetchMediaRequest,
   type ToggleMessagePinRequest,
   type ToggleMessagePinResult,
+  type RoleModificationRequest,
   rawThreadInfoValidator,
 } from 'lib/types/thread-types.js';
 import { serverUpdateInfoValidator } from 'lib/types/update-types.js';
@@ -44,6 +46,7 @@
   entryQueryInputValidator,
   verifyCalendarQueryThreadIDs,
 } from './entry-responders.js';
+import { modifyRole } from '../creators/role-creator.js';
 import { createThread } from '../creators/thread-creator.js';
 import { deleteThread } from '../deleters/thread-deleters.js';
 import { fetchMediaForThread } from '../fetchers/upload-fetchers.js';
@@ -354,6 +357,25 @@
   );
 }
 
+const roleModificationRequestInputValidator = tShape<RoleModificationRequest>({
+  community: tID,
+  name: t.String,
+  permissions: t.list(userSurfacedPermissionValidator),
+  action: t.enums.of(['create_role', 'edit_role']),
+});
+
+async function roleModificationResponder(
+  viewer: Viewer,
+  input: mixed,
+): Promise<void> {
+  const request = await validateInput(
+    viewer,
+    roleModificationRequestInputValidator,
+    input,
+  );
+  await modifyRole(viewer, request);
+}
+
 export {
   threadDeletionResponder,
   roleUpdateResponder,
@@ -365,4 +387,5 @@
   threadFetchMediaResponder,
   newThreadRequestInputValidator,
   toggleMessagePinResponder,
+  roleModificationResponder,
 };
diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js
--- a/lib/actions/thread-actions.js
+++ b/lib/actions/thread-actions.js
@@ -14,6 +14,7 @@
   ThreadFetchMediaResult,
   ToggleMessagePinRequest,
   ToggleMessagePinResult,
+  RoleModificationRequest,
 } from '../types/thread-types.js';
 import type { CallServerEndpoint } from '../utils/call-server-endpoint.js';
 import { values } from '../utils/objects.js';
@@ -193,6 +194,14 @@
     };
   };
 
+const modifyCommunityRole =
+  (
+    callServerEndpoint: CallServerEndpoint,
+  ): ((request: RoleModificationRequest) => Promise<void>) =>
+  async request => {
+    await callServerEndpoint('modify_community_role', request);
+  };
+
 export {
   deleteThreadActionTypes,
   deleteThread,
@@ -211,4 +220,5 @@
   fetchThreadMedia,
   toggleMessagePinActionTypes,
   toggleMessagePin,
+  modifyCommunityRole,
 };
diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js
--- a/lib/types/endpoints.js
+++ b/lib/types/endpoints.js
@@ -71,6 +71,7 @@
   GET_SESSION_PUBLIC_KEYS: 'get_session_public_keys',
   JOIN_THREAD: 'join_thread',
   LEAVE_THREAD: 'leave_thread',
+  MODIFY_COMMUNITY_ROLE: 'modify_community_role',
   REMOVE_MEMBERS: 'remove_members',
   REQUEST_ACCESS: 'request_access',
   RESTORE_ENTRY: 'restore_entry',
diff --git a/lib/types/thread-permission-types.js b/lib/types/thread-permission-types.js
--- a/lib/types/thread-permission-types.js
+++ b/lib/types/thread-permission-types.js
@@ -1,7 +1,7 @@
 // @flow
 
 import invariant from 'invariant';
-import t, { type TDict } from 'tcomb';
+import t, { type TDict, type TEnums } from 'tcomb';
 
 import { values } from '../utils/objects.js';
 import { tBool, tShape } from '../utils/validation-utils.js';
@@ -114,6 +114,11 @@
   MANAGE_INVITE_LINKS: 'manage_invite_links',
 });
 export type UserSurfacedPermission = $Values<typeof userSurfacedPermissions>;
+export const userSurfacedPermissionsSet: $ReadOnlySet<UserSurfacedPermission> =
+  new Set(values(userSurfacedPermissions));
+export const userSurfacedPermissionValidator: TEnums = t.enums.of(
+  values(userSurfacedPermissions),
+);
 
 const editCalendarPermission = {
   title: 'Edit calendar',
@@ -347,6 +352,35 @@
     [userSurfacedPermissions.MANAGE_INVITE_LINKS]: manageInviteLinksPermissions,
   });
 
+export const universalCommunityPermissions: $ReadOnlyArray<string> = [
+  // know_of | descendant_open_know_of
+  threadPermissions.KNOW_OF,
+  threadPermissionPropagationPrefixes.DESCENDANT +
+    threadPermissionFilterPrefixes.OPEN +
+    threadPermissions.KNOW_OF,
+
+  // voiced
+  threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.VOICED,
+
+  // visible | descendant_open_visible
+  threadPermissions.VISIBLE,
+  threadPermissionPropagationPrefixes.DESCENDANT +
+    threadPermissionFilterPrefixes.OPEN +
+    threadPermissions.VISIBLE,
+
+  // join_thread | child_open_join_thread | descendant_opentoplevel_join_thread
+  threadPermissions.JOIN_THREAD,
+  threadPermissionPropagationPrefixes.CHILD +
+    threadPermissionFilterPrefixes.OPEN +
+    threadPermissions.JOIN_THREAD,
+  threadPermissionPropagationPrefixes.DESCENDANT +
+    threadPermissionFilterPrefixes.OPEN_TOP_LEVEL +
+    threadPermissions.JOIN_THREAD,
+
+  threadPermissions.CREATE_SIDEBARS,
+  threadPermissions.LEAVE_THREAD,
+];
+
 export type ThreadPermissionInfo =
   | { +value: true, +source: string }
   | { +value: false, +source: null };
diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js
--- a/lib/types/thread-types.js
+++ b/lib/types/thread-types.js
@@ -24,6 +24,7 @@
   type ThreadRolePermissionsBlob,
   threadPermissionsInfoValidator,
   threadRolePermissionsBlobValidator,
+  type UserSurfacedPermission,
 } from './thread-permission-types.js';
 import { type ThreadType, threadTypeValidator } from './thread-types-enum.js';
 import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js';
@@ -431,6 +432,13 @@
   +threadID: string,
 };
 
+export type RoleModificationRequest = {
+  +community: string,
+  +name: string,
+  +permissions: $ReadOnlyArray<UserSurfacedPermission>,
+  +action: 'create_role' | 'edit_role',
+};
+
 // We can show a max of 3 sidebars inline underneath their parent in the chat
 // tab. If there are more, we show a button that opens a modal to see the rest
 export const maxReadSidebars = 3;