diff --git a/src/admin/user/mod.rs b/src/admin/user/mod.rs
index 031dd97a862bdd3e7ab814b44459fbbbdea7d36c..e715daf0451ab315b7f2f8abcfed7e11ca491053 100644
--- a/src/admin/user/mod.rs
+++ b/src/admin/user/mod.rs
@@ -1,7 +1,8 @@
 pub(crate) mod user_commands;
 
 use clap::Subcommand;
-use ruma::events::room::message::RoomMessageEventContent;
+use ruma::{events::room::message::RoomMessageEventContent, RoomId};
+use user_commands::{delete_room_tag, get_room_tags, put_room_tag};
 
 use self::user_commands::{create, deactivate, deactivate_all, list, list_joined_rooms, reset_password};
 use crate::Result;
@@ -62,6 +63,32 @@ pub(crate) enum UserCommand {
 	ListJoinedRooms {
 		user_id: String,
 	},
+
+	/// - Puts a room tag for the specified user and room ID.
+	///
+	/// This is primarily useful if you'd like to set your admin room
+	/// to the special "System Alerts" section in Element as a way to
+	/// permanently see your admin room without it being buried away in your
+	/// favourites or rooms. To do this, you would pass your user, your admin
+	/// room's internal ID, and the tag name `m.server_notice`.
+	PutRoomTag {
+		user_id: String,
+		room_id: Box<RoomId>,
+		tag: String,
+	},
+
+	/// - Deletes the room tag for the specified user and room ID
+	DeleteRoomTag {
+		user_id: String,
+		room_id: Box<RoomId>,
+		tag: String,
+	},
+
+	/// - Gets all the room tags for the specified user and room ID
+	GetRoomTags {
+		user_id: String,
+		room_id: Box<RoomId>,
+	},
 }
 
 pub(crate) async fn process(command: UserCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
@@ -85,5 +112,19 @@ pub(crate) async fn process(command: UserCommand, body: Vec<&str>) -> Result<Roo
 		UserCommand::ListJoinedRooms {
 			user_id,
 		} => list_joined_rooms(body, user_id).await?,
+		UserCommand::PutRoomTag {
+			user_id,
+			room_id,
+			tag,
+		} => put_room_tag(body, user_id, room_id, tag).await?,
+		UserCommand::DeleteRoomTag {
+			user_id,
+			room_id,
+			tag,
+		} => delete_room_tag(body, user_id, room_id, tag).await?,
+		UserCommand::GetRoomTags {
+			user_id,
+			room_id,
+		} => get_room_tags(body, user_id, room_id).await?,
 	})
 }
diff --git a/src/admin/user/user_commands.rs b/src/admin/user/user_commands.rs
index 4869af9ab44ef003e4a2606cefb5205e9e1e2398..9bd963dd0aa41d32c73423baa6009808211a0226 100644
--- a/src/admin/user/user_commands.rs
+++ b/src/admin/user/user_commands.rs
@@ -1,8 +1,15 @@
-use std::fmt::Write as _;
+use std::{collections::BTreeMap, fmt::Write as _};
 
 use api::client::{join_room_by_id_helper, leave_all_rooms};
 use conduit::utils;
-use ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId, OwnedUserId, RoomId, UserId};
+use ruma::{
+	events::{
+		room::message::RoomMessageEventContent,
+		tag::{TagEvent, TagEventContent, TagInfo},
+		RoomAccountDataEventType,
+	},
+	OwnedRoomId, OwnedUserId, RoomId, UserId,
+};
 use tracing::{error, info, warn};
 
 use crate::{
@@ -315,3 +322,94 @@ pub(crate) async fn list_joined_rooms(_body: Vec<&str>, user_id: String) -> Resu
 
 	Ok(RoomMessageEventContent::text_html(output_plain, output_html))
 }
+
+pub(crate) async fn put_room_tag(
+	_body: Vec<&str>, user_id: String, room_id: Box<RoomId>, tag: String,
+) -> Result<RoomMessageEventContent> {
+	let user_id = parse_active_local_user_id(&user_id)?;
+
+	let event = services()
+		.account_data
+		.get(Some(&room_id), &user_id, RoomAccountDataEventType::Tag)?;
+
+	let mut tags_event = event.map_or_else(
+		|| TagEvent {
+			content: TagEventContent {
+				tags: BTreeMap::new(),
+			},
+		},
+		|e| serde_json::from_str(e.get()).expect("Bad account data in database for user {user_id}"),
+	);
+
+	tags_event
+		.content
+		.tags
+		.insert(tag.clone().into(), TagInfo::new());
+
+	services().account_data.update(
+		Some(&room_id),
+		&user_id,
+		RoomAccountDataEventType::Tag,
+		&serde_json::to_value(tags_event).expect("to json value always works"),
+	)?;
+
+	Ok(RoomMessageEventContent::text_plain(format!(
+		"Successfully updated room account data for {user_id} and room {room_id} with tag {tag}"
+	)))
+}
+
+pub(crate) async fn delete_room_tag(
+	_body: Vec<&str>, user_id: String, room_id: Box<RoomId>, tag: String,
+) -> Result<RoomMessageEventContent> {
+	let user_id = parse_active_local_user_id(&user_id)?;
+
+	let event = services()
+		.account_data
+		.get(Some(&room_id), &user_id, RoomAccountDataEventType::Tag)?;
+
+	let mut tags_event = event.map_or_else(
+		|| TagEvent {
+			content: TagEventContent {
+				tags: BTreeMap::new(),
+			},
+		},
+		|e| serde_json::from_str(e.get()).expect("Bad account data in database for user {user_id}"),
+	);
+
+	tags_event.content.tags.remove(&tag.clone().into());
+
+	services().account_data.update(
+		Some(&room_id),
+		&user_id,
+		RoomAccountDataEventType::Tag,
+		&serde_json::to_value(tags_event).expect("to json value always works"),
+	)?;
+
+	Ok(RoomMessageEventContent::text_plain(format!(
+		"Successfully updated room account data for {user_id} and room {room_id}, deleting room tag {tag}"
+	)))
+}
+
+pub(crate) async fn get_room_tags(
+	_body: Vec<&str>, user_id: String, room_id: Box<RoomId>,
+) -> Result<RoomMessageEventContent> {
+	let user_id = parse_active_local_user_id(&user_id)?;
+
+	let event = services()
+		.account_data
+		.get(Some(&room_id), &user_id, RoomAccountDataEventType::Tag)?;
+
+	let tags_event = event.map_or_else(
+		|| TagEvent {
+			content: TagEventContent {
+				tags: BTreeMap::new(),
+			},
+		},
+		|e| serde_json::from_str(e.get()).expect("Bad account data in database for user {user_id}"),
+	);
+
+	Ok(RoomMessageEventContent::text_html(
+		format!("<pre><code>\n{:?}\n</code></pre>", tags_event.content.tags),
+		format!("```\n{:?}\n```", tags_event.content.tags),
+	))
+}