diff --git a/src/garage/Cargo.toml b/src/garage/Cargo.toml
index 2ce4fe52..b8ff88ab 100644
--- a/src/garage/Cargo.toml
+++ b/src/garage/Cargo.toml
@@ -50,6 +50,7 @@ sodiumoxide.workspace = true
 structopt.workspace = true
 git-version.workspace = true
 utoipa.workspace = true
+serde_json.workspace = true
 
 futures.workspace = true
 tokio.workspace = true
diff --git a/src/garage/cli/remote/mod.rs b/src/garage/cli/remote/mod.rs
index f2516427..af79157c 100644
--- a/src/garage/cli/remote/mod.rs
+++ b/src/garage/cli/remote/mod.rs
@@ -43,6 +43,7 @@ impl Cli {
 			Command::Meta(mo) => self.cmd_meta(mo).await,
 			Command::Stats(so) => self.cmd_stats(so).await,
 			Command::Repair(ro) => self.cmd_repair(ro).await,
+			Command::JsonApi { endpoint, payload } => self.cmd_json_api(endpoint, payload).await,
 
 			_ => unreachable!(),
 		}
@@ -105,6 +106,49 @@ impl Cli {
 		}
 		Ok(resp.success.into_iter().next().unwrap().1)
 	}
+
+	pub async fn cmd_json_api(&self, endpoint: String, payload: String) -> Result<(), Error> {
+		let payload: serde_json::Value = if payload == "-" {
+			serde_json::from_reader(&std::io::stdin())?
+		} else {
+			serde_json::from_str(&payload)?
+		};
+
+		let request: AdminApiRequest = serde_json::from_value(serde_json::json!({
+			endpoint.clone(): payload,
+		}))?;
+
+		let resp = match self
+			.proxy_rpc_endpoint
+			.call(&self.rpc_host, ProxyRpc::Proxy(request), PRIO_NORMAL)
+			.await??
+		{
+			ProxyRpcResponse::ProxyApiOkResponse(resp) => resp,
+			ProxyRpcResponse::ApiErrorResponse {
+				http_code,
+				error_code,
+				message,
+			} => {
+				return Err(Error::Message(format!(
+					"{} ({}): {}",
+					error_code, http_code, message
+				)))
+			}
+			m => return Err(Error::unexpected_rpc_message(m)),
+		};
+
+		if let serde_json::Value::Object(map) = serde_json::to_value(&resp)? {
+			if let Some(inner) = map.get(&endpoint) {
+				serde_json::to_writer_pretty(std::io::stdout(), &inner)?;
+				return Ok(());
+			}
+		}
+
+		Err(Error::Message(format!(
+			"Invalid response: {}",
+			serde_json::to_string(&resp)?
+		)))
+	}
 }
 
 pub fn table_list_abbr<T: IntoIterator<Item = S>, S: AsRef<str>>(values: T) -> String {
diff --git a/src/garage/cli/structs.rs b/src/garage/cli/structs.rs
index d4446a17..9a6d912c 100644
--- a/src/garage/cli/structs.rs
+++ b/src/garage/cli/structs.rs
@@ -66,6 +66,17 @@ pub enum Command {
 	/// Output openapi JSON schema for admin api
 	#[structopt(name = "admin-api-schema", version = garage_version(), setting(structopt::clap::AppSettings::Hidden))]
 	AdminApiSchema,
+
+	/// Directly invoke the admin API using a JSON payload.
+	/// The result is printed to `stdout` in JSON format.
+	#[structopt(name = "json-api", version = garage_version())]
+	JsonApi {
+		/// The admin API endpoint to invoke, e.g. GetClusterStatus
+		endpoint: String,
+		/// The JSON payload, or `-` to read from `stdin`
+		#[structopt(default_value = "null")]
+		payload: String,
+	},
 }
 
 // -------------------------
diff --git a/src/garage/tests/common/garage.rs b/src/garage/tests/common/garage.rs
index 8d71504f..3d4efbc2 100644
--- a/src/garage/tests/common/garage.rs
+++ b/src/garage/tests/common/garage.rs
@@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
 use std::process;
 use std::sync::Once;
 
+use serde_json::json;
+
 use super::ext::*;
 
 // https://xkcd.com/221/
@@ -193,27 +195,17 @@ api_bind_addr = "127.0.0.1:{admin_port}"
 		let mut key = Key::default();
 
 		let mut cmd = self.command();
-		let base = cmd.args(["key", "create"]);
+		let base = cmd.args(["json-api", "CreateKey"]);
 		let with_name = match maybe_name {
-			Some(name) => base.args([name]),
-			None => base,
+			Some(name) => base.args([serde_json::to_string(&json!({"name": name})).unwrap()]),
+			None => base.args(["{}"]),
 		};
 
 		let output = with_name.expect_success_output("Could not create key");
-		let stdout = String::from_utf8(output.stdout).unwrap();
+		let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
 
-		for line in stdout.lines() {
-			if let Some(key_id) = line.strip_prefix("Key ID: ") {
-				key.id = key_id.to_owned();
-				continue;
-			}
-			if let Some(key_secret) = line.strip_prefix("Secret key: ") {
-				key.secret = key_secret.to_owned();
-				continue;
-			}
-		}
-		assert!(!key.id.is_empty(), "Invalid key: Key ID is empty");
-		assert!(!key.secret.is_empty(), "Invalid key: Key secret is empty");
+		key.id = stdout["accessKeyId"].as_str().unwrap().to_string();
+		key.secret = stdout["secretAccessKey"].as_str().unwrap().to_string();
 
 		key
 	}