diff --git a/Cargo.lock b/Cargo.lock
index e9730053e2a681ea870aa2a537607b2c6ee5e318..0649f3be1f22c3bf08ee366d025fa4e0817939b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -612,6 +612,7 @@ dependencies = [
  "clap",
  "conduit_api",
  "conduit_core",
+ "conduit_macros",
  "conduit_service",
  "const-str",
  "futures-util",
@@ -712,6 +713,16 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "conduit_macros"
+version = "0.4.5"
+dependencies = [
+ "conduit_core",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "conduit_router"
 version = "0.4.6"
diff --git a/Cargo.toml b/Cargo.toml
index f5c2ad241e3891294d697e53e76d7a3f3c80a4f3..5fcb03ef84687a6098a211ad7141564dd9c53faa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -413,6 +413,16 @@ default-features = false
 [workspace.dependencies.checked_ops]
 version = "0.1"
 
+[workspace.dependencies.syn]
+version = "1.0"
+features = ["full", "extra-traits"]
+
+[workspace.dependencies.quote]
+version = "1.0"
+
+[workspace.dependencies.proc-macro2]
+version = "1.0.86"
+
 
 #
 # Patches
@@ -480,6 +490,11 @@ package = "conduit_core"
 path = "src/core"
 default-features = false
 
+[workspace.dependencies.conduit-macros]
+package = "conduit_macros"
+path = "src/macros"
+default-features = false
+
 ###############################################################################
 #
 # Release profiles
diff --git a/src/admin/Cargo.toml b/src/admin/Cargo.toml
index 1e13fb7a97cb2041dbfb8d67913494ddd30c97a4..d756b3cbdd171a1637f638997debd0ad8bcb6f77 100644
--- a/src/admin/Cargo.toml
+++ b/src/admin/Cargo.toml
@@ -29,6 +29,7 @@ release_max_log_level = [
 clap.workspace = true
 conduit-api.workspace = true
 conduit-core.workspace = true
+conduit-macros.workspace = true
 conduit-service.workspace = true
 const-str.workspace = true
 futures-util.workspace = true
diff --git a/src/admin/debug/commands.rs b/src/admin/debug/commands.rs
index 3a7d3544911bf7175619504cdd7e04fe7bb1b198..4efefce7cc047c25155d4d2dc8a41499a41d4cae 100644
--- a/src/admin/debug/commands.rs
+++ b/src/admin/debug/commands.rs
@@ -477,7 +477,7 @@ pub(super) async fn latest_pdu_in_room(_body: Vec<&str>, room_id: Box<RoomId>) -
 
 #[tracing::instrument(skip(_body))]
 pub(super) async fn force_set_room_state_from_server(
-	_body: Vec<&str>, server_name: Box<ServerName>, room_id: Box<RoomId>,
+	_body: Vec<&str>, room_id: Box<RoomId>, server_name: Box<ServerName>,
 ) -> Result<RoomMessageEventContent> {
 	if !services()
 		.rooms
@@ -691,18 +691,19 @@ pub(super) async fn resolve_true_destination(
 	Ok(RoomMessageEventContent::text_markdown(msg))
 }
 
-#[must_use]
-pub(super) fn memory_stats() -> RoomMessageEventContent {
+pub(super) async fn memory_stats(_body: Vec<&str>) -> Result<RoomMessageEventContent> {
 	let html_body = conduit::alloc::memory_stats();
 
 	if html_body.is_none() {
-		return RoomMessageEventContent::text_plain("malloc stats are not supported on your compiled malloc.");
+		return Ok(RoomMessageEventContent::text_plain(
+			"malloc stats are not supported on your compiled malloc.",
+		));
 	}
 
-	RoomMessageEventContent::text_html(
+	Ok(RoomMessageEventContent::text_html(
 		"This command's output can only be viewed by clients that render HTML.".to_owned(),
 		html_body.expect("string result"),
-	)
+	))
 }
 
 #[cfg(tokio_unstable)]
diff --git a/src/admin/debug/mod.rs b/src/admin/debug/mod.rs
index 0df477487540241c85d122875828fcf86ae9b699..82b37c5357462920466fb6d9a5684082796bb590 100644
--- a/src/admin/debug/mod.rs
+++ b/src/admin/debug/mod.rs
@@ -3,11 +3,12 @@
 
 use clap::Subcommand;
 use conduit::Result;
+use conduit_macros::admin_command_dispatch;
 use ruma::{events::room::message::RoomMessageEventContent, EventId, OwnedRoomOrAliasId, RoomId, ServerName};
-use tester::TesterCommand;
 
-use self::commands::*;
+use self::{commands::*, tester::TesterCommand};
 
+#[admin_command_dispatch]
 #[derive(Debug, Subcommand)]
 pub(super) enum DebugCommand {
 	/// - Echo input of admin command
@@ -176,63 +177,6 @@ pub(super) enum DebugCommand {
 
 	/// - Developer test stubs
 	#[command(subcommand)]
+	#[allow(non_snake_case)]
 	Tester(TesterCommand),
 }
-
-pub(super) async fn process(command: DebugCommand, body: Vec<&str>) -> Result<RoomMessageEventContent> {
-	Ok(match command {
-		DebugCommand::Echo {
-			message,
-		} => echo(body, message).await?,
-		DebugCommand::GetSigningKeys {
-			server_name,
-			cached,
-		} => get_signing_keys(body, server_name, cached).await?,
-		DebugCommand::GetAuthChain {
-			event_id,
-		} => get_auth_chain(body, event_id).await?,
-		DebugCommand::ParsePdu => parse_pdu(body).await?,
-		DebugCommand::GetPdu {
-			event_id,
-		} => get_pdu(body, event_id).await?,
-		DebugCommand::GetRemotePdu {
-			event_id,
-			server,
-		} => get_remote_pdu(body, event_id, server).await?,
-		DebugCommand::GetRoomState {
-			room_id,
-		} => get_room_state(body, room_id).await?,
-		DebugCommand::Ping {
-			server,
-		} => ping(body, server).await?,
-		DebugCommand::ForceDeviceListUpdates => force_device_list_updates(body).await?,
-		DebugCommand::ChangeLogLevel {
-			filter,
-			reset,
-		} => change_log_level(body, filter, reset).await?,
-		DebugCommand::SignJson => sign_json(body).await?,
-		DebugCommand::VerifyJson => verify_json(body).await?,
-		DebugCommand::FirstPduInRoom {
-			room_id,
-		} => first_pdu_in_room(body, room_id).await?,
-		DebugCommand::LatestPduInRoom {
-			room_id,
-		} => latest_pdu_in_room(body, room_id).await?,
-		DebugCommand::GetRemotePduList {
-			server,
-			force,
-		} => get_remote_pdu_list(body, server, force).await?,
-		DebugCommand::ForceSetRoomStateFromServer {
-			room_id,
-			server_name,
-		} => force_set_room_state_from_server(body, server_name, room_id).await?,
-		DebugCommand::ResolveTrueDestination {
-			server_name,
-			no_cache,
-		} => resolve_true_destination(body, server_name, no_cache).await?,
-		DebugCommand::MemoryStats => memory_stats(),
-		DebugCommand::RuntimeMetrics => runtime_metrics(body).await?,
-		DebugCommand::RuntimeInterval => runtime_interval(body).await?,
-		DebugCommand::Tester(command) => tester::process(command, body).await?,
-	})
-}
diff --git a/src/admin/handler.rs b/src/admin/handler.rs
index ff04d378294ae59b1ce731154f2020cef98afb0d..6acb19bfd2ee53822412b52b592a89332e74c68f 100644
--- a/src/admin/handler.rs
+++ b/src/admin/handler.rs
@@ -1,7 +1,7 @@
 use std::{panic::AssertUnwindSafe, time::Instant};
 
 use clap::{CommandFactory, Parser};
-use conduit::{error, trace, Error};
+use conduit::{error, trace, utils::string::common_prefix, Error, Result};
 use futures_util::future::FutureExt;
 use ruma::{
 	events::{
@@ -10,8 +10,6 @@
 	},
 	OwnedEventId,
 };
-
-use conduit::{utils::string::common_prefix, Result};
 use service::{
 	admin::{Command, CommandOutput, CommandResult, HandlerResult},
 	Services,
@@ -36,7 +34,7 @@ pub(super) fn handle(command: Command) -> HandlerResult { Box::pin(handle_comman
 
 #[tracing::instrument(skip_all, name = "admin")]
 async fn handle_command(command: Command) -> CommandResult {
-	AssertUnwindSafe(process_command(&command))
+	AssertUnwindSafe(Box::pin(process_command(&command)))
 		.catch_unwind()
 		.await
 		.map_err(Error::from_panic)
diff --git a/src/admin/mod.rs b/src/admin/mod.rs
index ff2aefd5c8fbb666b78d6990f442c597183d7395..7d752ff869207c68ca2a63e303739105911d95e1 100644
--- a/src/admin/mod.rs
+++ b/src/admin/mod.rs
@@ -1,5 +1,6 @@
 #![recursion_limit = "168"]
 #![allow(clippy::wildcard_imports)]
+#![allow(clippy::enum_glob_use)]
 
 pub(crate) mod admin;
 pub(crate) mod handler;
diff --git a/src/macros/Cargo.toml b/src/macros/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..b9a35aab754f9ea0c7e7abf42a2b616de45283cd
--- /dev/null
+++ b/src/macros/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "conduit_macros"
+categories.workspace = true
+description.workspace = true
+edition.workspace = true
+keywords.workspace = true
+license.workspace = true
+readme.workspace = true
+repository.workspace = true
+version.workspace = true
+
+[lib]
+name = "conduit_macros"
+path = "mod.rs"
+proc-macro = true
+
+[dependencies]
+syn.workspace = true
+quote.workspace = true
+proc-macro2.workspace = true
+conduit-core.workspace = true
+
+[lints]
+workspace = true
diff --git a/src/macros/admin.rs b/src/macros/admin.rs
new file mode 100644
index 0000000000000000000000000000000000000000..e95e402ae6ec442bbafb6b9420ed3b6a041812a9
--- /dev/null
+++ b/src/macros/admin.rs
@@ -0,0 +1,50 @@
+use conduit_core::utils::string::camel_to_snake_string;
+use proc_macro::{Span, TokenStream};
+use proc_macro2::TokenStream as TokenStream2;
+use quote::quote;
+use syn::{parse_macro_input, AttributeArgs, Fields, Ident, ItemEnum, Variant};
+
+pub(super) fn command_dispatch(args: TokenStream, input_: TokenStream) -> TokenStream {
+	let input = input_.clone();
+	let item = parse_macro_input!(input as ItemEnum);
+	let _args = parse_macro_input!(args as AttributeArgs);
+	let arm = item.variants.iter().map(dispatch_arm);
+	let name = item.ident;
+	let q = quote! {
+		pub(super) async fn process(command: #name, body: Vec<&str>) -> Result<RoomMessageEventContent> {
+			use #name::*;
+			#[allow(non_snake_case)]
+			Ok(match command {
+				#( #arm )*
+			})
+		}
+	};
+
+	[input_, q.into()].into_iter().collect::<TokenStream>()
+}
+
+fn dispatch_arm(v: &Variant) -> TokenStream2 {
+	let name = &v.ident;
+	let target = camel_to_snake_string(&format!("{name}"));
+	let handler = Ident::new(&target, Span::call_site().into());
+	match &v.fields {
+		Fields::Named(fields) => {
+			let field = fields.named.iter().filter_map(|f| f.ident.as_ref());
+			let arg = field.clone();
+			quote! {
+				#name { #( #field ),* } => Box::pin(#handler(body, #( #arg ),*)).await?,
+			}
+		},
+		Fields::Unnamed(fields) => {
+			let field = &fields.unnamed.first().expect("one field");
+			quote! {
+				#name ( #field ) => Box::pin(#handler::process(#field, body)).await?,
+			}
+		},
+		Fields::Unit => {
+			quote! {
+				#name => Box::pin(#handler(body)).await?,
+			}
+		},
+	}
+}
diff --git a/src/macros/mod.rs b/src/macros/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..0aba7560eb0ecabb1f4074be6973fa77d2b6269a
--- /dev/null
+++ b/src/macros/mod.rs
@@ -0,0 +1,8 @@
+mod admin;
+
+use proc_macro::TokenStream;
+
+#[proc_macro_attribute]
+pub fn admin_command_dispatch(args: TokenStream, input: TokenStream) -> TokenStream {
+	admin::command_dispatch(args, input)
+}