diff --git a/loader/include/Geode/loader/IPC.hpp b/loader/include/Geode/loader/IPC.hpp
index 5a8ab4ae..46ab8ffc 100644
--- a/loader/include/Geode/loader/IPC.hpp
+++ b/loader/include/Geode/loader/IPC.hpp
@@ -8,35 +8,61 @@ namespace geode {
     constexpr const char* IPC_PIPE_NAME = "\\\\.\\pipe\\GeodeIPCPipe";
     #endif
 
-    // message ID may be anything that doesn't contain /, but to be safe, you 
-    // should only define message IDs that match the following: [a-z\-_\.]+
+    // IPC (Inter-Process Communication) provides a way for Geode mods to talk 
+    // to other programs on the user's computer. If you have, for example, a 
+    // debugger, or an external modding UI, that application can open up the 
+    // platform-specific pipe and start sending messages to mods. Mods can 
+    // listen for messages using the listenForIPC function, and reply to 
+    // messages the get by using the reply method on the event provided. For 
+    // example, an external application can query what mods are loaded in Geode 
+    // by sending the `list-mods` message to `geode.loader`.
 
     class GEODE_DLL IPCEvent : public Event {
     protected:
         void* m_rawPipeHandle;
         std::string m_targetModID;
-        std::string m_senderID;
+        std::optional<std::string> m_replyID;
         std::string m_messageID;
-        std::string m_messageData;
+        nlohmann::json m_messageData;
+        bool m_replied = false;
 
     public:
         IPCEvent(
             void* rawPipeHandle,
-            std::string targetModID,
-            std::string senderID,
-            std::string messageID,
-            std::string messageData
+            std::string const& targetModID,
+            std::string const& messageID,
+            std::optional<std::string> const& replyID,
+            nlohmann::json const& messageData
         );
+        virtual ~IPCEvent();
 
-        std::string getSenderID() const;
+        std::optional<std::string> getReplyID() const;
         std::string getTargetModID() const;
         std::string getMessageID() const;
-        std::string getMessageData() const;
+        nlohmann::json getMessageData() const;
 
         /**
-         * Reply to the message. You can only reply once
+         * Whether this message can be replied to, i.e. if it has a reply ID 
+         * provided
+         * @returns True if the message can be replied to, false otherwise
          */
-        void reply(std::string const& data);
+        bool canReply() const;
+
+        /**
+         * Reply to the message. Will post a message back to the application 
+         * the sent this message with the reply ID and provided data.
+         * You can only reply once; after the other application has received 
+         * the reply, it can assume the reply ID can be freed and reused for 
+         * other messages. Calling reply again on this message will not cause 
+         * a new response to be sent.
+         * If reply is not explicitly called, a default response of null will 
+         * be posted back.
+         * @param data The data to send back; will be the under the "data" key 
+         * in the response JSON. The structure may be anything; however, you 
+         * should document what kind of JSON structures applications may expect 
+         * from your mod.
+         */
+        void reply(nlohmann::json const& data);
     };
 
     class GEODE_DLL IPCFilter : public EventFilter<IPCEvent> {
diff --git a/loader/include/Geode/loader/Mod.hpp b/loader/include/Geode/loader/Mod.hpp
index f07d9eb4..147c096b 100644
--- a/loader/include/Geode/loader/Mod.hpp
+++ b/loader/include/Geode/loader/Mod.hpp
@@ -172,7 +172,19 @@ namespace geode {
          */
         static Result<ModInfo> create(ModJson const& json);
 
+        /**
+         * Convert to JSON. Essentially same as getRawJSON except dynamically 
+         * adds runtime fields like path
+         */
+        ModJson toJSON() const;
+        /**
+         * Get the raw JSON file
+         */
+        ModJson getRawJSON() const;
+
     private:
+        ModJson m_rawJSON;
+
         /**
          * Version is passed for backwards
          * compatibility if we update the mod.json
@@ -186,6 +198,9 @@ namespace geode {
         std::vector<std::pair<std::string, std::optional<std::string>*>> getSpecialFiles();
     };
 
+    // For converting ModInfo back to JSON
+    void GEODE_DLL to_json(nlohmann::json& json, ModInfo const& info);
+
     /**
      * @class DataStore
      * Internal class for notifying Mod
diff --git a/loader/include/Geode/loader/SettingEvent.hpp b/loader/include/Geode/loader/SettingEvent.hpp
index c92b5219..de30f4ed 100644
--- a/loader/include/Geode/loader/SettingEvent.hpp
+++ b/loader/include/Geode/loader/SettingEvent.hpp
@@ -52,8 +52,8 @@ namespace geode {
     };
 
     template <class T>
-
-    requires std::is_base_of_v<Setting, T> std::monostate listenForSettingChanges(
+    requires std::is_base_of_v<Setting, T>
+    std::monostate listenForSettingChanges(
         std::string const& settingID, void (*callback)(T*)
     ) {
         Loader::get()->scheduleOnModLoad(getMod(), [=]() {
diff --git a/loader/include/Geode/utils/VersionInfo.hpp b/loader/include/Geode/utils/VersionInfo.hpp
index 365e6d77..22119e4d 100644
--- a/loader/include/Geode/utils/VersionInfo.hpp
+++ b/loader/include/Geode/utils/VersionInfo.hpp
@@ -2,6 +2,7 @@
 
 #include <Geode/DefaultInclude.hpp>
 #include <string_view>
+#include "json.hpp"
 
 namespace geode {
     /**
@@ -61,6 +62,8 @@ namespace geode {
         std::string toString() const;
     };
 
+    void GEODE_DLL to_json(nlohmann::json& json, VersionInfo const& info);
+
     inline std::ostream& operator<<(std::ostream& stream, VersionInfo const& version) {
         stream << version.toString();
         return stream;
diff --git a/loader/src/internal/InternalLoader.hpp b/loader/src/internal/InternalLoader.hpp
index 58965174..144c172e 100644
--- a/loader/src/internal/InternalLoader.hpp
+++ b/loader/src/internal/InternalLoader.hpp
@@ -44,10 +44,10 @@ public:
 
     bool setup();
 
-    void postIPCMessage(
+    void postIPCReply(
         void* rawPipeHandle,
-        std::string const& senderID,
-        std::string const& data
+        std::string const& replyID,
+        nlohmann::json const& data
     );
 
     /**
diff --git a/loader/src/internal/ios/InternalLoader.cpp b/loader/src/internal/ios/InternalLoader.cpp
index 6c026b15..1ef12df6 100644
--- a/loader/src/internal/ios/InternalLoader.cpp
+++ b/loader/src/internal/ios/InternalLoader.cpp
@@ -24,7 +24,7 @@ void InternalLoader::openPlatformConsole() {
 
 void InternalLoader::closePlatformConsole() {}
 
-void InternalLoader::postIPCMessage(
+void InternalLoader::postIPCReply(
     void* rawPipeHandle,
     std::string const& senderID,
     std::string const& data
diff --git a/loader/src/internal/mac/InternalLoader.cpp b/loader/src/internal/mac/InternalLoader.cpp
index 5abc785c..d6f1e98d 100644
--- a/loader/src/internal/mac/InternalLoader.cpp
+++ b/loader/src/internal/mac/InternalLoader.cpp
@@ -28,7 +28,7 @@ void InternalLoader::closePlatformConsole() {
     m_platformConsoleOpen = false;
 }
 
-void InternalLoader::postIPCMessage(
+void InternalLoader::postIPCReply(
     void* rawPipeHandle,
     std::string const& senderID,
     std::string const& data
diff --git a/loader/src/internal/windows/InternalLoader.cpp b/loader/src/internal/windows/InternalLoader.cpp
index ab709061..c6e99206 100644
--- a/loader/src/internal/windows/InternalLoader.cpp
+++ b/loader/src/internal/windows/InternalLoader.cpp
@@ -37,17 +37,21 @@ void InternalLoader::closePlatformConsole() {
     m_platformConsoleOpen = false;
 }
 
-void InternalLoader::postIPCMessage(
+void InternalLoader::postIPCReply(
     void* rawPipeHandle,
-    std::string const& senderID,
-    std::string const& data
+    std::string const& replyID,
+    nlohmann::json const& data
 ) {
-    std::string msg = senderID + "/" + data;
-    log::debug("Replying msg: {}", msg);
+    auto msgJson = nlohmann::json::object();
+    msgJson["reply"] = replyID;
+    msgJson["data"] = data;
+    auto msg = msgJson.dump();
+
     DWORD written;
     WriteFile(rawPipeHandle, msg.c_str(), msg.size(), &written, nullptr);
 }
 
+// todo: multiple connections
 void InternalLoader::setupIPC() {
     auto pipe = CreateNamedPipeA(
         IPC_PIPE_NAME,
@@ -68,29 +72,27 @@ void InternalLoader::setupIPC() {
                 DWORD read;
                 while (ReadFile(pipe, buffer, sizeof(buffer) - 1, &read, nullptr)) {
                     buffer[read] = '\0';
-
-                    // format of the message should be modID/senderID/msgID/data
-                    std::string modID;
-                    std::string senderID;
-                    std::string msgID;
-                    std::string data;
-                    size_t collectPart = 0;
-                    for (size_t i = 0; i < read; i++) {
-                        if (buffer[i] == '/' && collectPart < 3) {
-                            collectPart++;
-                        } else {
-                            switch (collectPart) {
-                                case 0: modID += buffer[i]; break;
-                                case 1: senderID += buffer[i]; break;
-                                case 2: msgID += buffer[i]; break;
-                                default: data += buffer[i]; break;
-                            }
+                    try {
+                        auto json = nlohmann::json::parse(buffer);
+                        if (!json.contains("mod") || !json["mod"].is_string()) {
+                            log::warn("Received IPC message without 'mod' field");
+                            continue;
                         }
-                    }
-                    if (modID.size() && senderID.size() && msgID.size()) {
-                        IPCEvent(pipe, modID, senderID, msgID, data).post();
-                    } else {
-                        log::warn("Received invalid IPC message: '{}'", buffer);
+                        if (!json.contains("message") || !json["message"].is_string()) {
+                            log::warn("Received IPC message without 'message' field");
+                            continue;
+                        }
+                        std::optional<std::string> reply = std::nullopt;
+                        if (json.contains("reply") && json["reply"].is_string()) {
+                            reply = json["reply"];
+                        }
+                        nlohmann::json data;
+                        if (json.contains("data")) {
+                            data = json["data"];
+                        }
+                        IPCEvent(pipe, json["mod"], json["message"], reply, data).post();
+                    } catch(...) {
+                        log::warn("Received IPC message that isn't valid JSON");
                     }
                 }
                 log::debug("Connection done");
diff --git a/loader/src/load/IPC.cpp b/loader/src/load/IPC.cpp
index 84d3081b..8974fe13 100644
--- a/loader/src/load/IPC.cpp
+++ b/loader/src/load/IPC.cpp
@@ -5,18 +5,22 @@ USE_GEODE_NAMESPACE();
 
 IPCEvent::IPCEvent(
     void* rawPipeHandle,
-    std::string targetModID,
-    std::string senderID,
-    std::string messageID,
-    std::string messageData
+    std::string const& targetModID,
+    std::string const& messageID,
+    std::optional<std::string> const& replyID,
+    nlohmann::json const& messageData
 ) : m_rawPipeHandle(rawPipeHandle),
     m_targetModID(targetModID),
-    m_senderID(senderID),
     m_messageID(messageID),
+    m_replyID(replyID),
     m_messageData(messageData) {}
 
-std::string IPCEvent::getSenderID() const {
-    return m_senderID;
+IPCEvent::~IPCEvent() {
+    this->reply(nullptr);
+}
+
+std::optional<std::string> IPCEvent::getReplyID() const {
+    return m_replyID;
 }
 
 std::string IPCEvent::getTargetModID() const {
@@ -27,13 +31,20 @@ std::string IPCEvent::getMessageID() const {
     return m_messageID;
 }
 
-std::string IPCEvent::getMessageData() const {
+nlohmann::json IPCEvent::getMessageData() const {
     return m_messageData;
 }
 
-void IPCEvent::reply(std::string const& data) {
-    if (m_rawPipeHandle) {
-        InternalLoader::get()->postIPCMessage(m_rawPipeHandle, m_senderID, data);
+bool IPCEvent::canReply() const {
+    return m_replyID.has_value();
+}
+
+void IPCEvent::reply(nlohmann::json const& data) {
+    if (!m_replied && m_rawPipeHandle && m_replyID.has_value()) {
+        InternalLoader::get()->postIPCReply(
+            m_rawPipeHandle, m_replyID.value(), data
+        );
+        m_replied = true;
     }
 }
 
diff --git a/loader/src/load/ModInfo.cpp b/loader/src/load/ModInfo.cpp
index 926e1fad..4ac43838 100644
--- a/loader/src/load/ModInfo.cpp
+++ b/loader/src/load/ModInfo.cpp
@@ -15,6 +15,7 @@ Result<ModInfo> ModInfo::createFromSchemaV010(ModJson const& rawJson) {
     ModInfo info;
 
     auto json = rawJson;
+    info.m_rawJSON = rawJson;
 
 #define PROPAGATE(err)                         \
     {                                          \
@@ -290,3 +291,18 @@ std::vector<std::pair<std::string, std::optional<std::string>*>> ModInfo::getSpe
         { "support.md", &m_supportInfo },
     };
 }
+
+ModJson ModInfo::toJSON() const {
+    auto json = m_rawJSON;
+    json["path"] = m_path;
+    json["binary"] = m_binaryName;
+    return json;
+}
+
+ModJson ModInfo::getRawJSON() const {
+    return m_rawJSON;
+}
+
+void to_json(nlohmann::json& json, ModInfo const& info) {
+    json = info.toJSON();
+}
diff --git a/loader/src/main.cpp b/loader/src/main.cpp
index 3bea32c8..a0cc0b68 100644
--- a/loader/src/main.cpp
+++ b/loader/src/main.cpp
@@ -102,7 +102,7 @@ BOOL WINAPI DllMain(HINSTANCE lib, DWORD reason, LPVOID) {
 
 #define $_ GEODE_CONCAT(unnamedVar_, __LINE__)
 
-static auto $_ = listenForSettingChanges(
+static auto $_ = listenForSettingChanges<BoolSetting>(
     "show-platform-console",
     [](BoolSetting* setting) {
         if (setting->getValue()) {
@@ -119,16 +119,11 @@ static auto $_ = listenForIPC("ipc-test", +[](IPCEvent* event) {
 });
 
 static auto $_ = listenForIPC("list-mods", +[](IPCEvent* event) {
-    event->reply(
-        "[ " + ranges::join(
-            ranges::map<std::vector<std::string>>(
-                Loader::get()->getAllMods(),
-                [](Mod* mod) {
-                    return "\"" + mod->getID() + "\"";
-                }
-            ), ", "
-        ) + " ]"
-    );
+    event->reply(ranges::map<std::vector<nlohmann::json>>(
+        Loader::get()->getAllMods(), [](Mod* mod) {
+            return mod->getModInfo().toJSON();
+        }
+    ));
 });
 
 int geodeEntry(void* platformData) {
diff --git a/loader/src/utils/version.cpp b/loader/src/utils/version.cpp
index cd72a00a..55f9b6d9 100644
--- a/loader/src/utils/version.cpp
+++ b/loader/src/utils/version.cpp
@@ -105,6 +105,10 @@ bool VersionInfo::match(
     return false;
 }
 
+void to_json(nlohmann::json& json, VersionInfo const& info) {
+    json = info.toString();
+}
+
 std::string VersionInfo::toString() const {
     return "v" + std::to_string(this->m_major) + "." + std::to_string(this->m_minor) + "." +
         std::to_string(this->m_patch);