diff --git a/Cargo.lock b/Cargo.lock
index ef2c44f7ca..7fefc07021 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3884,7 +3884,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-api-client"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "hex",
  "mesh-llm-client",
@@ -3894,7 +3894,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-api-server"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "mesh-llm-api-client",
@@ -3905,7 +3905,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-client"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -3937,11 +3937,12 @@ dependencies = [
 [[package]]
 name = "mesh-llm-config"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "dirs",
  "mesh-llm-types",
+ "semver",
  "serde",
  "skippy-protocol",
  "toml 0.9.12+spec-1.1.0",
@@ -3951,7 +3952,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-gpu-bench"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "cc",
@@ -3964,7 +3965,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-guardrails"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "serde",
  "serde_json",
@@ -3973,13 +3974,14 @@ dependencies = [
 [[package]]
 name = "mesh-llm-host-runtime"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "ansi-to-tui",
  "anyhow",
  "arboard",
  "argon2",
  "async-trait",
+ "axum",
  "base64",
  "bytes",
  "chacha20poly1305",
@@ -3989,6 +3991,8 @@ dependencies = [
  "crypto_box",
  "dirs",
  "ed25519-dalek",
+ "flate2",
+ "futures-util",
  "hex",
  "hf-hub",
  "http",
@@ -4004,6 +4008,7 @@ dependencies = [
  "mesh-llm-config",
  "mesh-llm-guardrails",
  "mesh-llm-identity",
+ "mesh-llm-native-runtime",
  "mesh-llm-node",
  "mesh-llm-plugin",
  "mesh-llm-plugin-manager",
@@ -4042,6 +4047,7 @@ dependencies = [
  "skippy-server",
  "skippy-topology",
  "tabwriter",
+ "tar",
  "tempfile",
  "thiserror 2.0.18",
  "tokio",
@@ -4058,7 +4064,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-identity"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "crypto_box",
  "ed25519-dalek",
@@ -4070,10 +4076,21 @@ dependencies = [
  "thiserror 2.0.18",
 ]
 
+[[package]]
+name = "mesh-llm-native-runtime"
+version = "0.68.0"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
+dependencies = [
+ "anyhow",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+]
+
 [[package]]
 name = "mesh-llm-node"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "mesh-llm-types",
@@ -4087,7 +4104,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-plugin"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4104,7 +4121,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-plugin-manager"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "dirs",
@@ -4122,7 +4139,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-protocol"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "hex",
@@ -4135,7 +4152,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-routing"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "iroh",
 ]
@@ -4143,7 +4160,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-sdk"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "mesh-llm-host-runtime",
@@ -4153,7 +4170,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-skills"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "dirs",
@@ -4164,7 +4181,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-system"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "chrono",
@@ -4186,7 +4203,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-types"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "hex",
  "serde",
@@ -4197,12 +4214,12 @@ dependencies = [
 [[package]]
 name = "mesh-llm-ui"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 
 [[package]]
 name = "mesh-mixture-of-agents"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "async-trait",
  "mesh-llm-guardrails",
@@ -4319,7 +4336,7 @@ dependencies = [
 [[package]]
 name = "model-artifact"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4330,7 +4347,7 @@ dependencies = [
 [[package]]
 name = "model-hf"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4344,7 +4361,7 @@ dependencies = [
 [[package]]
 name = "model-package"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "bytes",
@@ -4364,7 +4381,7 @@ dependencies = [
 [[package]]
 name = "model-ref"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "serde",
 ]
@@ -4372,7 +4389,7 @@ dependencies = [
 [[package]]
 name = "model-resolver"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "model-artifact",
@@ -5136,7 +5153,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
 [[package]]
 name = "openai-frontend"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "async-trait",
  "axum",
@@ -6610,7 +6627,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
@@ -7242,7 +7259,7 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b"
 [[package]]
 name = "skippy-cache"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "blake3",
@@ -7252,7 +7269,7 @@ dependencies = [
 [[package]]
 name = "skippy-coordinator"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "thiserror 2.0.18",
 ]
@@ -7260,17 +7277,17 @@ dependencies = [
 [[package]]
 name = "skippy-ffi"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 
 [[package]]
 name = "skippy-metrics"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 
 [[package]]
 name = "skippy-protocol"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "prost",
  "prost-build",
@@ -7281,7 +7298,7 @@ dependencies = [
 [[package]]
 name = "skippy-runtime"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "libc",
@@ -7295,7 +7312,7 @@ dependencies = [
 [[package]]
 name = "skippy-server"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -7323,7 +7340,7 @@ dependencies = [
 [[package]]
 name = "skippy-topology"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "serde",
  "serde_json",
diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs
index 5913818e51..ccc3264abe 100644
--- a/crates/sprout-core/src/kind.rs
+++ b/crates/sprout-core/src/kind.rs
@@ -180,6 +180,22 @@ pub const KIND_PAIRING: u32 = 24134;
 pub const KIND_TYPING_INDICATOR: u32 = 20002;
 /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame.
 pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200;
+/// Ephemeral: mesh status report (desktop → relay). A relay member reports its
+/// current mesh serve availability + EndpointAddr(s) so the relay can project a
+/// sanitized, relay-signed kind:30621 discovery note keyed per reporter. Tagged
+/// `["p", <self>]` optional; never stored — the durable record is the relay's
+/// 30621, not this transient input.
+pub const KIND_MESH_STATUS_REPORT: u32 = 24620;
+/// Ephemeral: mesh connect request (desktop → relay). A relay member asks the
+/// relay to coordinate a direct iroh hole-punch to a peer it discovered via
+/// kind:30621. Tagged `["p", <target_pubkey>]`. Never stored; the relay
+/// validates membership of both ends, then emits paired KIND_MESH_CALL_ME_NOW.
+pub const KIND_MESH_CONNECT_REQUEST: u32 = 24621;
+/// Ephemeral: mesh call-me-now signal (relay → desktop, relay-signed). The live
+/// dial trigger for a direct iroh hole-punch — carries the peer's EndpointAddr
+/// so both ends dial near-simultaneously. Tagged `["p", <recipient_pubkey>]`.
+/// Never stored; seconds expiry.
+pub const KIND_MESH_CALL_ME_NOW: u32 = 24622;
 
 // Stream messaging
 /// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001.
@@ -372,6 +388,9 @@ pub const ALL_KINDS: &[u32] = &[
     KIND_NIP29_GROUP_ROLES,
     KIND_PRESENCE_UPDATE,
     KIND_TYPING_INDICATOR,
+    KIND_MESH_STATUS_REPORT,
+    KIND_MESH_CONNECT_REQUEST,
+    KIND_MESH_CALL_ME_NOW,
     KIND_BLOSSOM_AUTH,
     KIND_PAIRING,
     KIND_AGENT_OBSERVER_FRAME,
diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml
index ac52f5bbb9..70c2c7f324 100644
--- a/crates/sprout-relay/Cargo.toml
+++ b/crates/sprout-relay/Cargo.toml
@@ -65,7 +65,7 @@ metrics-exporter-prometheus = { workspace = true }
 dev = ["sprout-auth/dev"]
 
 [dev-dependencies]
-mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bd16da4955a9dceee8f1e0a02c5814944f4e12a5", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"] }
+mesh-llm-sdk = { git = "https://github.com/tlongwell-block/mesh-llm.git", rev = "bc2f110625ccad2c3f8557b125c063bdc030fd21", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"] }
 sprout-core = { workspace = true, features = ["test-utils"] }
 sprout-auth = { workspace = true, features = ["dev"] }
 reqwest = { workspace = true }
diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs
index 6f525ddad7..2f8ce16be9 100644
--- a/crates/sprout-relay/src/handlers/event.rs
+++ b/crates/sprout-relay/src/handlers/event.rs
@@ -7,7 +7,8 @@ use tracing::{debug, error, info, warn};
 use nostr::{Event, PublicKey};
 use sprout_core::event::StoredEvent;
 use sprout_core::kind::{
-    event_kind_u32, is_ephemeral, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE,
+    event_kind_u32, is_ephemeral, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP,
+    KIND_MESH_CONNECT_REQUEST, KIND_MESH_STATUS_REPORT, KIND_PRESENCE_UPDATE,
 };
 use sprout_core::observer::{
     content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG,
@@ -249,6 +250,22 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
             ));
             return;
         }
+        // Mesh signaling kinds are direct desktop-user actions: the resulting
+        // call-me-now routes to the *authenticated connection's* pubkey, so a
+        // proxy-submitted mesh request would point the dial at the proxy, not the
+        // user's desktop. Reject mesh kinds under proxy scope — they must come
+        // from the member's own session.
+        if has_proxy_scope
+            && (kind_u32 == KIND_MESH_CONNECT_REQUEST || kind_u32 == KIND_MESH_STATUS_REPORT)
+        {
+            reject("scope");
+            conn.send(RelayMessage::ok(
+                &event_id_hex,
+                false,
+                "restricted: mesh signaling cannot be proxy-submitted",
+            ));
+            return;
+        }
         handle_ephemeral_event(
             event,
             conn_id,
@@ -384,6 +401,69 @@ async fn handle_ephemeral_event(
         return;
     }
 
+    // Mesh status report (kind:24620). An authenticated relay member reports its
+    // current mesh serve availability; the relay projects it into a relay-signed,
+    // per-reporter kind:30621 discovery note. The report is ephemeral input; the
+    // 30621 is the durable, relay-owned record.
+    if event_kind_u32(&event) == KIND_MESH_STATUS_REPORT {
+        let reporter_hex = auth_pubkey.to_hex();
+        match super::mesh_signaling::handle_status_report(&state, &reporter_hex, &event).await {
+            Ok(()) => {
+                conn.send(RelayMessage::ok(event_id_hex, true, ""));
+            }
+            Err(reason) => {
+                conn.send(RelayMessage::ok(event_id_hex, false, &reason));
+            }
+        }
+        return;
+    }
+
+    // Mesh hole-punch signaling (kind:24621). An authenticated relay member
+    // asks the relay to coordinate a direct iroh hole-punch to a peer it found
+    // via kind:30621. The relay validates the target is also a member, then
+    // emits the paired call-me-now (kind:24622). This is the relay's ONLY role
+    // in the v1 direct-iroh mesh — validate membership + pair + fan out. It
+    // never carries iroh traffic and stores no endpoint state.
+    if event_kind_u32(&event) == KIND_MESH_CONNECT_REQUEST {
+        // Generous per-requester rate limit: each accepted 24621 makes the relay
+        // sign + fan TWO 24622s, so bound the amplification. 20/sec is far above
+        // any real interactive use; a buggy desktop loop can't storm the relay.
+        {
+            let key: [u8; 32] = auth_pubkey.to_bytes();
+            let now = std::time::Instant::now();
+            let mut entry = state
+                .mesh_connect_rate_limiter
+                .entry(key)
+                .or_insert((0, now));
+            let (count, window_start) = entry.value_mut();
+            if now.duration_since(*window_start).as_secs() >= 1 {
+                *count = 1;
+                *window_start = now;
+            } else {
+                *count += 1;
+                if *count > 20 {
+                    drop(entry);
+                    conn.send(RelayMessage::ok(
+                        event_id_hex,
+                        false,
+                        "rate-limited: mesh connect request rate exceeded (20/sec)",
+                    ));
+                    return;
+                }
+            }
+        }
+        let requester_hex = auth_pubkey.to_hex();
+        match super::mesh_signaling::handle_connect_request(&state, &requester_hex, &event).await {
+            Ok(()) => {
+                conn.send(RelayMessage::ok(event_id_hex, true, ""));
+            }
+            Err(reason) => {
+                conn.send(RelayMessage::ok(event_id_hex, false, &reason));
+            }
+        }
+        return;
+    }
+
     // Check channel membership before publishing other ephemeral events.
     if let Some(ch_id) = super::ingest::extract_channel_id(&event) {
         if let Err(msg) =
diff --git a/crates/sprout-relay/src/handlers/mesh_signaling.rs b/crates/sprout-relay/src/handlers/mesh_signaling.rs
new file mode 100644
index 0000000000..239ca06910
--- /dev/null
+++ b/crates/sprout-relay/src/handlers/mesh_signaling.rs
@@ -0,0 +1,612 @@
+//! Mesh hole-punch signaling: the relay's only role in the v1 direct-iroh mesh.
+//!
+//! v1 mesh is "Sprout-coordinated direct iroh" — no server-side iroh relay/proxy.
+//! The relay's entire job for connectivity is: when a member asks to dial a peer
+//! it discovered via kind:30621, validate that BOTH ends are relay members, then
+//! emit a *paired* live "call-me-now" so both desktops hole-punch at the same
+//! moment. The relay never sees or stores the bulk iroh traffic — only this tiny
+//! control-plane exchange.
+//!
+//! Flow:
+//!   desktop A (member) reads 30621 → already holds B's `EndpointAddr` →
+//!   publishes KIND_MESH_CONNECT_REQUEST (24621) `#p=B` with both endpoint addrs →
+//!   relay validates B is a member → mints two relay-signed KIND_MESH_CALL_ME_NOW
+//!   (24622): one `#p=A` carrying B's addr, one `#p=B` carrying A's addr →
+//!   both fan out over the existing channel-less ephemeral path (local + Redis) →
+//!   both desktops dial simultaneously → direct QUIC.
+//!
+//! The relay is ENDPOINT-STATELESS here: the requester supplies both dial hints
+//! (it read them from the relay-signed 30621), so the relay only validates
+//! membership and pairs. Endpoint addrs are dial hints, never auth — membership
+//! is the gate.
+
+use std::sync::Arc;
+
+use nostr::{EventBuilder, Kind, Tag};
+use sprout_core::event::StoredEvent;
+use sprout_core::kind::KIND_MESH_CALL_ME_NOW;
+
+use crate::api::relay_members::{check_relay_membership, MembershipDecision};
+use crate::state::AppState;
+
+/// Parsed `KIND_MESH_CONNECT_REQUEST` (24621) content.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ConnectRequest {
+    /// Requester's own iroh EndpointAddr (base64 invite token) — sent to the peer.
+    pub self_endpoint_addr: String,
+    /// Peer's iroh EndpointAddr (base64 invite token), read by the requester from
+    /// the peer's kind:30621 serve target — sent back to the requester.
+    pub peer_endpoint_addr: String,
+    /// Requester's own iroh endpoint id (optional) — correlation/instrumentation
+    /// only, never trusted for auth. Copied into the peer's call-me-now.
+    pub self_endpoint_id: Option<String>,
+    /// Peer's iroh endpoint id (optional) — correlation/instrumentation only.
+    /// Copied into the requester's call-me-now so the desktop can target the
+    /// exact peer endpoint it picked from 30621 (multi-endpoint disambiguation).
+    pub peer_endpoint_id: Option<String>,
+    /// Correlates the two halves of one punch attempt.
+    pub attempt_id: String,
+}
+
+/// Outcome of validating + parsing a connect request, before any relay state is touched.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RequestError {
+    /// Content was not valid JSON in the expected shape.
+    Malformed(String),
+    /// No `#p` tag naming the target peer.
+    MissingTarget,
+}
+
+/// Parse the JSON content of a 24621 connect request. Pure — no I/O.
+pub fn parse_connect_request(content: &str) -> Result<ConnectRequest, RequestError> {
+    let v: serde_json::Value = serde_json::from_str(content)
+        .map_err(|e| RequestError::Malformed(format!("not JSON: {e}")))?;
+    let get = |k: &str| {
+        v.get(k)
+            .and_then(|x| x.as_str())
+            .filter(|s| !s.is_empty())
+            .map(str::to_string)
+    };
+    let self_endpoint_addr = get("self_endpoint_addr")
+        .ok_or_else(|| RequestError::Malformed("self_endpoint_addr".into()))?;
+    let peer_endpoint_addr = get("peer_endpoint_addr")
+        .ok_or_else(|| RequestError::Malformed("peer_endpoint_addr".into()))?;
+    let attempt_id =
+        get("attempt_id").ok_or_else(|| RequestError::Malformed("attempt_id".into()))?;
+    Ok(ConnectRequest {
+        self_endpoint_addr,
+        peer_endpoint_addr,
+        self_endpoint_id: get("self_endpoint_id"),
+        peer_endpoint_id: get("peer_endpoint_id"),
+        attempt_id,
+    })
+}
+
+/// Extract the single `#p` target pubkey (hex) from the request event's tags.
+/// If multiple `#p` tags are present, the first wins; multi-target mesh-connect
+/// is not supported in v1.
+pub fn extract_target_pubkey(event: &nostr::Event) -> Option<String> {
+    event.tags.iter().find_map(|t| {
+        let s = t.as_slice();
+        if s.len() >= 2 && s[0] == "p" {
+            Some(s[1].clone())
+        } else {
+            None
+        }
+    })
+}
+
+/// Build the JSON content for one call-me-now (24622) directed at `recipient`,
+/// telling it to dial `peer_endpoint_addr` (optionally `peer_endpoint_id` for
+/// multi-endpoint disambiguation). Pure — no I/O.
+pub fn call_me_now_content(
+    peer_endpoint_addr: &str,
+    peer_endpoint_id: Option<&str>,
+    attempt_id: &str,
+    expires_at: u64,
+) -> String {
+    let mut obj = serde_json::json!({
+        "v": 1,
+        "type": "sprout-iroh-call-me-now",
+        "peer_endpoint_addr": peer_endpoint_addr,
+        "attempt_id": attempt_id,
+        "expires_at": expires_at,
+    });
+    if let Some(eid) = peer_endpoint_id {
+        obj["peer_endpoint_id"] = serde_json::Value::String(eid.to_string());
+    }
+    obj.to_string()
+}
+
+/// Pure: does a membership decision admit a peer into the v1 mesh? Direct relay
+/// members (or open relays) only — `ViaOwner` (NIP-OA delegated) is intentionally
+/// NOT admitted in v1, keeping the mesh trust boundary tighter and legible. This
+/// is applied identically to BOTH the requester and the target, so the two ends
+/// are symmetric. Isolated as a pure fn so the trust gate is unit-testable
+/// without an `AppState`.
+pub fn membership_admits_mesh(decision: &MembershipDecision) -> bool {
+    matches!(
+        decision,
+        MembershipDecision::OpenRelay | MembershipDecision::Member
+    )
+}
+
+/// Seconds a call-me-now is valid; iroh's punch loop runs ~60s, so this bounds
+/// how stale a signal a desktop should act on.
+pub const CALL_ME_NOW_TTL_SECS: u64 = 60;
+
+/// Handle a verified KIND_MESH_CONNECT_REQUEST (24621) from an authenticated
+/// relay member. Validates the target is also a member, then emits the paired
+/// call-me-now to both ends. Returns Ok(()) on success or an Err(reason) string
+/// suitable for an OK(false) reply (reason is for the requester, not secret).
+pub async fn handle_connect_request(
+    state: &Arc<AppState>,
+    requester_pubkey_hex: &str,
+    event: &nostr::Event,
+) -> Result<(), String> {
+    let req = parse_connect_request(&event.content)
+        .map_err(|e| format!("invalid: malformed mesh connect request ({e:?})"))?;
+
+    let target_hex = extract_target_pubkey(event)
+        .ok_or_else(|| "invalid: mesh connect request missing #p target".to_string())?;
+
+    if target_hex == requester_pubkey_hex {
+        return Err("invalid: cannot mesh-connect to self".to_string());
+    }
+
+    // Membership gate, applied SYMMETRICALLY to both ends — direct relay members
+    // only, gated purely by relay access. The requester reached this handler via
+    // a NIP-42-authed WS, but that auth can be ViaOwner (NIP-OA delegated) when
+    // SPROUT_ALLOW_NIP_OA_AUTH is on; v1 mesh excludes delegated identities, so we
+    // re-check the requester here with no auth tag (which makes ViaOwner
+    // unreachable — only Member/OpenRelay/Denied) to match the target check.
+    require_mesh_member(state, requester_pubkey_hex)
+        .await
+        .map_err(|_| "restricted: delegated identities cannot initiate mesh in v1".to_string())?;
+
+    require_mesh_member(state, &target_hex)
+        .await
+        .map_err(|_| "restricted: target is not a relay member".to_string())?;
+
+    let expires_at = (chrono::Utc::now().timestamp().max(0) as u64) + CALL_ME_NOW_TTL_SECS;
+
+    // Pair: tell the requester to dial the peer's addr, and the peer to dial the
+    // requester's addr. Each is a relay-signed ephemeral #p-addressed event.
+    // endpoint_id (if supplied) is copied through for desktop multi-endpoint
+    // disambiguation — it is correlation metadata, never trusted for auth.
+    let to_requester = build_call_me_now(
+        state,
+        requester_pubkey_hex,
+        &req.peer_endpoint_addr,
+        req.peer_endpoint_id.as_deref(),
+        &req.attempt_id,
+        expires_at,
+    )?;
+    let to_target = build_call_me_now(
+        state,
+        &target_hex,
+        &req.self_endpoint_addr,
+        req.self_endpoint_id.as_deref(),
+        &req.attempt_id,
+        expires_at,
+    )?;
+
+    publish_channelless_ephemeral(state, &to_requester).await;
+    publish_channelless_ephemeral(state, &to_target).await;
+    Ok(())
+}
+
+/// Async: confirm `pubkey_hex` is a direct relay member admissible to the mesh.
+/// `None` auth_tag → ViaOwner is unreachable, so only Member/OpenRelay admit;
+/// everything else (Denied, ViaOwner-if-it-somehow-appeared, or a check error)
+/// FAILS CLOSED. Used symmetrically for requester and target.
+async fn require_mesh_member(state: &Arc<AppState>, pubkey_hex: &str) -> Result<(), ()> {
+    let bytes = hex::decode(pubkey_hex).map_err(|_| ())?;
+    match check_relay_membership(state, &bytes, None).await {
+        Ok(d) if membership_admits_mesh(&d) => Ok(()),
+        Ok(_) => Err(()),
+        Err(e) => {
+            tracing::warn!("mesh connect: membership check failed (fail-closed): {e}");
+            Err(())
+        }
+    }
+}
+
+/// Mint one relay-signed call-me-now (24622) addressed to `recipient_hex`.
+fn build_call_me_now(
+    state: &Arc<AppState>,
+    recipient_hex: &str,
+    peer_endpoint_addr: &str,
+    peer_endpoint_id: Option<&str>,
+    attempt_id: &str,
+    expires_at: u64,
+) -> Result<nostr::Event, String> {
+    let content = call_me_now_content(peer_endpoint_addr, peer_endpoint_id, attempt_id, expires_at);
+    let p_tag = Tag::parse(["p", recipient_hex])
+        .map_err(|e| format!("error: failed to build p tag: {e}"))?;
+    EventBuilder::new(Kind::Custom(KIND_MESH_CALL_ME_NOW as u16), content)
+        .tags([p_tag])
+        .sign_with_keys(&state.relay_keypair)
+        .map_err(|e| format!("error: failed to sign call-me-now: {e}"))
+}
+
+/// Publish a channel-less ephemeral event over the same path NIP-AB pairing uses:
+/// Redis fan-out (nil-UUID global routing key) for cross-pod, plus direct local
+/// WS fan-out. The recipient's desktop receives it via a REQ on `#p=self kind:24622`.
+///
+/// Observability note: call-me-now is delivered via `#p` subscription matching,
+/// so it is NOT private to the recipient — any member can REQ `kind:24622
+/// #p=<other_member>` and observe who that member is mesh-connecting to (and the
+/// endpoint addrs). This matches presence/typing being broadly observable and is
+/// intentional for v1. A conn-scoped private delivery channel would be needed to
+/// hide it.
+async fn publish_channelless_ephemeral(state: &Arc<AppState>, event: &nostr::Event) {
+    state.mark_local_event(&event.id);
+    if let Err(e) = state.pubsub.publish_event(uuid::Uuid::nil(), event).await {
+        state.local_event_ids.invalidate(&event.id.to_bytes());
+        tracing::warn!(event_id = %event.id, "mesh call-me-now global publish failed: {e}");
+    }
+    let stored = StoredEvent::new(event.clone(), None);
+    let matches = state.sub_registry.fan_out(&stored);
+    metrics::histogram!("sprout_fanout_recipients").record(matches.len() as f64);
+    if let Ok(event_json) = serde_json::to_string(event) {
+        for (target_conn_id, sub_id) in &matches {
+            let msg = format!(r#"["EVENT","{sub_id}",{event_json}]"#);
+            let _ = state.conn_manager.send_to(*target_conn_id, msg);
+        }
+    }
+}
+
+/// Handle a verified KIND_MESH_STATUS_REPORT (24620) from an authenticated relay
+/// member. The member reports its current mesh `/api/status` JSON; the relay
+/// sanitizes it and republishes a relay-signed kind:30621 discovery note keyed
+/// to the reporter (so members' notes never clobber each other). The report
+/// itself is ephemeral — only the relay's projection is durable. Membership is
+/// already enforced (the reporter is authenticated on a member-gated WS).
+pub async fn handle_status_report(
+    state: &Arc<AppState>,
+    reporter_pubkey_hex: &str,
+    event: &nostr::Event,
+) -> Result<(), String> {
+    // Same membership symmetry as handle_connect_request: a NIP-OA delegated
+    // (ViaOwner) identity is authed on the WS but is NOT a v1 mesh participant.
+    // If we let it report, the relay would advertise a serve_target under that
+    // pubkey that the connect path (which denies ViaOwner) then refuses — broken
+    // discovery. So gate the reporter the same way: direct members only, fail
+    // closed. Keeps all three desktop-facing mesh kinds consistent on delegation.
+    require_mesh_member(state, reporter_pubkey_hex)
+        .await
+        .map_err(|_| {
+            "restricted: delegated identities cannot report mesh status in v1".to_string()
+        })?;
+
+    let payload: serde_json::Value = serde_json::from_str(&event.content)
+        .map_err(|e| format!("invalid: mesh status report content is not JSON ({e})"))?;
+    crate::mesh_status_publisher::publish_mesh_status_from_payload(
+        state,
+        reporter_pubkey_hex,
+        &payload,
+    )
+    .await
+    .map_err(|e| {
+        tracing::warn!("mesh status report publish failed: {e}");
+        "error: failed to publish mesh status".to_string()
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_valid_request() {
+        let c = r#"{"self_endpoint_addr":"AAA","peer_endpoint_addr":"BBB","attempt_id":"x1"}"#;
+        let r = parse_connect_request(c).unwrap();
+        assert_eq!(r.self_endpoint_addr, "AAA");
+        assert_eq!(r.peer_endpoint_addr, "BBB");
+        assert_eq!(r.attempt_id, "x1");
+    }
+
+    #[test]
+    fn parse_rejects_missing_field() {
+        let c = r#"{"self_endpoint_addr":"AAA","attempt_id":"x1"}"#;
+        assert!(matches!(
+            parse_connect_request(c),
+            Err(RequestError::Malformed(_))
+        ));
+    }
+
+    #[test]
+    fn parse_rejects_empty_field() {
+        let c = r#"{"self_endpoint_addr":"","peer_endpoint_addr":"BBB","attempt_id":"x1"}"#;
+        assert!(matches!(
+            parse_connect_request(c),
+            Err(RequestError::Malformed(_))
+        ));
+    }
+
+    #[test]
+    fn parse_rejects_non_json() {
+        assert!(matches!(
+            parse_connect_request("not json"),
+            Err(RequestError::Malformed(_))
+        ));
+    }
+
+    #[test]
+    fn call_me_now_content_shape() {
+        let s = call_me_now_content("ENDPOINT", None, "att-1", 1234);
+        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
+        assert_eq!(v["type"], "sprout-iroh-call-me-now");
+        assert_eq!(v["peer_endpoint_addr"], "ENDPOINT");
+        assert_eq!(v["attempt_id"], "att-1");
+        assert_eq!(v["expires_at"], 1234);
+        assert_eq!(v["v"], 1);
+        // No endpoint id supplied → field omitted entirely.
+        assert!(v.get("peer_endpoint_id").is_none());
+    }
+
+    #[test]
+    fn call_me_now_content_includes_endpoint_id_when_present() {
+        let s = call_me_now_content("ENDPOINT", Some("EID-7"), "att-1", 1234);
+        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
+        assert_eq!(v["peer_endpoint_id"], "EID-7");
+    }
+
+    #[test]
+    fn parse_round_trips_optional_endpoint_ids() {
+        let c = r#"{"self_endpoint_addr":"A","peer_endpoint_addr":"B","self_endpoint_id":"SI","peer_endpoint_id":"PI","attempt_id":"x"}"#;
+        let r = parse_connect_request(c).unwrap();
+        assert_eq!(r.self_endpoint_id.as_deref(), Some("SI"));
+        assert_eq!(r.peer_endpoint_id.as_deref(), Some("PI"));
+        // endpoint ids are optional — absent is fine.
+        let c2 = r#"{"self_endpoint_addr":"A","peer_endpoint_addr":"B","attempt_id":"x"}"#;
+        let r2 = parse_connect_request(c2).unwrap();
+        assert_eq!(r2.self_endpoint_id, None);
+        assert_eq!(r2.peer_endpoint_id, None);
+    }
+
+    // ── Trust gate: membership_admits_mesh ──────────────────────────────────
+    // This is the single pure predicate behind the requester, target, AND
+    // reporter gates. v1 admits only direct relay members (or open relays);
+    // NIP-OA-delegated (ViaOwner) and Denied are excluded, symmetrically.
+
+    #[test]
+    fn member_and_open_relay_are_admitted() {
+        assert!(membership_admits_mesh(&MembershipDecision::Member));
+        assert!(membership_admits_mesh(&MembershipDecision::OpenRelay));
+    }
+
+    #[test]
+    fn denied_is_not_admitted() {
+        assert!(!membership_admits_mesh(&MembershipDecision::Denied));
+    }
+
+    #[test]
+    fn via_owner_is_not_admitted_in_v1() {
+        // Delegated identities are excluded from v1 mesh on every desktop-facing
+        // path (requester / target / reporter all run through this predicate).
+        let owner = nostr::Keys::generate().public_key();
+        assert!(!membership_admits_mesh(&MembershipDecision::ViaOwner(
+            owner
+        )));
+    }
+
+    #[test]
+    fn extract_target_takes_the_p_tag() {
+        let keys = nostr::Keys::generate();
+        let target = nostr::Keys::generate().public_key().to_hex();
+        let event = nostr::EventBuilder::new(nostr::Kind::Custom(24621), "{}")
+            .tags([nostr::Tag::parse(["p", &target]).unwrap()])
+            .sign_with_keys(&keys)
+            .unwrap();
+        assert_eq!(
+            extract_target_pubkey(&event).as_deref(),
+            Some(target.as_str())
+        );
+    }
+
+    #[test]
+    fn extract_target_none_without_p_tag() {
+        let keys = nostr::Keys::generate();
+        let event = nostr::EventBuilder::new(nostr::Kind::Custom(24621), "{}")
+            .sign_with_keys(&keys)
+            .unwrap();
+        assert_eq!(extract_target_pubkey(&event), None);
+    }
+
+    fn p_tag() -> nostr::SingleLetterTag {
+        nostr::SingleLetterTag::lowercase(nostr::Alphabet::P)
+    }
+
+    fn test_config() -> crate::config::Config {
+        let mut config = crate::config::Config::from_env().expect("default config loads");
+        config.require_relay_membership = false;
+        config.redis_url = "redis://127.0.0.1:1".to_string();
+        config
+    }
+
+    async fn test_state() -> std::sync::Arc<AppState> {
+        let config = test_config();
+        let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool");
+        let db = sprout_db::Db::from_pool(pool.clone());
+        let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
+            .create_pool(Some(deadpool_redis::Runtime::Tokio1))
+            .expect("redis pool");
+        let pubsub = std::sync::Arc::new(
+            sprout_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
+                .await
+                .expect("pubsub manager"),
+        );
+        let audit = sprout_audit::AuditService::new(pool);
+        let auth = sprout_auth::AuthService::new(config.auth.clone());
+        let search = sprout_search::SearchService::new(sprout_search::SearchConfig {
+            url: config.typesense_url.clone(),
+            api_key: config.typesense_key.clone(),
+            collection: "events".to_string(),
+        });
+        let workflow_engine = std::sync::Arc::new(sprout_workflow::WorkflowEngine::new(
+            db.clone(),
+            sprout_workflow::WorkflowConfig::default(),
+        ));
+        let media_storage = sprout_media::MediaStorage::new(&config.media).expect("media storage");
+        let (state, _audit_shutdown) = crate::state::AppState::new(
+            config,
+            db,
+            redis_pool,
+            audit,
+            pubsub,
+            auth,
+            search,
+            workflow_engine,
+            nostr::Keys::generate(),
+            media_storage,
+        );
+        std::sync::Arc::new(state)
+    }
+
+    fn register_call_me_now_sub(
+        state: &AppState,
+        recipient_hex: &str,
+        sub_id: &str,
+    ) -> (
+        uuid::Uuid,
+        tokio::sync::mpsc::Receiver<axum::extract::ws::Message>,
+    ) {
+        let conn_id = uuid::Uuid::new_v4();
+        let (tx, rx) = tokio::sync::mpsc::channel(10);
+        state.conn_manager.register(
+            conn_id,
+            tx,
+            tokio_util::sync::CancellationToken::new(),
+            std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)),
+            std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
+        );
+        state.sub_registry.register(
+            conn_id,
+            sub_id.to_string(),
+            vec![nostr::Filter::new()
+                .kind(nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16))
+                .custom_tags(p_tag(), [recipient_hex])],
+            None,
+        );
+        (conn_id, rx)
+    }
+
+    fn connect_request_event(target_hex: &str) -> nostr::Event {
+        let content = serde_json::json!({
+            "self_endpoint_addr": "SELF_ADDR",
+            "peer_endpoint_addr": "PEER_ADDR",
+            "self_endpoint_id": "SELF_ID",
+            "peer_endpoint_id": "PEER_ID",
+            "attempt_id": "attempt-1"
+        })
+        .to_string();
+        nostr::EventBuilder::new(nostr::Kind::Custom(24621), content)
+            .tags([nostr::Tag::parse(["p", target_hex]).unwrap()])
+            .sign_with_keys(&nostr::Keys::generate())
+            .unwrap()
+    }
+
+    fn event_from_ws_message(msg: axum::extract::ws::Message) -> nostr::Event {
+        let axum::extract::ws::Message::Text(text) = msg else {
+            panic!("expected text ws message");
+        };
+        let v: serde_json::Value = serde_json::from_str(&text).expect("EVENT frame JSON");
+        assert_eq!(v[0], "EVENT");
+        serde_json::from_value(v[2].clone()).expect("nostr event")
+    }
+
+    #[tokio::test]
+    async fn accepted_connect_request_emits_two_relay_signed_call_me_now_events() {
+        let state = test_state().await;
+        let requester_hex = nostr::Keys::generate().public_key().to_hex();
+        let target_hex = nostr::Keys::generate().public_key().to_hex();
+        let (_requester_conn, mut requester_rx) =
+            register_call_me_now_sub(&state, &requester_hex, "mesh_requester");
+        let (_target_conn, mut target_rx) =
+            register_call_me_now_sub(&state, &target_hex, "mesh_target");
+
+        handle_connect_request(&state, &requester_hex, &connect_request_event(&target_hex))
+            .await
+            .expect("open relay admits both peers and emits pair");
+
+        let requester_event = event_from_ws_message(
+            requester_rx
+                .try_recv()
+                .expect("requester receives call-me-now"),
+        );
+        let target_event =
+            event_from_ws_message(target_rx.try_recv().expect("target receives call-me-now"));
+        assert!(
+            requester_rx.try_recv().is_err(),
+            "requester gets exactly one event"
+        );
+        assert!(
+            target_rx.try_recv().is_err(),
+            "target gets exactly one event"
+        );
+
+        for event in [&requester_event, &target_event] {
+            assert_eq!(
+                event.kind,
+                nostr::Kind::Custom(KIND_MESH_CALL_ME_NOW as u16)
+            );
+            assert_eq!(event.pubkey, state.relay_keypair.public_key());
+            event.verify().expect("relay-signed event verifies");
+        }
+
+        assert_eq!(
+            extract_target_pubkey(&requester_event).as_deref(),
+            Some(requester_hex.as_str())
+        );
+        assert_eq!(
+            extract_target_pubkey(&target_event).as_deref(),
+            Some(target_hex.as_str())
+        );
+
+        let requester_content: serde_json::Value =
+            serde_json::from_str(&requester_event.content).expect("requester content JSON");
+        assert_eq!(requester_content["type"], "sprout-iroh-call-me-now");
+        assert_eq!(requester_content["peer_endpoint_addr"], "PEER_ADDR");
+        assert_eq!(requester_content["peer_endpoint_id"], "PEER_ID");
+        assert_eq!(requester_content["attempt_id"], "attempt-1");
+        assert!(requester_content["expires_at"].as_u64().is_some());
+
+        let target_content: serde_json::Value =
+            serde_json::from_str(&target_event.content).expect("target content JSON");
+        assert_eq!(target_content["type"], "sprout-iroh-call-me-now");
+        assert_eq!(target_content["peer_endpoint_addr"], "SELF_ADDR");
+        assert_eq!(target_content["peer_endpoint_id"], "SELF_ID");
+        assert_eq!(target_content["attempt_id"], "attempt-1");
+        assert_eq!(
+            target_content["expires_at"],
+            requester_content["expires_at"]
+        );
+    }
+
+    #[tokio::test]
+    async fn self_target_connect_request_emits_no_call_me_now_events() {
+        let state = test_state().await;
+        let requester_hex = nostr::Keys::generate().public_key().to_hex();
+        let target_hex = nostr::Keys::generate().public_key().to_hex();
+        let (_requester_conn, mut requester_rx) =
+            register_call_me_now_sub(&state, &requester_hex, "mesh_requester");
+        let (_target_conn, mut target_rx) =
+            register_call_me_now_sub(&state, &target_hex, "mesh_target");
+
+        let err = handle_connect_request(
+            &state,
+            &requester_hex,
+            &connect_request_event(&requester_hex),
+        )
+        .await
+        .expect_err("self-target is rejected before emitting");
+        assert!(err.contains("self"), "unexpected error: {err}");
+        assert!(
+            requester_rx.try_recv().is_err(),
+            "requester receives no event"
+        );
+        assert!(target_rx.try_recv().is_err(), "target receives no event");
+    }
+}
diff --git a/crates/sprout-relay/src/handlers/mod.rs b/crates/sprout-relay/src/handlers/mod.rs
index 7e5a456bd7..bc6a14ed13 100644
--- a/crates/sprout-relay/src/handlers/mod.rs
+++ b/crates/sprout-relay/src/handlers/mod.rs
@@ -14,6 +14,8 @@ pub mod identity_archive;
 pub mod imeta;
 /// Transport-neutral event ingestion pipeline.
 pub mod ingest;
+/// Mesh hole-punch signaling: validate membership + emit paired call-me-now.
+pub mod mesh_signaling;
 /// NIP-43 relay membership admin command handler (kinds 9030–9032).
 pub mod relay_admin;
 /// REQ handler — subscribe, deliver historical events, then EOSE.
diff --git a/crates/sprout-relay/src/iroh_relay.rs b/crates/sprout-relay/src/iroh_relay.rs
deleted file mode 100644
index f1bebc0c9d..0000000000
--- a/crates/sprout-relay/src/iroh_relay.rs
+++ /dev/null
@@ -1,236 +0,0 @@
-//! Sprout admission helpers for mesh-LLM iroh-relay connections.
-//!
-//! This module intentionally keeps the admission logic independent from the
-//! concrete `iroh-relay` server type for the first landing. Max's runtime lane
-//! owns adding the iroh dependency and wiring this decision into
-//! `AccessConfig::Restricted`. The security boundary lives here: a connecting
-//! endpoint must present a NIP-98 bearer signed by a direct relay member.
-//!
-//! V1 deliberately admits direct relay members only. NIP-OA owner delegation for
-//! agent-owned mesh nodes is a follow-up so the mesh compute trust boundary stays
-//! legible.
-
-use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
-use base64::Engine;
-use nostr::PublicKey;
-
-use crate::api::relay_members::MembershipDecision;
-use crate::state::AppState;
-
-/// Path component appended to the configured iroh-relay public URL.
-pub const IROH_RELAY_PATH: &str = "/relay";
-
-const NIP98_METHOD: &str = "GET";
-const MAX_BEARER_LEN: usize = 64 * 1024;
-
-/// Result of a mesh iroh admission check.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub enum IrohAdmissionDecision {
-    /// Allow the connection for this direct relay member.
-    Allow {
-        /// NIP-98-proven member pubkey.
-        pubkey: PublicKey,
-    },
-    /// Deny the connection. Reason is for server logs/tests, not client display.
-    Deny(String),
-}
-
-/// Decide whether an iroh-relay connection should be admitted.
-pub async fn decide_admission(
-    state: &AppState,
-    canonical_url: &str,
-    auth_token: Option<&str>,
-) -> IrohAdmissionDecision {
-    let pubkey = match verify_bearer(canonical_url, auth_token) {
-        Ok(pubkey) => pubkey,
-        Err(reason) => return IrohAdmissionDecision::Deny(reason),
-    };
-
-    let membership =
-        crate::api::relay_members::check_relay_membership(state, &pubkey.to_bytes(), None).await;
-    admission_from_membership(membership, pubkey)
-}
-
-/// Map a relay-membership outcome to an iroh admission decision.
-///
-/// This is the mesh admission *invariant*, isolated from any I/O so it can be
-/// asserted directly: relay membership is the only thing that admits. A direct
-/// member (or an open relay) is allowed; everything else — non-members,
-/// NIP-OA owner-delegated agents (denied in v1), and membership-check errors —
-/// is denied. Possession of dial metadata or a valid NIP-98 bearer is by itself
-/// never sufficient; only membership flips this to `Allow`.
-pub fn admission_from_membership(
-    membership: Result<MembershipDecision, String>,
-    pubkey: PublicKey,
-) -> IrohAdmissionDecision {
-    match membership {
-        Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => {
-            IrohAdmissionDecision::Allow { pubkey }
-        }
-        Ok(MembershipDecision::ViaOwner(_)) => IrohAdmissionDecision::Deny(
-            // v1 deliberately denies NIP-OA owner-delegated agents even when their
-            // owner is a relay member. HTTP endpoints accept that delegation; iroh
-            // admission does not, keeping the mesh-compute trust boundary tighter
-            // and legible. Lifting this is a follow-up gated on NIP-OA scope review.
-            "owner-delegated mesh admission is not enabled in v1".to_string(),
-        ),
-        Ok(MembershipDecision::Denied) => {
-            IrohAdmissionDecision::Deny(format!("not a relay member: {pubkey}"))
-        }
-        Err(error) => IrohAdmissionDecision::Deny(format!("membership check failed: {error}")),
-    }
-}
-
-/// Verify a base64-encoded NIP-98 bearer for the iroh-relay WebSocket URL.
-pub fn verify_bearer(canonical_url: &str, auth_token: Option<&str>) -> Result<PublicKey, String> {
-    let token = match auth_token {
-        Some(token) if !token.trim().is_empty() => token.trim(),
-        _ => return Err("missing bearer token".to_string()),
-    };
-    if token.len() > MAX_BEARER_LEN {
-        return Err(format!("bearer token exceeds {MAX_BEARER_LEN} bytes"));
-    }
-
-    let json = decode_bearer(token).ok_or_else(|| "bearer token is not base64 JSON".to_string())?;
-    sprout_auth::verify_nip98_event(&json, canonical_url, NIP98_METHOD, None)
-        .map_err(|error| format!("NIP-98 verification failed: {error}"))
-}
-
-fn decode_bearer(token: &str) -> Option<String> {
-    for engine in [STANDARD, URL_SAFE, STANDARD_NO_PAD, URL_SAFE_NO_PAD] {
-        if let Ok(bytes) = engine.decode(token) {
-            if let Ok(json) = String::from_utf8(bytes) {
-                return Some(json);
-            }
-        }
-    }
-    None
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use base64::engine::general_purpose::STANDARD;
-    use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
-
-    fn bearer_for(keys: &Keys, canonical_url: &str, method: &str) -> String {
-        let event = EventBuilder::new(Kind::HttpAuth, "")
-            .tags([
-                Tag::parse(["u", canonical_url]).unwrap(),
-                Tag::parse(["method", method]).unwrap(),
-            ])
-            .custom_created_at(Timestamp::now())
-            .sign_with_keys(keys)
-            .unwrap();
-        STANDARD.encode(serde_json::to_string(&event).unwrap())
-    }
-
-    #[test]
-    fn verify_bearer_accepts_valid_nip98() {
-        let keys = Keys::generate();
-        let url = "https://relay.example/iroh/relay";
-        let token = bearer_for(&keys, url, NIP98_METHOD);
-
-        assert_eq!(verify_bearer(url, Some(&token)).unwrap(), keys.public_key());
-    }
-
-    #[test]
-    fn verify_bearer_rejects_missing_token() {
-        assert!(verify_bearer("https://relay.example/iroh/relay", None).is_err());
-    }
-
-    #[test]
-    fn verify_bearer_rejects_wrong_method() {
-        let keys = Keys::generate();
-        let url = "https://relay.example/iroh/relay";
-        let token = bearer_for(&keys, url, "POST");
-
-        let error = verify_bearer(url, Some(&token)).unwrap_err();
-        assert!(error.contains("method"), "{error}");
-    }
-
-    #[test]
-    fn verify_bearer_rejects_wrong_url() {
-        let keys = Keys::generate();
-        let token = bearer_for(&keys, "https://other.example/iroh/relay", NIP98_METHOD);
-
-        let error = verify_bearer("https://relay.example/iroh/relay", Some(&token)).unwrap_err();
-        assert!(error.contains("URL"), "{error}");
-    }
-
-    #[test]
-    fn verify_bearer_rejects_expired_timestamp() {
-        // NIP-98's ±60s window must reject stale bearers — guards against
-        // observed-token replay outside the live admission moment.
-        let keys = Keys::generate();
-        let url = "https://relay.example/iroh/relay";
-        let stale = Timestamp::now() - 120u64;
-        let event = EventBuilder::new(Kind::HttpAuth, "")
-            .tags([
-                Tag::parse(["u", url]).unwrap(),
-                Tag::parse(["method", NIP98_METHOD]).unwrap(),
-            ])
-            .custom_created_at(stale)
-            .sign_with_keys(&keys)
-            .unwrap();
-        let token = STANDARD.encode(serde_json::to_string(&event).unwrap());
-
-        let error = verify_bearer(url, Some(&token)).unwrap_err();
-        assert!(error.contains("window"), "{error}");
-    }
-    // ── Admission invariant: relay membership is the ONLY factor ──────────────
-    //
-    // These assert the policy mapping directly (no db/AppState needed). The
-    // bearer-proof layer is covered by the verify_bearer tests above; here we
-    // pin that a *proven* identity is admitted iff it is a relay member.
-
-    fn any_pubkey() -> PublicKey {
-        Keys::generate().public_key()
-    }
-
-    #[test]
-    fn admission_allows_direct_relay_member() {
-        let pk = any_pubkey();
-        let decision = admission_from_membership(Ok(MembershipDecision::Member), pk);
-        assert_eq!(decision, IrohAdmissionDecision::Allow { pubkey: pk });
-    }
-
-    #[test]
-    fn admission_allows_when_relay_is_open() {
-        // require_relay_membership disabled → OpenRelay → admitted.
-        let pk = any_pubkey();
-        let decision = admission_from_membership(Ok(MembershipDecision::OpenRelay), pk);
-        assert_eq!(decision, IrohAdmissionDecision::Allow { pubkey: pk });
-    }
-
-    #[test]
-    fn admission_denies_non_member() {
-        // A valid Nostr identity that is not a relay member gets nothing.
-        let pk = any_pubkey();
-        let decision = admission_from_membership(Ok(MembershipDecision::Denied), pk);
-        assert!(matches!(decision, IrohAdmissionDecision::Deny(_)));
-    }
-
-    #[test]
-    fn admission_denies_owner_delegation_in_v1() {
-        // NIP-OA owner-delegated agents are explicitly NOT admitted to the mesh
-        // in v1, even though HTTP endpoints accept the same delegation.
-        let pk = any_pubkey();
-        let owner = any_pubkey();
-        let decision = admission_from_membership(Ok(MembershipDecision::ViaOwner(owner)), pk);
-        match decision {
-            IrohAdmissionDecision::Deny(reason) => {
-                assert!(reason.contains("owner-delegated"), "{reason}");
-            }
-            other => panic!("owner delegation must be denied, got {other:?}"),
-        }
-    }
-
-    #[test]
-    fn admission_denies_on_membership_check_error() {
-        // Fail closed: an errored membership lookup denies, never admits.
-        let pk = any_pubkey();
-        let decision = admission_from_membership(Err("db down".to_string()), pk);
-        assert!(matches!(decision, IrohAdmissionDecision::Deny(_)));
-    }
-}
diff --git a/crates/sprout-relay/src/lib.rs b/crates/sprout-relay/src/lib.rs
index ba35dcb986..42c51c1182 100644
--- a/crates/sprout-relay/src/lib.rs
+++ b/crates/sprout-relay/src/lib.rs
@@ -14,8 +14,6 @@ pub mod connection;
 pub mod error;
 /// WebSocket message handlers for NIP-01 client commands.
 pub mod handlers;
-/// Mesh iroh-relay admission helpers.
-pub mod iroh_relay;
 /// Relay-signed mesh-LLM status publisher.
 pub mod mesh_status_publisher;
 /// Prometheus metrics: recorder, upkeep, HTTP middleware.
diff --git a/crates/sprout-relay/src/mesh_status_publisher.rs b/crates/sprout-relay/src/mesh_status_publisher.rs
index 88dc9d901d..1c967cb138 100644
--- a/crates/sprout-relay/src/mesh_status_publisher.rs
+++ b/crates/sprout-relay/src/mesh_status_publisher.rs
@@ -17,8 +17,18 @@ use tracing::info;
 use crate::handlers::event::dispatch_persistent_event;
 use crate::state::AppState;
 
-/// Stable d-tag for the relay's mesh status event.
-pub const MESH_STATUS_D_TAG: &str = "sprout-relay-mesh";
+/// d-tag prefix for the relay's mesh status events. The full d-tag is
+/// `"<prefix>:<reporter_pubkey_hex>"` so each reporting member gets an isolated,
+/// independently-replaceable kind:30621 note (NIP-33 keys on `(kind, pubkey,
+/// d_tag)`, and the author is always the relay key here). This avoids two
+/// members' reports clobbering each other and sidesteps any read-modify-write
+/// race on a single shared note. Discovery reads all notes with the `k` tag.
+pub const MESH_STATUS_D_TAG_PREFIX: &str = "sprout-relay-mesh";
+
+/// Build the per-reporter d-tag for a mesh status note.
+pub fn mesh_status_d_tag(reporter_pubkey_hex: &str) -> String {
+    format!("{MESH_STATUS_D_TAG_PREFIX}:{reporter_pubkey_hex}")
+}
 
 /// Content schema discriminator.
 pub const MESH_STATUS_TYPE: &str = "sprout-mesh-status";
@@ -178,26 +188,29 @@ pub fn sanitize_mesh_status(payload: &Value, now_unix: u64) -> SproutMeshStatus
     }
 }
 
-/// Publish sanitized mesh status as a relay-signed kind:30621 event.
+/// Publish sanitized mesh status as a relay-signed kind:30621 event, keyed to
+/// the reporting member's pubkey so each member's note is isolated.
 pub async fn publish_mesh_status_from_payload(
     state: &Arc<AppState>,
+    reporter_pubkey_hex: &str,
     payload: &Value,
 ) -> anyhow::Result<()> {
     let now_unix = chrono::Utc::now().timestamp().max(0) as u64;
     let status = sanitize_mesh_status(payload, now_unix);
-    publish_mesh_status(state, &status).await
+    publish_mesh_status(state, reporter_pubkey_hex, &status).await
 }
 
 /// Publish a pre-sanitized mesh status. Exposed for tests and integration seams.
 pub async fn publish_mesh_status(
     state: &Arc<AppState>,
+    reporter_pubkey_hex: &str,
     status: &SproutMeshStatus,
 ) -> anyhow::Result<()> {
     let content = serde_json::to_string(status)?;
+    let d_tag = mesh_status_d_tag(reporter_pubkey_hex);
     let tags = vec![
         Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?,
-        Tag::parse(["d", MESH_STATUS_D_TAG])
-            .map_err(|e| anyhow::anyhow!("failed to build d tag: {e}"))?,
+        Tag::parse(["d", &d_tag]).map_err(|e| anyhow::anyhow!("failed to build d tag: {e}"))?,
         Tag::parse(["k", MESH_STATUS_TYPE])
             .map_err(|e| anyhow::anyhow!("failed to build k tag: {e}"))?,
     ];
@@ -209,7 +222,7 @@ pub async fn publish_mesh_status(
 
     let (stored, was_inserted) = state
         .db
-        .replace_parameterized_event(&event, MESH_STATUS_D_TAG, None)
+        .replace_parameterized_event(&event, &d_tag, None)
         .await?;
     if was_inserted {
         let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
@@ -278,6 +291,22 @@ fn push_target(targets: &mut Vec<MeshServeTarget>, target: MeshServeTarget) {
 mod tests {
     use super::*;
 
+    #[test]
+    fn d_tag_is_per_reporter_so_members_never_clobber() {
+        let a = "aa".repeat(32);
+        let b = "bb".repeat(32);
+        let d_a = mesh_status_d_tag(&a);
+        let d_b = mesh_status_d_tag(&b);
+        // Two different members → two different d-tags → isolated 30621 notes
+        // (NIP-33 keys on (kind, pubkey, d); the relay is always the author, so
+        // the d-tag is the only thing distinguishing reporters).
+        assert_ne!(d_a, d_b, "distinct reporters must get distinct d-tags");
+        assert!(d_a.starts_with(MESH_STATUS_D_TAG_PREFIX));
+        assert!(d_a.ends_with(&a));
+        // Same reporter → same d-tag → its later report replaces its own note.
+        assert_eq!(d_a, mesh_status_d_tag(&a));
+    }
+
     #[test]
     fn sanitizer_projects_models_and_endpoint_addr_without_raw_runtime() {
         let payload = serde_json::json!({
diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs
index fe2ae9f193..597ba61817 100644
--- a/crates/sprout-relay/src/state.rs
+++ b/crates/sprout-relay/src/state.rs
@@ -231,6 +231,12 @@ pub struct AppState {
     /// Key: agent pubkey bytes (32). Value: (count, window_start).
     /// 100 events/sec per agent — prevents relay/DB pressure from bursty telemetry.
     pub observer_rate_limiter: Arc<DashMap<[u8; 32], (u32, Instant)>>,
+    /// Per-requester sliding-window rate limiter for mesh connect requests
+    /// (kind 24621). Key: requester pubkey bytes (32). Value: (count,
+    /// window_start). Bounds the 1→2 call-me-now amplification: a member is
+    /// trusted, but a buggy desktop loop shouldn't make the relay sign+fan
+    /// unboundedly. 20/sec is far above any real interactive use.
+    pub mesh_connect_rate_limiter: Arc<DashMap<[u8; 32], (u32, Instant)>>,
     /// Cache for observer agent-owner authorization (kind 24200).
     /// Key: (agent_pubkey_bytes, owner_pubkey_bytes). Value: is_owner.
     /// agent_owner_pubkey is immutable so a long TTL (5 min) is safe.
@@ -376,6 +382,7 @@ impl AppState {
                     .build(),
             ),
             observer_rate_limiter: Arc::new(DashMap::new()),
+            mesh_connect_rate_limiter: Arc::new(DashMap::new()),
             observer_owner_cache: Arc::new(
                 moka::sync::Cache::builder()
                     .max_capacity(1_000)
diff --git a/crates/sprout-test-client/tests/e2e_mesh_llm.rs b/crates/sprout-test-client/tests/e2e_mesh_llm.rs
index e3f3fd0b99..c7779d782a 100644
--- a/crates/sprout-test-client/tests/e2e_mesh_llm.rs
+++ b/crates/sprout-test-client/tests/e2e_mesh_llm.rs
@@ -6,7 +6,7 @@
 //! All tests are `#[ignore]` by default — they need infra CI does not host
 //! (native llama, multi-node, model download). The deterministic trust
 //! invariants are unit-tested in `sprout-relay` (`mesh_status_publisher`,
-//! `iroh_relay`); this file is the opt-in full-stack acceptance layer.
+//! `mesh_signaling`); this file is the opt-in full-stack acceptance layer.
 //!
 //! # Running (manual / runbook)
 //!
@@ -32,7 +32,7 @@
 //! |---|-----------|-----------|-----------------|
 //! | 1 | member reads kind:30621 status w/ dial pointer, no secrets | `trust_member_reads_mesh_status` | relay `mesh_status_publisher` units |
 //! | 2 | non-member REQ for kind:30621 returns nothing | `trust_nonmember_read_denied` | — |
-//! | 3 | non-member iroh dial denied (NIP-98→membership) | runbook (needs iroh dial) | relay `iroh_relay` admission units |
+//! | 3 | non-member iroh dial denied (NIP-98→membership) | runbook (needs iroh dial) | relay `mesh_signaling` admission units |
 //! | 4 | B's agent completes a chat against A's model over mesh | `live_agent_completes_chat_over_mesh` | runbook |
 //! | 5 | dropped member → typed auth failure reaches lastError | runbook (desktop harness) | sprout-agent `-32001` unit |
 //! | 6 | split: model too big → 2 serve nodes → chat completes | `live_split_model_completes` | runbook |
diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 1d44452d30..9b43b98e7d 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -81,7 +81,7 @@ const overrides = new Map([
   ["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
   ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
   ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
-  ["src-tauri/src/lib.rs", 835], // + mesh_llm_stubs module (feature-gated no-op Tauri commands) + mesh command registrations + PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
+  ["src-tauri/src/lib.rs", 850], // + mesh_llm_stubs module (feature-gated no-op Tauri commands; incl mesh_dial_endpoint_addr + mesh_status_report_payload required for the default no-feature build's generate_handler! list) + mesh command registrations + PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
   ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
 ]);
 
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 944b0401fc..7cae9ca905 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -164,7 +164,7 @@ version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
 dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -175,7 +175,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
 dependencies = [
  "anstyle",
  "once_cell_polyfill",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -218,7 +218,7 @@ dependencies = [
  "objc2-foundation",
  "parking_lot",
  "percent-encoding",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
  "x11rb",
 ]
 
@@ -1234,7 +1234,7 @@ version = "3.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
 dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -1873,7 +1873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
 dependencies = [
  "data-encoding",
- "syn 1.0.109",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -2809,8 +2809,8 @@ dependencies = [
  "libc",
  "log",
  "rustversion",
- "windows-link 0.1.3",
- "windows-result 0.3.4",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
 ]
 
 [[package]]
@@ -3520,7 +3520,7 @@ dependencies = [
  "tokio",
  "tower-service",
  "tracing",
- "windows-registry 0.6.1",
+ "windows-registry 0.5.3",
 ]
 
 [[package]]
@@ -4542,7 +4542,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-api-client"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "hex",
  "mesh-llm-client",
@@ -4552,7 +4552,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-api-server"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "mesh-llm-api-client",
@@ -4563,7 +4563,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-client"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4595,11 +4595,12 @@ dependencies = [
 [[package]]
 name = "mesh-llm-config"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "dirs",
  "mesh-llm-types",
+ "semver",
  "serde",
  "skippy-protocol",
  "toml 0.9.12+spec-1.1.0",
@@ -4609,7 +4610,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-gpu-bench"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "cc",
@@ -4622,7 +4623,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-guardrails"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "serde",
  "serde_json",
@@ -4631,13 +4632,14 @@ dependencies = [
 [[package]]
 name = "mesh-llm-host-runtime"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "ansi-to-tui",
  "anyhow",
  "arboard",
  "argon2",
  "async-trait",
+ "axum",
  "base64 0.22.1",
  "bytes",
  "chacha20poly1305",
@@ -4647,6 +4649,8 @@ dependencies = [
  "crypto_box",
  "dirs",
  "ed25519-dalek",
+ "flate2",
+ "futures-util",
  "hex",
  "hf-hub",
  "http",
@@ -4662,6 +4666,7 @@ dependencies = [
  "mesh-llm-config",
  "mesh-llm-guardrails",
  "mesh-llm-identity",
+ "mesh-llm-native-runtime",
  "mesh-llm-node",
  "mesh-llm-plugin",
  "mesh-llm-plugin-manager",
@@ -4700,6 +4705,7 @@ dependencies = [
  "skippy-server",
  "skippy-topology",
  "tabwriter",
+ "tar",
  "tempfile",
  "thiserror 2.0.18",
  "tokio",
@@ -4716,7 +4722,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-identity"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "crypto_box",
  "ed25519-dalek",
@@ -4728,10 +4734,21 @@ dependencies = [
  "thiserror 2.0.18",
 ]
 
+[[package]]
+name = "mesh-llm-native-runtime"
+version = "0.68.0"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
+dependencies = [
+ "anyhow",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+]
+
 [[package]]
 name = "mesh-llm-node"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "mesh-llm-types",
@@ -4745,7 +4762,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-plugin"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4762,7 +4779,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-plugin-manager"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "dirs",
@@ -4780,7 +4797,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-protocol"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "hex",
@@ -4793,7 +4810,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-routing"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "iroh",
 ]
@@ -4801,7 +4818,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-sdk"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "mesh-llm-host-runtime",
@@ -4811,7 +4828,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-skills"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "dirs",
@@ -4822,7 +4839,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-system"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "chrono",
@@ -4844,7 +4861,7 @@ dependencies = [
 [[package]]
 name = "mesh-llm-types"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "hex",
  "serde",
@@ -4855,12 +4872,12 @@ dependencies = [
 [[package]]
 name = "mesh-llm-ui"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 
 [[package]]
 name = "mesh-mixture-of-agents"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "async-trait",
  "mesh-llm-guardrails",
@@ -4924,7 +4941,7 @@ dependencies = [
 [[package]]
 name = "model-artifact"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4935,7 +4952,7 @@ dependencies = [
 [[package]]
 name = "model-hf"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -4949,7 +4966,7 @@ dependencies = [
 [[package]]
 name = "model-package"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "bytes",
@@ -4969,7 +4986,7 @@ dependencies = [
 [[package]]
 name = "model-ref"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "serde",
 ]
@@ -4977,7 +4994,7 @@ dependencies = [
 [[package]]
 name = "model-resolver"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "model-artifact",
@@ -5515,7 +5532,7 @@ version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
 dependencies = [
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -5625,7 +5642,7 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
 dependencies = [
- "proc-macro-crate 3.5.0",
+ "proc-macro-crate 1.3.1",
  "proc-macro2",
  "quote",
  "syn 2.0.117",
@@ -6001,7 +6018,7 @@ dependencies = [
 [[package]]
 name = "openai-frontend"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "async-trait",
  "axum",
@@ -6871,7 +6888,7 @@ version = "0.14.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
 dependencies = [
- "heck 0.4.1",
+ "heck 0.5.0",
  "itertools",
  "log",
  "multimap",
@@ -7664,7 +7681,7 @@ dependencies = [
  "errno",
  "libc",
  "linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
 ]
 
 [[package]]
@@ -7973,7 +7990,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
 dependencies = [
  "libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
 ]
 
 [[package]]
@@ -8410,7 +8427,7 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
 [[package]]
 name = "skippy-cache"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "blake3",
@@ -8420,7 +8437,7 @@ dependencies = [
 [[package]]
 name = "skippy-coordinator"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "thiserror 2.0.18",
 ]
@@ -8428,17 +8445,17 @@ dependencies = [
 [[package]]
 name = "skippy-ffi"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 
 [[package]]
 name = "skippy-metrics"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 
 [[package]]
 name = "skippy-protocol"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "prost",
  "prost-build",
@@ -8449,7 +8466,7 @@ dependencies = [
 [[package]]
 name = "skippy-runtime"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "libc",
@@ -8463,7 +8480,7 @@ dependencies = [
 [[package]]
 name = "skippy-server"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -8491,7 +8508,7 @@ dependencies = [
 [[package]]
 name = "skippy-topology"
 version = "0.68.0"
-source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=bd16da4955a9dceee8f1e0a02c5814944f4e12a5#bd16da4955a9dceee8f1e0a02c5814944f4e12a5"
+source = "git+https://github.com/tlongwell-block/mesh-llm.git?rev=bc2f110625ccad2c3f8557b125c063bdc030fd21#bc2f110625ccad2c3f8557b125c063bdc030fd21"
 dependencies = [
  "serde",
  "serde_json",
@@ -11839,8 +11856,8 @@ dependencies = [
  "log",
  "serde",
  "thiserror 2.0.18",
- "windows 0.61.3",
- "windows-core 0.61.2",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
 ]
 
 [[package]]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 66fe4122c4..8138e09036 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -61,7 +61,7 @@ url = "2"
 sprout-core = { path = "../../crates/sprout-core" }
 sprout-persona = { path = "../../crates/sprout-persona" }
 sprout-sdk = { path = "../../crates/sprout-sdk" }
-mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "bd16da4955a9dceee8f1e0a02c5814944f4e12a5", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"], optional = true }
+mesh-llm-sdk = { git = "https://github.com/tlongwell-block/mesh-llm.git", rev = "bc2f110625ccad2c3f8557b125c063bdc030fd21", package = "mesh-llm-sdk", default-features = false, features = ["client", "serve"], optional = true }
 base64 = "0.22"
 sha2 = "0.11"
 tar = "0.4"
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 18a1fc95e0..f8f5ee8033 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -37,7 +37,7 @@ async fn ensure_relay_mesh_for_record(
     let Some(model_id) = relay_mesh_model_id(record) else {
         return Ok(());
     };
-    crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id).await?;
+    crate::commands::mesh_llm::ensure_client_node_for_model(state, model_id, None).await?;
     Ok(())
 }
 
diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs
index 63c0c0b42a..c9f9686590 100644
--- a/desktop/src-tauri/src/commands/mesh_llm.rs
+++ b/desktop/src-tauri/src/commands/mesh_llm.rs
@@ -1,6 +1,3 @@
-use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
-use nostr::{EventBuilder, JsonUtil, Kind, Tag};
-use reqwest::Method;
 use tauri::{AppHandle, State};
 
 use crate::{app_state::AppState, mesh_llm, relay};
@@ -21,15 +18,13 @@ pub async fn mesh_availability(
 pub async fn mesh_start_node(
     _app: AppHandle,
     state: State<'_, AppState>,
-    mut request: mesh_llm::StartMeshNodeRequest,
+    request: mesh_llm::StartMeshNodeRequest,
 ) -> CmdResult<mesh_llm::MeshNodeStatus> {
     let mut runtime = state.mesh_llm_runtime.lock().await;
     if runtime.is_some() {
         return Err("mesh node is already running".to_string());
     }
 
-    hydrate_private_relay_config(&state, &mut request).await?;
-
     let started = mesh_llm::DesktopMeshRuntime::start(request)
         .await
         .map_err(|error| error.to_string())?;
@@ -46,12 +41,13 @@ pub async fn mesh_ensure_client_node(
     state: State<'_, AppState>,
     request: mesh_llm::EnsureMeshClientRequest,
 ) -> CmdResult<mesh_llm::MeshNodeStatus> {
-    ensure_client_node_for_model(&state, request.model_id).await
+    ensure_client_node_for_model(&state, request.model_id, request.endpoint_addr).await
 }
 
 pub(crate) async fn ensure_client_node_for_model(
     state: &AppState,
     model_id: impl AsRef<str>,
+    endpoint_addr: Option<String>,
 ) -> CmdResult<mesh_llm::MeshNodeStatus> {
     let requested_model = model_id.as_ref().trim();
     if requested_model.is_empty() {
@@ -73,31 +69,40 @@ pub(crate) async fn ensure_client_node_for_model(
         }
     }
 
-    let availability = match relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await {
-        Ok(events) => mesh_llm::availability_from_events(events),
-        Err(error) => return Err(format!("failed to read relay mesh status: {error}")),
+    let join_token = match endpoint_addr
+        .map(|value| value.trim().to_string())
+        .filter(|value| !value.is_empty())
+    {
+        Some(value) => value,
+        None => {
+            let availability =
+                match relay::query_relay(state, &[mesh_llm::mesh_status_filter()]).await {
+                    Ok(events) => mesh_llm::availability_from_events(events),
+                    Err(error) => return Err(format!("failed to read relay mesh status: {error}")),
+                };
+            if !availability.available {
+                return Err(availability
+                    .reason
+                    .unwrap_or_else(|| "relay mesh is not available".to_string()));
+            }
+            let target = availability
+                .serve_targets
+                .iter()
+                .find(|target| target.model_id == requested_model)
+                .cloned()
+                .ok_or_else(|| {
+                    format!("relay mesh has no serve target for model {requested_model}")
+                })?;
+            target.endpoint_addr
+        }
     };
-    if !availability.available {
-        return Err(availability
-            .reason
-            .unwrap_or_else(|| "relay mesh is not available".to_string()));
-    }
-    let target = availability
-        .serve_targets
-        .iter()
-        .find(|target| target.model_id == requested_model)
-        .ok_or_else(|| format!("relay mesh has no serve target for model {requested_model}"))?;
 
-    let mut start = mesh_llm::StartMeshNodeRequest {
+    let start = mesh_llm::StartMeshNodeRequest {
         mode: mesh_llm::MeshNodeMode::Client,
         model_id: None,
         max_vram_gb: None,
-        join_token: Some(target.endpoint_addr.clone()),
-        iroh_relay_url: None,
-        iroh_relay_auth: None,
+        join_token: Some(join_token),
     };
-    hydrate_private_relay_config(state, &mut start).await?;
-
     let mut runtime = state.mesh_llm_runtime.lock().await;
     if runtime.is_some() {
         return Err("mesh node changed while starting relay mesh client".to_string());
@@ -113,80 +118,45 @@ pub(crate) async fn ensure_client_node_for_model(
     Ok(status)
 }
 
-async fn hydrate_private_relay_config(
-    state: &AppState,
-    request: &mut mesh_llm::StartMeshNodeRequest,
-) -> Result<(), String> {
-    if request.iroh_relay_url.is_none() {
-        request.iroh_relay_url = Some(fetch_iroh_relay_url(state).await?);
-    }
-    if request.iroh_relay_auth.is_none() {
-        let relay = request
-            .iroh_relay_url
-            .as_deref()
-            .ok_or_else(|| "relay did not advertise iroh_relay_url".to_string())?;
-        request.iroh_relay_auth = Some(build_iroh_relay_bearer(state, relay)?);
-    }
-    Ok(())
+#[derive(serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshDialEndpointRequest {
+    pub endpoint_addr: String,
 }
 
-async fn fetch_iroh_relay_url(state: &AppState) -> Result<String, String> {
-    #[derive(serde::Deserialize)]
-    struct Nip11Info {
-        iroh_relay_url: Option<String>,
-    }
-
-    let url = relay::relay_api_base_url_with_override(state);
-    let response = state
-        .http_client
-        .get(&url)
-        .header("Accept", "application/nostr+json")
-        .send()
-        .await
-        .map_err(|error| format!("failed to fetch relay NIP-11: {error}"))?;
-    if !response.status().is_success() {
-        return Err(relay::relay_error_message(response).await);
+#[tauri::command]
+pub async fn mesh_dial_endpoint_addr(
+    state: State<'_, AppState>,
+    request: MeshDialEndpointRequest,
+) -> CmdResult<mesh_llm::MeshNodeStatus> {
+    let endpoint_addr = request.endpoint_addr.trim();
+    if endpoint_addr.is_empty() {
+        return Err("endpointAddr is required".to_string());
     }
-    let info = response
-        .json::<Nip11Info>()
+    let runtime = state.mesh_llm_runtime.lock().await;
+    let Some(runtime) = runtime.as_ref() else {
+        return Err("mesh node is not running".to_string());
+    };
+    runtime
+        .dial_endpoint_addr(endpoint_addr)
         .await
-        .map_err(|error| format!("failed to parse relay NIP-11: {error}"))?;
-    info.iroh_relay_url
-        .filter(|value| !value.trim().is_empty())
-        .ok_or_else(|| "relay NIP-11 does not advertise iroh_relay_url".to_string())
-}
-
-fn build_iroh_relay_bearer(state: &AppState, relay_url: &str) -> Result<String, String> {
-    let canonical = canonical_iroh_relay_auth_url(relay_url)?;
-    let tags = vec![
-        Tag::parse(vec!["u", canonical.as_str()])
-            .map_err(|error| format!("url tag failed: {error}"))?,
-        Tag::parse(vec!["method", Method::GET.as_str()])
-            .map_err(|error| format!("method tag failed: {error}"))?,
-    ];
-    let keys = state.keys.lock().map_err(|error| error.to_string())?;
-    let event = EventBuilder::new(Kind::HttpAuth, "")
-        .tags(tags)
-        .sign_with_keys(&keys)
-        .map_err(|error| format!("sign failed: {error}"))?;
-    Ok(BASE64.encode(event.as_json().as_bytes()))
+        .map_err(|error| format!("mesh dial failed: {error}"))?;
+    runtime.status().await.map_err(|error| error.to_string())
 }
 
-fn canonical_iroh_relay_auth_url(relay_url: &str) -> Result<String, String> {
-    let mut parsed = url::Url::parse(relay_url)
-        .map_err(|error| format!("invalid iroh relay URL {relay_url:?}: {error}"))?;
-    parsed.set_query(None);
-    parsed.set_fragment(None);
-    let mut path = parsed.path().trim_end_matches('/').to_string();
-    if !path.ends_with("/relay") {
-        if path.is_empty() {
-            path = "/relay".to_string();
-        } else {
-            path.push_str("/relay");
-        }
+#[tauri::command]
+pub async fn mesh_status_report_payload(
+    state: State<'_, AppState>,
+) -> CmdResult<Option<serde_json::Value>> {
+    let runtime = state.mesh_llm_runtime.lock().await;
+    match runtime.as_ref() {
+        Some(runtime) => runtime
+            .status_report_payload()
+            .await
+            .map(Some)
+            .map_err(|error| error.to_string()),
+        None => Ok(None),
     }
-    parsed.set_path(&path);
-    Ok(parsed.to_string().trim_end_matches('/').to_string())
 }
 
 #[tauri::command]
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 4738269399..97b6e161c4 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -65,6 +65,21 @@ mod mesh_llm_stubs {
     pub fn mesh_agent_preset(_request: serde_json::Value) -> CmdResult<serde_json::Value> {
         Err("mesh-llm feature not enabled".to_string())
     }
+
+    #[tauri::command]
+    pub async fn mesh_dial_endpoint_addr(
+        _state: State<'_, AppState>,
+        _request: serde_json::Value,
+    ) -> CmdResult<serde_json::Value> {
+        Err("mesh-llm feature not enabled".to_string())
+    }
+
+    #[tauri::command]
+    pub async fn mesh_status_report_payload(
+        _state: State<'_, AppState>,
+    ) -> CmdResult<Option<serde_json::Value>> {
+        Err("mesh-llm feature not enabled".to_string())
+    }
 }
 
 #[cfg(not(feature = "mesh-llm"))]
@@ -640,6 +655,8 @@ pub fn run() {
             mesh_availability,
             mesh_start_node,
             mesh_ensure_client_node,
+            mesh_dial_endpoint_addr,
+            mesh_status_report_payload,
             mesh_stop_node,
             mesh_node_status,
             mesh_installed_models,
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index e3e16dd788..75cc437856 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -112,7 +112,7 @@ pub async fn restore_managed_agents_on_launch(
                 continue;
             };
             if let Err(error) =
-                crate::commands::ensure_client_node_for_model(&state, model_id).await
+                crate::commands::ensure_client_node_for_model(&state, model_id, None).await
             {
                 persist_restore_error(app, &state, &record.pubkey, error)?;
                 mesh_preflight_failures.insert(record.pubkey.clone());
diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs
new file mode 100644
index 0000000000..fd9ef0abcc
--- /dev/null
+++ b/desktop/src-tauri/src/mesh_llm/discovery.rs
@@ -0,0 +1,176 @@
+use std::collections::BTreeMap;
+
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
+
+use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, MESH_STATUS_KIND};
+
+fn dedupe_targets(targets: Vec<MeshServeTarget>) -> Vec<MeshServeTarget> {
+    let mut by_endpoint = BTreeMap::<String, MeshServeTarget>::new();
+    for target in targets {
+        by_endpoint
+            .entry(target.endpoint_addr.clone())
+            .or_insert(target);
+    }
+    by_endpoint.into_values().collect()
+}
+
+pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
+    if events.is_empty() {
+        return MeshAvailability::unavailable("relay mesh status is not published yet");
+    }
+
+    // Relay status is now per reporter (d=sprout-relay-mesh:<pubkey>), so a
+    // query returns multiple replaceable events. Aggregate them; do not pick the
+    // newest single event or one member's machines hide everyone else's.
+    let mut all_targets = Vec::<MeshServeTarget>::new();
+    let mut all_models = Vec::<MeshModelOption>::new();
+    let mut saw_valid_status = false;
+
+    for event in events {
+        let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
+            continue;
+        };
+        saw_valid_status = true;
+        let reporter_pubkey = reporter_pubkey_from_status_event(&event);
+        let mut serve_targets = content
+            .get("serveTargets")
+            .or_else(|| content.get("serve_targets"))
+            .cloned()
+            .and_then(|value| serde_json::from_value::<Vec<MeshServeTarget>>(value).ok())
+            .unwrap_or_default()
+            .into_iter()
+            .map(|mut target| {
+                if target.reporter_pubkey.is_none() {
+                    target.reporter_pubkey = reporter_pubkey.clone();
+                }
+                if target.endpoint_id.is_none() {
+                    target.endpoint_id = endpoint_id_from_invite_token(&target.endpoint_addr);
+                }
+                if target.device_id.is_none() {
+                    target.device_id = target.endpoint_id.clone();
+                }
+                if target.device_name.is_none() {
+                    target.device_name = target
+                        .node_name
+                        .clone()
+                        .or_else(|| target.endpoint_id.as_deref().map(short_endpoint_label));
+                }
+                target
+            })
+            .collect::<Vec<_>>();
+
+        let mut models = content
+            .get("models")
+            .cloned()
+            .and_then(|value| serde_json::from_value::<Vec<MeshModelOption>>(value).ok())
+            .unwrap_or_else(|| {
+                dedupe_models(
+                    serve_targets
+                        .iter()
+                        .map(|target| MeshModelOption {
+                            id: target.model_id.clone(),
+                            name: target.model_name.clone(),
+                        })
+                        .collect(),
+                )
+            });
+        all_targets.append(&mut serve_targets);
+        all_models.append(&mut models);
+    }
+
+    if !saw_valid_status {
+        return MeshAvailability::unavailable("relay mesh status is malformed");
+    }
+
+    let serve_targets = dedupe_targets(all_targets);
+    let models = dedupe_models(all_models);
+    let available = !serve_targets.is_empty();
+    MeshAvailability {
+        capable: true,
+        admitted: true,
+        available,
+        reason: if available {
+            None
+        } else {
+            Some("no relay mesh serve targets are available".to_string())
+        },
+        models,
+        serve_targets,
+    }
+}
+
+pub fn mesh_status_filter() -> serde_json::Value {
+    serde_json::json!({
+        "kinds": [MESH_STATUS_KIND],
+        "#k": ["sprout-mesh-status"],
+        "limit": 100
+    })
+}
+
+fn reporter_pubkey_from_status_event(event: &nostr::Event) -> Option<String> {
+    event.tags.iter().find_map(|tag| {
+        let slice = tag.as_slice();
+        let d = slice.get(1)?;
+        if slice.first().is_some_and(|name| name == "d") {
+            d.strip_prefix("sprout-relay-mesh:")
+                .map(ToString::to_string)
+        } else {
+            None
+        }
+    })
+}
+
+pub(super) fn enrich_status_payload_identity(
+    payload: &mut serde_json::Value,
+    invite_token: Option<&str>,
+) {
+    let endpoint_id = endpoint_id_from_status(payload, invite_token);
+    let device_name = device_name_from_status(payload, endpoint_id.as_deref());
+    if let Some(endpoint_id) = endpoint_id {
+        payload["endpointId"] = serde_json::Value::String(endpoint_id.clone());
+        payload["deviceId"] = serde_json::Value::String(endpoint_id);
+    }
+    if let Some(device_name) = device_name {
+        payload["deviceName"] = serde_json::Value::String(device_name);
+    }
+}
+
+pub(super) fn endpoint_id_from_status(
+    payload: &serde_json::Value,
+    invite_token: Option<&str>,
+) -> Option<String> {
+    string_value(payload, "endpointId")
+        .or_else(|| string_value(payload, "endpoint_id"))
+        .or_else(|| string_value(payload, "node_id"))
+        .or_else(|| invite_token.and_then(endpoint_id_from_invite_token))
+}
+
+pub(super) fn device_name_from_status(
+    payload: &serde_json::Value,
+    endpoint_id: Option<&str>,
+) -> Option<String> {
+    string_value(payload, "deviceName")
+        .or_else(|| string_value(payload, "device_name"))
+        .or_else(|| string_value(payload, "my_hostname"))
+        .or_else(|| string_value(payload, "hostname"))
+        .or_else(|| endpoint_id.map(short_endpoint_label))
+}
+
+fn endpoint_id_from_invite_token(invite_token: &str) -> Option<String> {
+    let json = URL_SAFE_NO_PAD.decode(invite_token).ok()?;
+    let value = serde_json::from_slice::<serde_json::Value>(&json).ok()?;
+    string_value(&value, "id")
+}
+
+fn string_value(value: &serde_json::Value, key: &str) -> Option<String> {
+    value
+        .get(key)
+        .and_then(serde_json::Value::as_str)
+        .map(str::trim)
+        .filter(|value| !value.is_empty())
+        .map(ToString::to_string)
+}
+
+fn short_endpoint_label(endpoint_id: &str) -> String {
+    endpoint_id.chars().take(12).collect()
+}
diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs
index 77ee4c3749..3eb3670103 100644
--- a/desktop/src-tauri/src/mesh_llm/mod.rs
+++ b/desktop/src-tauri/src/mesh_llm/mod.rs
@@ -1,12 +1,17 @@
 use std::collections::BTreeMap;
 
+mod discovery;
+pub use discovery::{availability_from_events, mesh_status_filter};
+use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
+
 use mesh_llm_sdk::{client, serve, EmbeddedNodeHandle, MeshDiscoveryMode};
 use serde::{Deserialize, Serialize};
 
 const DEFAULT_MESH_API_PORT: u16 = 9337;
 const DEFAULT_MESH_CONSOLE_PORT: u16 = 3131;
 const MESH_STATUS_KIND: u64 = 30_621;
-const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1";
+const MESH_API_PORT_ENV: &str = "SPROUT_MESH_API_PORT";
+const MESH_CONSOLE_PORT_ENV: &str = "SPROUT_MESH_CONSOLE_PORT";
 const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local";
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -24,6 +29,14 @@ pub struct MeshServeTarget {
     pub endpoint_addr: String,
     pub node_name: Option<String>,
     pub capacity: Option<MeshTargetCapacity>,
+    #[serde(default)]
+    pub reporter_pubkey: Option<String>,
+    #[serde(default)]
+    pub endpoint_id: Option<String>,
+    #[serde(default)]
+    pub device_id: Option<String>,
+    #[serde(default)]
+    pub device_name: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -122,16 +135,14 @@ pub struct StartMeshNodeRequest {
     pub max_vram_gb: Option<u64>,
     #[serde(default)]
     pub join_token: Option<String>,
-    #[serde(default)]
-    pub iroh_relay_url: Option<String>,
-    #[serde(default)]
-    pub iroh_relay_auth: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 pub struct EnsureMeshClientRequest {
     pub model_id: String,
+    #[serde(default)]
+    pub endpoint_addr: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -146,6 +157,12 @@ pub struct MeshNodeStatus {
     pub model_name: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub invite_token: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub endpoint_id: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub device_id: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub device_name: Option<String>,
 }
 
 pub fn stopped_status() -> MeshNodeStatus {
@@ -158,6 +175,9 @@ pub fn stopped_status() -> MeshNodeStatus {
         model_id: None,
         model_name: None,
         invite_token: None,
+        endpoint_id: None,
+        device_id: None,
+        device_name: None,
     }
 }
 
@@ -183,10 +203,11 @@ impl DesktopMeshRuntime {
                     .ok_or_else(|| anyhow::anyhow!("modelId is required for serve mode"))?;
                 let mut builder = serve::EmbeddedServeConfig::builder()
                     .model(model)
-                    .api_port(DEFAULT_MESH_API_PORT)
-                    .console_port(DEFAULT_MESH_CONSOLE_PORT)
+                    .api_port(mesh_api_port()?)
+                    .console_port(mesh_console_port()?)
                     .publish(false)
                     .auto_join(false)
+                    .disable_iroh_relays(true)
                     .discovery_mode(MeshDiscoveryMode::Nostr)
                     .console_ui(true);
                 if let Some(max_vram_gb) = request.max_vram_gb {
@@ -195,31 +216,20 @@ impl DesktopMeshRuntime {
                 if let Some(join_token) = request.join_token.as_deref() {
                     builder = builder.join_token(join_token);
                 }
-                if let Some(relay) = request.iroh_relay_url.as_deref() {
-                    builder = builder.iroh_relay(relay);
-                    if let Some(auth) = request.iroh_relay_auth.as_deref() {
-                        builder = builder.iroh_relay_auth(relay, auth);
-                    }
-                }
                 serve::start(builder.build()).await?
             }
             MeshNodeMode::Client => {
                 let mut builder = client::EmbeddedClientConfig::builder()
-                    .api_port(DEFAULT_MESH_API_PORT)
-                    .console_port(DEFAULT_MESH_CONSOLE_PORT)
+                    .api_port(mesh_api_port()?)
+                    .console_port(mesh_console_port()?)
                     .publish(false)
                     .auto_join(false)
+                    .disable_iroh_relays(true)
                     .discovery_mode(MeshDiscoveryMode::Nostr)
                     .console_ui(true);
                 if let Some(join_token) = request.join_token.as_deref() {
                     builder = builder.join_token(join_token);
                 }
-                if let Some(relay) = request.iroh_relay_url.as_deref() {
-                    builder = builder.iroh_relay(relay);
-                    if let Some(auth) = request.iroh_relay_auth.as_deref() {
-                        builder = builder.iroh_relay_auth(relay, auth);
-                    }
-                }
                 client::start(builder.build()).await?
             }
         };
@@ -237,6 +247,17 @@ impl DesktopMeshRuntime {
         self.status_from_sdk(status)
     }
 
+    pub async fn status_report_payload(&self) -> anyhow::Result<serde_json::Value> {
+        let status = self.handle.status().await?;
+        let mut payload = status.payload;
+        enrich_status_payload_identity(&mut payload, status.invite_token.as_deref());
+        Ok(payload)
+    }
+
+    pub async fn dial_endpoint_addr(&self, endpoint_addr: impl Into<String>) -> anyhow::Result<()> {
+        self.handle.join_token(endpoint_addr).await
+    }
+
     pub async fn installed_models(&self) -> anyhow::Result<Vec<MeshModelOption>> {
         let status = self.handle.status().await?;
         Ok(models_from_status_payload(Some(&status.payload)))
@@ -247,6 +268,9 @@ impl DesktopMeshRuntime {
         status: mesh_llm_sdk::EmbeddedNodeStatus,
     ) -> anyhow::Result<MeshNodeStatus> {
         let health = health_from_payload(&status.payload);
+        let endpoint_id = endpoint_id_from_status(&status.payload, status.invite_token.as_deref());
+        let device_name = device_name_from_status(&status.payload, endpoint_id.as_deref());
+        let device_id = endpoint_id.clone();
         Ok(MeshNodeStatus {
             state: if matches!(health.status, MeshHealthStatus::Failed) {
                 MeshNodeState::Failed
@@ -260,6 +284,9 @@ impl DesktopMeshRuntime {
             model_id: self.model_id.clone(),
             model_name: self.model_name.clone(),
             invite_token: status.invite_token,
+            endpoint_id,
+            device_id,
+            device_name,
         })
     }
 
@@ -268,16 +295,40 @@ impl DesktopMeshRuntime {
     }
 }
 
+fn mesh_api_port() -> anyhow::Result<u16> {
+    mesh_port_from_env(MESH_API_PORT_ENV, DEFAULT_MESH_API_PORT)
+}
+
+fn mesh_console_port() -> anyhow::Result<u16> {
+    mesh_port_from_env(MESH_CONSOLE_PORT_ENV, DEFAULT_MESH_CONSOLE_PORT)
+}
+
+fn mesh_port_from_env(name: &str, default: u16) -> anyhow::Result<u16> {
+    let Some(raw) = std::env::var(name)
+        .ok()
+        .map(|value| value.trim().to_string())
+        .filter(|value| !value.is_empty())
+    else {
+        return Ok(default);
+    };
+    let port = raw
+        .parse::<u16>()
+        .map_err(|error| anyhow::anyhow!("{name} must be a TCP port (got {raw:?}): {error}"))?;
+    if port == 0 {
+        anyhow::bail!("{name} must be a non-zero TCP port");
+    }
+    Ok(port)
+}
+
+fn relay_mesh_api_base_url() -> Result<String, String> {
+    let port = mesh_api_port().map_err(|error| error.to_string())?;
+    Ok(format!("http://127.0.0.1:{port}/v1"))
+}
+
 fn validate_no_leak_request(request: &StartMeshNodeRequest) -> anyhow::Result<()> {
     if request.join_token.as_deref().is_some_and(str::is_empty) {
         anyhow::bail!("joinToken cannot be empty when provided");
     }
-    if request.iroh_relay_url.as_deref().is_none_or(str::is_empty) {
-        anyhow::bail!("relay NIP-11 must advertise iroh_relay_url before starting mesh");
-    }
-    if request.iroh_relay_auth.as_deref().is_none_or(str::is_empty) {
-        anyhow::bail!("iroh relay admission bearer is required before starting mesh");
-    }
     Ok(())
 }
 
@@ -373,7 +424,7 @@ fn push_model(out: &mut Vec<MeshModelOption>, id: &str, name: Option<String>) {
     });
 }
 
-fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption> {
+pub(super) fn dedupe_models(models: Vec<MeshModelOption>) -> Vec<MeshModelOption> {
     let mut by_id = BTreeMap::<String, Option<String>>::new();
     for model in models {
         by_id
@@ -427,7 +478,7 @@ pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset,
             ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()),
             (
                 "OPENAI_COMPAT_BASE_URL".to_string(),
-                RELAY_MESH_API_BASE_URL.to_string(),
+                relay_mesh_api_base_url()?,
             ),
             ("OPENAI_COMPAT_MODEL".to_string(), model.to_string()),
             (
@@ -439,60 +490,6 @@ pub fn agent_preset(request: MeshAgentPresetRequest) -> Result<MeshAgentPreset,
     })
 }
 
-pub fn availability_from_events(events: Vec<nostr::Event>) -> MeshAvailability {
-    let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
-        return MeshAvailability::unavailable("relay mesh status is not published yet");
-    };
-
-    let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
-        return MeshAvailability::unavailable("relay mesh status is malformed");
-    };
-
-    let serve_targets = content
-        .get("serveTargets")
-        .or_else(|| content.get("serve_targets"))
-        .cloned()
-        .and_then(|value| serde_json::from_value::<Vec<MeshServeTarget>>(value).ok())
-        .unwrap_or_default();
-    let models = content
-        .get("models")
-        .cloned()
-        .and_then(|value| serde_json::from_value::<Vec<MeshModelOption>>(value).ok())
-        .unwrap_or_else(|| {
-            dedupe_models(
-                serve_targets
-                    .iter()
-                    .map(|target| MeshModelOption {
-                        id: target.model_id.clone(),
-                        name: target.model_name.clone(),
-                    })
-                    .collect(),
-            )
-        });
-
-    let available = !serve_targets.is_empty();
-    MeshAvailability {
-        capable: true,
-        admitted: true,
-        available,
-        reason: if available {
-            None
-        } else {
-            Some("no relay mesh serve targets are available".to_string())
-        },
-        models,
-        serve_targets,
-    }
-}
-
-pub fn mesh_status_filter() -> serde_json::Value {
-    serde_json::json!({
-        "kinds": [MESH_STATUS_KIND],
-        "#d": ["sprout-relay-mesh"],
-        "limit": 1
-    })
-}
-
 #[cfg(test)]
 #[path = "mod_tests.rs"]
 mod mod_tests;
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 8ff8eadc42..5461fee7d7 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -53,6 +53,7 @@ import {
   type SettingsSection,
 } from "@/features/settings/ui/SettingsPanels";
 import { HuddleBar, HuddleProvider } from "@/features/huddle";
+import { useMeshRelayOrchestrator } from "@/features/mesh-compute/hooks/useMeshRelayOrchestrator";
 import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
 import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
 import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
@@ -198,6 +199,7 @@ export function AppShell() {
   const startupReady = useDeferredStartup();
 
   const identityQuery = useIdentityQuery();
+  useMeshRelayOrchestrator(identityQuery.data?.pubkey);
   const profileQuery = useProfileQuery();
   const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
   usePresenceSubscription();
diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx
index a92e5cc9bd..650c45e83f 100644
--- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx
+++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx
@@ -36,7 +36,8 @@ import {
 } from "./ProviderConfigFields";
 import { CreateAgentRespondToField } from "./RespondToField";
 import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection";
-import { meshEnsureClientNode } from "@/shared/api/tauriMesh";
+import type { MeshServeTarget } from "@/shared/api/tauriMesh";
+import { startRelayMeshClientForTarget } from "@/features/mesh-compute/startRelayMeshClientForTarget";
 import { useLastRuntimeProvider } from "@/features/agents/lib/useLastRuntimeProvider";
 
 // ── Dialog ────────────────────────────────────────────────────────────────────
@@ -95,6 +96,9 @@ export function CreateAgentDialog({
   // input carries `model: meshModelId`.
   const [useMesh, setUseMesh] = React.useState(false);
   const [meshModelId, setMeshModelId] = React.useState("");
+  const [meshTarget, setMeshTarget] = React.useState<MeshServeTarget | null>(
+    null,
+  );
   const [meshClientError, setMeshClientError] = React.useState<string | null>(
     null,
   );
@@ -317,9 +321,8 @@ export function CreateAgentDialog({
     // fields and config schema are only known after a successful probe.
     !(isProviderMode && !probedProvider) &&
     providerConfigComplete &&
-    // Relay-mesh mode requires the user to have picked a model from the
-    // availability dropdown — without a model the preset is empty.
-    !(useMesh && meshModelId.trim().length === 0) &&
+    // Relay-mesh mode requires a concrete serve target, not just a model name.
+    !(useMesh && (meshModelId.trim().length === 0 || meshTarget == null)) &&
     respondToValid &&
     !createMutation.isPending;
 
@@ -328,7 +331,7 @@ export function CreateAgentDialog({
     try {
       if (useMesh) {
         try {
-          await meshEnsureClientNode(meshModelId.trim());
+          await startRelayMeshClientForTarget(meshModelId.trim(), meshTarget);
         } catch (err) {
           setMeshClientError(err instanceof Error ? err.message : String(err));
           return;
@@ -434,6 +437,7 @@ export function CreateAgentDialog({
                 envVars,
               }}
               modelId={meshModelId}
+              targetEndpointAddr={meshTarget?.endpointAddr ?? ""}
               onModelIdChange={(nextId, patch) => {
                 setMeshModelId(nextId);
                 if (patch == null) return;
@@ -446,6 +450,7 @@ export function CreateAgentDialog({
                 setMcpCommand(patch.mcpCommand);
                 setEnvVars(patch.envVars);
               }}
+              onTargetChange={setMeshTarget}
               onUseMeshChange={(next) => {
                 setUseMesh(next);
                 if (!next) {
@@ -454,6 +459,7 @@ export function CreateAgentDialog({
                   // fields keep whatever the user had — they can re-pick
                   // ACP runtime or stay with the preset values, their call.
                   setMeshModelId("");
+                  setMeshTarget(null);
                 }
               }}
               useMesh={useMesh}
diff --git a/desktop/src/features/mesh-compute/hooks/useMeshRelayOrchestrator.ts b/desktop/src/features/mesh-compute/hooks/useMeshRelayOrchestrator.ts
new file mode 100644
index 0000000000..de49693c4a
--- /dev/null
+++ b/desktop/src/features/mesh-compute/hooks/useMeshRelayOrchestrator.ts
@@ -0,0 +1,94 @@
+import * as React from "react";
+
+import {
+  meshDialEndpointAddr,
+  meshStatusReportPayload,
+  type MeshCallMeNow,
+} from "@/shared/api/tauriMesh";
+import type { RelayEvent } from "@/shared/api/types";
+import {
+  publishMeshStatusReport,
+  subscribeToMeshCallMeNow,
+} from "@/shared/api/relayMeshSignaling";
+
+const STATUS_REPORT_INTERVAL_MS = 15_000;
+
+function parseCallMeNow(event: RelayEvent): MeshCallMeNow | null {
+  try {
+    const payload = JSON.parse(event.content) as Partial<MeshCallMeNow>;
+    if (
+      payload.v !== 1 ||
+      payload.type !== "sprout-iroh-call-me-now" ||
+      typeof payload.peer_endpoint_addr !== "string" ||
+      typeof payload.attempt_id !== "string" ||
+      typeof payload.expires_at !== "number"
+    ) {
+      return null;
+    }
+    return payload as MeshCallMeNow;
+  } catch {
+    return null;
+  }
+}
+
+async function publishCurrentMeshStatus() {
+  const payload = await meshStatusReportPayload();
+  if (payload == null) return;
+  await publishMeshStatusReport(payload);
+}
+
+/**
+ * Keeps Sprout's relay-owned mesh discovery and punch signaling in sync with the
+ * local embedded mesh node. The relay only coordinates tiny control-plane
+ * messages; all iroh traffic remains desktop-to-desktop.
+ */
+export function useMeshRelayOrchestrator(pubkey: string | undefined) {
+  React.useEffect(() => {
+    if (!pubkey) return;
+
+    let disposed = false;
+    let unsubscribeCallMeNow: (() => Promise<void>) | null = null;
+
+    const handleCallMeNow = (event: RelayEvent) => {
+      const payload = parseCallMeNow(event);
+      if (!payload) return;
+      const now = Math.floor(Date.now() / 1_000);
+      if (payload.expires_at < now) return;
+
+      void meshDialEndpointAddr(payload.peer_endpoint_addr).catch((error) => {
+        console.warn("mesh call-me-now dial failed", {
+          attemptId: payload.attempt_id,
+          peerEndpointId: payload.peer_endpoint_id,
+          error,
+        });
+      });
+    };
+
+    subscribeToMeshCallMeNow(pubkey, handleCallMeNow)
+      .then((unsubscribe) => {
+        if (disposed) {
+          void unsubscribe();
+          return;
+        }
+        unsubscribeCallMeNow = unsubscribe;
+      })
+      .catch((error) => {
+        console.warn("mesh call-me-now subscription failed", error);
+      });
+
+    const report = () => {
+      void publishCurrentMeshStatus().catch((error) => {
+        // Status reports are best-effort; a stale report expires relay-side.
+        console.debug("mesh status report skipped", error);
+      });
+    };
+    report();
+    const interval = window.setInterval(report, STATUS_REPORT_INTERVAL_MS);
+
+    return () => {
+      disposed = true;
+      window.clearInterval(interval);
+      if (unsubscribeCallMeNow) void unsubscribeCallMeNow();
+    };
+  }, [pubkey]);
+}
diff --git a/desktop/src/features/mesh-compute/startRelayMeshClientForTarget.ts b/desktop/src/features/mesh-compute/startRelayMeshClientForTarget.ts
new file mode 100644
index 0000000000..ba9bcb5bcc
--- /dev/null
+++ b/desktop/src/features/mesh-compute/startRelayMeshClientForTarget.ts
@@ -0,0 +1,28 @@
+import { publishMeshConnectRequest } from "@/shared/api/relayMeshSignaling";
+import {
+  meshEnsureClientNode,
+  type MeshServeTarget,
+} from "@/shared/api/tauriMesh";
+
+export async function startRelayMeshClientForTarget(
+  modelId: string,
+  target: MeshServeTarget | null,
+): Promise<void> {
+  const status = await meshEnsureClientNode(modelId, target);
+  if (!target?.reporterPubkey) {
+    throw new Error(
+      "Selected relay mesh target is missing its reporter pubkey.",
+    );
+  }
+  if (!status.inviteToken) {
+    throw new Error("Local mesh client did not publish an endpoint address.");
+  }
+  await publishMeshConnectRequest({
+    targetPubkey: target.reporterPubkey,
+    selfEndpointAddr: status.inviteToken,
+    peerEndpointAddr: target.endpointAddr,
+    attemptId: crypto.randomUUID(),
+    selfEndpointId: status.endpointId ?? null,
+    peerEndpointId: target.endpointId ?? null,
+  });
+}
diff --git a/desktop/src/features/mesh-compute/ui/RelayMeshAgentSection.tsx b/desktop/src/features/mesh-compute/ui/RelayMeshAgentSection.tsx
index b61d7787b4..3a9fdb0b4f 100644
--- a/desktop/src/features/mesh-compute/ui/RelayMeshAgentSection.tsx
+++ b/desktop/src/features/mesh-compute/ui/RelayMeshAgentSection.tsx
@@ -1,7 +1,7 @@
 import * as React from "react";
 import { AlertCircle, Network } from "lucide-react";
 
-import { meshAgentPreset } from "@/shared/api/tauriMesh";
+import { meshAgentPreset, type MeshServeTarget } from "@/shared/api/tauriMesh";
 import { Switch } from "@/shared/ui/switch";
 
 import {
@@ -24,9 +24,10 @@ import { useMeshAvailability } from "../hooks/useMeshAvailability";
 export function RelayMeshAgentSection({
   current,
   useMesh,
-  modelId,
+  targetEndpointAddr,
   onUseMeshChange,
   onModelIdChange,
+  onTargetChange,
 }: {
   /**
    * Current draft state of the *fields the preset would overwrite*. Used to
@@ -42,7 +43,8 @@ export function RelayMeshAgentSection({
     envVars: Record<string, string>;
   };
   useMesh: boolean;
-  modelId: string;
+  modelId: string; // Parent-owned selected model id; retained for API symmetry with onModelIdChange.
+  targetEndpointAddr: string;
   onUseMeshChange: (next: boolean) => void;
   /**
    * Fires when the user picks a model. The parent should fan out the
@@ -54,6 +56,7 @@ export function RelayMeshAgentSection({
     nextModelId: string,
     patch: ReturnType<typeof meshAgentPresetPatch> | null,
   ) => void;
+  onTargetChange: (target: MeshServeTarget | null) => void;
 }) {
   const { availability, error } = useMeshAvailability();
   const [presetError, setPresetError] = React.useState<string | null>(null);
@@ -68,25 +71,46 @@ export function RelayMeshAgentSection({
   // an arbitrary one — the warning must reflect what'll actually happen.
   const [overrides, setOverrides] = React.useState<string[]>([]);
 
-  async function pickModel(nextModelId: string) {
-    if (nextModelId === "") {
+  const targets = availability?.serveTargets ?? [];
+  const selectedValue = targetEndpointAddr;
+
+  async function pickTarget(endpointAddr: string) {
+    if (endpointAddr === "") {
+      onTargetChange(null);
       onModelIdChange("", null);
       setOverrides([]);
       setPresetError(null);
       return;
     }
+    const target = targets.find(
+      (candidate) => candidate.endpointAddr === endpointAddr,
+    );
+    if (!target) {
+      onTargetChange(null);
+      onModelIdChange("", null);
+      setPresetError("Selected relay mesh target is no longer available.");
+      return;
+    }
     setPresetError(null);
     try {
-      const preset = await meshAgentPreset(nextModelId);
+      const preset = await meshAgentPreset(target.modelId);
       const patch = meshAgentPresetPatch(preset);
       setOverrides(detectMeshPresetOverrides(current, preset));
-      onModelIdChange(nextModelId, patch);
+      onTargetChange(target);
+      onModelIdChange(target.modelId, patch);
     } catch (err) {
       setPresetError(err instanceof Error ? err.message : String(err));
-      onModelIdChange(nextModelId, null);
+      onTargetChange(target);
+      onModelIdChange(target.modelId, null);
     }
   }
 
+  function targetLabel(target: MeshServeTarget) {
+    const model = target.modelName ?? target.modelId;
+    const device = target.deviceName ?? target.nodeName ?? target.endpointId;
+    return device ? `${model} — ${device}` : model;
+  }
+
   return (
     <div className="space-y-3 rounded-2xl border border-border/70 bg-muted/10 px-4 py-3">
       <div className="flex items-center justify-between gap-4">
@@ -125,13 +149,13 @@ export function RelayMeshAgentSection({
             className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
             data-testid="agent-relay-mesh-model"
             id="agent-relay-mesh-model"
-            onChange={(e) => void pickModel(e.target.value)}
-            value={modelId}
+            onChange={(e) => void pickTarget(e.target.value)}
+            value={selectedValue}
           >
-            <option value="">Choose a model…</option>
-            {availability?.models.map((model) => (
-              <option key={model.id} value={model.id}>
-                {model.name ?? model.id}
+            <option value="">Choose a target…</option>
+            {targets.map((target) => (
+              <option key={target.endpointAddr} value={target.endpointAddr}>
+                {targetLabel(target)}
               </option>
             ))}
           </select>
diff --git a/desktop/src/shared/api/relayMeshSignaling.ts b/desktop/src/shared/api/relayMeshSignaling.ts
new file mode 100644
index 0000000000..906b7b1e2d
--- /dev/null
+++ b/desktop/src/shared/api/relayMeshSignaling.ts
@@ -0,0 +1,68 @@
+import { signRelayEvent } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+import { relayClient } from "@/shared/api/relayClient";
+import {
+  KIND_MESH_CALL_ME_NOW,
+  KIND_MESH_CONNECT_REQUEST,
+  KIND_MESH_STATUS_REPORT,
+} from "@/shared/constants/kinds";
+
+export async function publishMeshStatusReport(
+  payload: Record<string, unknown>,
+): Promise<void> {
+  await relayClient.preconnect();
+  const event = await signRelayEvent({
+    kind: KIND_MESH_STATUS_REPORT,
+    content: JSON.stringify(payload),
+    tags: [],
+  });
+  await relayClient.publishEvent(
+    event,
+    "Timed out publishing mesh status.",
+    "Failed to publish mesh status.",
+  );
+}
+
+export async function publishMeshConnectRequest(input: {
+  targetPubkey: string;
+  selfEndpointAddr: string;
+  peerEndpointAddr: string;
+  attemptId: string;
+  selfEndpointId?: string | null;
+  peerEndpointId?: string | null;
+}): Promise<void> {
+  await relayClient.preconnect();
+  const content: Record<string, unknown> = {
+    v: 1,
+    self_endpoint_addr: input.selfEndpointAddr,
+    peer_endpoint_addr: input.peerEndpointAddr,
+    attempt_id: input.attemptId,
+  };
+  if (input.selfEndpointId) content.self_endpoint_id = input.selfEndpointId;
+  if (input.peerEndpointId) content.peer_endpoint_id = input.peerEndpointId;
+  const event = await signRelayEvent({
+    kind: KIND_MESH_CONNECT_REQUEST,
+    content: JSON.stringify(content),
+    tags: [["p", input.targetPubkey]],
+  });
+  await relayClient.publishEvent(
+    event,
+    "Timed out requesting mesh connection.",
+    "Failed to request mesh connection.",
+  );
+}
+
+export function subscribeToMeshCallMeNow(
+  pubkey: string,
+  onEvent: (event: RelayEvent) => void,
+) {
+  return relayClient.subscribeLive(
+    {
+      kinds: [KIND_MESH_CALL_ME_NOW],
+      "#p": [pubkey],
+      limit: 0,
+      since: Math.max(0, Math.floor(Date.now() / 1_000) - 5),
+    },
+    onEvent,
+  );
+}
diff --git a/desktop/src/shared/api/tauriMesh.ts b/desktop/src/shared/api/tauriMesh.ts
index 54e33a7d72..16b9fa66de 100644
--- a/desktop/src/shared/api/tauriMesh.ts
+++ b/desktop/src/shared/api/tauriMesh.ts
@@ -15,6 +15,10 @@ export type MeshServeTarget = {
   endpointAddr: string;
   nodeName: string | null;
   capacity: { vramGb: number | null } | null;
+  reporterPubkey?: string | null;
+  endpointId?: string | null;
+  deviceId?: string | null;
+  deviceName?: string | null;
 };
 
 export type MeshAvailability = {
@@ -39,8 +43,6 @@ export type StartMeshNodeRequest = {
   modelId?: string;
   maxVramGb?: number;
   joinToken?: string;
-  irohRelayUrl?: string;
-  irohRelayAuth?: string;
 };
 
 export type MeshNodeStatus = {
@@ -52,6 +54,18 @@ export type MeshNodeStatus = {
   modelId: string | null;
   modelName: string | null;
   inviteToken?: string | null;
+  endpointId?: string | null;
+  deviceId?: string | null;
+  deviceName?: string | null;
+};
+
+export type MeshCallMeNow = {
+  v: 1;
+  type: "sprout-iroh-call-me-now";
+  peer_endpoint_addr: string;
+  peer_endpoint_id?: string;
+  attempt_id: string;
+  expires_at: number;
 };
 
 export type MeshAgentPreset = {
@@ -77,12 +91,35 @@ export async function meshStartNode(
 
 export async function meshEnsureClientNode(
   modelId: string,
+  target?: MeshServeTarget | null,
 ): Promise<MeshNodeStatus> {
   return await invokeTauri<MeshNodeStatus>("mesh_ensure_client_node", {
-    request: { modelId },
+    request: {
+      modelId,
+      endpointAddr: target?.endpointAddr,
+      reporterPubkey: target?.reporterPubkey,
+      peerEndpointId: target?.endpointId,
+    },
   });
 }
 
+export async function meshDialEndpointAddr(
+  endpointAddr: string,
+): Promise<MeshNodeStatus> {
+  return await invokeTauri<MeshNodeStatus>("mesh_dial_endpoint_addr", {
+    request: { endpointAddr },
+  });
+}
+
+export async function meshStatusReportPayload(): Promise<Record<
+  string,
+  unknown
+> | null> {
+  return await invokeTauri<Record<string, unknown> | null>(
+    "mesh_status_report_payload",
+  );
+}
+
 export async function meshStopNode(): Promise<MeshNodeStatus> {
   return await invokeTauri<MeshNodeStatus>("mesh_stop_node");
 }
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
index bed88adad6..21d8e21e9d 100644
--- a/desktop/src/shared/constants/kinds.ts
+++ b/desktop/src/shared/constants/kinds.ts
@@ -22,6 +22,9 @@ export const KIND_READ_STATE = 30078;
 export const KIND_CHANNEL_SECTIONS = 30078;
 export const KIND_USER_STATUS = 30315;
 export const KIND_AGENT_OBSERVER_FRAME = 24200;
+export const KIND_MESH_STATUS_REPORT = 24620;
+export const KIND_MESH_CONNECT_REQUEST = 24621;
+export const KIND_MESH_CALL_ME_NOW = 24622;
 export const KIND_REPO_ANNOUNCEMENT = 30617;
 
 // Human-visible "new content" message kinds. Used as the unread trigger set
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 6803a36fc0..64121eb36a 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -4827,6 +4827,16 @@ function sendToMockSocket(args: {
       return;
     }
 
+    // Mesh control events (24620 status report, 24621 connect request) are not
+    // channel messages — they carry a `p` tag, not an `h` tag. The real relay
+    // accepts them after membership/shape checks; the mock just ACKs so the
+    // desktop mesh flow (publishMeshConnectRequest) can proceed. We do not model
+    // the paired 24622 here; that belongs in a dedicated call-me-now test.
+    if (event.kind === 24620 || event.kind === 24621) {
+      sendWsText(socket.handler, ["OK", event.id, true, ""]);
+      return;
+    }
+
     const channelId = getChannelIdFromTags(event.tags);
     if (!channelId) {
       sendWsText(socket.handler, [
@@ -4951,7 +4961,10 @@ export function maybeInstallE2eTauriMocks() {
     consoleUrl: null,
     modelId: mockMeshState.models[0]?.id ?? null,
     modelName: mockMeshState.models[0]?.name ?? null,
-    inviteToken: null,
+    inviteToken: state === "running" ? "mock-endpoint-addr" : null,
+    endpointId: state === "running" ? "mock-endpoint-id" : null,
+    deviceId: state === "running" ? "mock-endpoint-id" : null,
+    deviceName: state === "running" ? "Mock desktop" : null,
   });
   const handleMockCommand = async (command: string, payload: unknown) => {
     const activeConfig = getConfig();
@@ -4966,7 +4979,17 @@ export function maybeInstallE2eTauriMocks() {
           available: mockMeshState.admitted,
           reason: mockMeshState.admitted ? null : mockMeshState.denyReason,
           models: mockMeshState.models,
-          serveTargets: [],
+          serveTargets: mockMeshState.models.map((model) => ({
+            modelId: model.id,
+            modelName: model.name,
+            endpointAddr: "mock-endpoint-addr",
+            nodeName: "Mock desktop",
+            capacity: { vramGb: null },
+            reporterPubkey: identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey,
+            endpointId: "mock-endpoint-id",
+            deviceId: "mock-endpoint-id",
+            deviceName: "Mock desktop",
+          })),
         };
       case "mesh_installed_models":
         return mockMeshState.models;
@@ -4991,7 +5014,22 @@ export function maybeInstallE2eTauriMocks() {
         if (!mockMeshState.admitted) {
           throw new Error(mockMeshState.denyReason);
         }
+        mockMeshState.nodeState = "running";
+        mockMeshState.nodeMode = "client";
         return meshNodeStatus("running", "client");
+      case "mesh_dial_endpoint_addr":
+        return meshNodeStatus("running", mockMeshState.nodeMode ?? "client");
+      case "mesh_status_report_payload":
+        return mockMeshState.nodeState === "running"
+          ? {
+              token: "mock-endpoint-addr",
+              node_id: "mock-endpoint-id",
+              endpointId: "mock-endpoint-id",
+              deviceId: "mock-endpoint-id",
+              deviceName: "Mock desktop",
+              hosted_models: mockMeshState.models.map((model) => model.id),
+            }
+          : null;
       case "mesh_agent_preset": {
         const req = (payload as { request?: { modelId?: string } } | null)
           ?.request;
diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts
index b4f3ad166b..4cd0d6415e 100644
--- a/desktop/tests/e2e/mesh-compute.spec.ts
+++ b/desktop/tests/e2e/mesh-compute.spec.ts
@@ -106,7 +106,7 @@ test("Run-on-relay-mesh ensures the client node BEFORE spawning the agent", asyn
   await toggle.click();
   await page
     .getByTestId("agent-relay-mesh-model")
-    .selectOption("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M");
+    .selectOption({ label: "SmolLM2 135M — Mock desktop" });
 
   await expect(page.getByTestId("create-agent-submit")).toBeEnabled({
     timeout: 10_000,
diff --git a/docs/mesh-llm-local-build.md b/docs/mesh-llm-local-build.md
index 4785a03aa0..8a4cb7ce97 100644
--- a/docs/mesh-llm-local-build.md
+++ b/docs/mesh-llm-local-build.md
@@ -50,19 +50,26 @@ Do not use dynamic-link locally unless you already have compatible `llama`,
 `llama-common`, and `mtmd` dynamic libraries. The default static build is the
 supported local path for M1.
 
-## Current privacy limitation: public STUN
-
-Sprout Desktop now refuses to start an embedded mesh node unless the active relay
-advertises a Sprout-owned `iroh_relay_url`, and it passes a fresh NIP-98 bearer
-to that relay. This prevents mesh-llm's empty-relay fallback to public iroh relay
-URLs.
-
-mesh-llm `bd16da4` still performs raw public STUN on startup to discover the
-host's public IP (`stun.l.google.com`, `stun.cloudflare.com`, or
-`stun.stunprotocol.org`) and may include that public address in its invite token.
-That behavior is inside mesh-llm's host runtime and is not currently exposed as
-an SDK option. Treat it as a v1 limitation until mesh exposes a disable-public-
-STUN / relay-only-addressing knob.
+## Connectivity model: public iroh relays off, raw STUN on (WAN)
+
+Sprout Desktop starts the embedded mesh node with `disable_iroh_relays(true)`
+(mesh-llm fork rev `bc2f1106`, `RelayPolicy::ExplicitlyDisabled`): **no public
+iroh relays** (no `*.iroh.link` traffic, no relay transport), so there is no
+empty-relay fallback to public infrastructure.
+
+Raw STUN **remains on** under this policy: the node discovers its public address
+(via `stun.l.google.com` / `stun.cloudflare.com`) and injects it into the invite
+token / `EndpointAddr`. That address rides the relay-signed `24621`/`24622`
+call-me-now exchange, and peers hole-punch directly over UDP — so this works over
+**WAN**, not just LAN. STUN is a "what's my public IP" lookup, not a relay or a
+data path; the Sprout relay performs the address-exchange coordination.
+
+Residual limit (intentional v1): with iroh relays off there is **no relay
+transport fallback**, so two peers both behind **symmetric NATs** may fail to
+hole-punch. Works for the common cases (≥1 side cone-NAT / port-forwarded /
+server). A hosted private-iroh-relay fallback (relay advertises an
+`iroh_relay_url` + NIP-98 auth) is the planned follow-up once the relay side is
+wired end-to-end.
 
 ## Mesh-compute e2e acceptance matrix
 
@@ -74,7 +81,7 @@ coverage into three layers and are explicit about what is real vs mocked.
 | # | What it proves | Where | Real / Mocked | Runs in CI? | How to run |
 |---|----------------|-------|---------------|-------------|------------|
 | 1 | serve node + client node + mesh routing + **real inference** | `crates/sprout-relay/examples/mesh_serve_client_smoke.rs` | **REAL** (loads a model, runs inference, joins a real mesh) | No — hardware-gated | `just mesh-e2e-hardware` (or `cargo run -p sprout-relay --example mesh_serve_client_smoke`) |
-| 2 | admission **invariant**: relay membership is the only factor | `crates/sprout-relay/src/iroh_relay.rs` (`admission_from_membership` tests) | REAL policy logic, no I/O | **Yes** | `cargo test -p sprout-relay iroh_relay::tests::admission` |
+| 2 | admission **invariant**: relay membership is the only factor | `crates/sprout-relay/src/handlers/mesh_signaling.rs` (`*_admitted` / `denied_is_not_admitted` tests) | REAL policy logic, no I/O | **Yes** | `cargo test -p sprout-relay mesh_signaling` |
 | 2b | live db-membership admission + member/non-member status reads | `crates/sprout-test-client/tests/e2e_mesh_llm.rs` (`trust_*`) | REAL relay over ws | No — env-gated (`MEMBER_NSEC`/`STRANGER_NSEC`, live relay) | see that file's module docs |
 | 3 | desktop UI contract: Share-compute start/stop, Run-on-relay-mesh preset, **ensure-before-spawn** order, membership-gated toggle | `desktop/tests/e2e/mesh-compute.spec.ts` | UI REAL, Tauri mesh commands MOCKED via the e2e bridge | **Yes** | `cd desktop && pnpm test:e2e:integration -- mesh-compute.spec.ts` |
 
@@ -87,8 +94,9 @@ coverage into three layers and are explicit about what is real vs mocked.
   token, and asserts a chat completion *routed through the client* returns
   `finish_reason=stop` with non-empty content. Verified locally with
   SmolLM2-135M; point `MESH_SMOKE_MODEL` at a larger `.gguf` for scale.
-  Note: even with mDNS discovery the join bootstraps through mesh's public iroh
-  relay at the pinned rev — see the STUN limitation above.
+  Note: with `disable_iroh_relays(true)` the join bootstraps via STUN-discovered
+  public addresses + relay-signed call-me-now (no public iroh relay) — see the
+  connectivity model above.
 - **Layer 2 proves the auth invariant without faking it.** The policy mapping
   (`MembershipDecision` → admit/deny) is exercised directly: member → allow,
   open relay → allow, non-member → deny, owner-delegation → deny (v1),
diff --git a/justfile b/justfile
index 5caf8f552d..e6b97880b0 100644
--- a/justfile
+++ b/justfile
@@ -198,9 +198,9 @@ desktop-e2e-smoke:
 desktop-e2e-integration: _ensure-migrations
     cd {{desktop_dir}} && pnpm test:e2e:integration
 
-# Mesh-compute e2e: the CI-safe layers (admission invariant + Playwright UI)
+# Mesh-compute e2e: the CI-safe layers (relay mesh signaling invariants + Playwright UI)
 mesh-e2e:
-    cargo test -p sprout-relay iroh_relay::tests::admission
+    cargo test -p sprout-relay mesh_signaling
     cd {{desktop_dir}} && pnpm test:e2e:integration -- mesh-compute.spec.ts
 
 # Mesh-compute Layer 1: REAL serve->client->inference on this machine (not CI)
