diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx
index 81cda0e0eff..c8ed3aa0568 100644
--- a/apps/desktop/src/app/settings/model-settings.tsx
+++ b/apps/desktop/src/app/settings/model-settings.tsx
@@ -182,6 +182,13 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const providerOptions = providers.length ? providers : NO_PROVIDERS
+ // MoA reference/aggregator slots must never be the moa virtual provider —
+ // that would create a recursive MoA tree (the backend rejects it on save).
+ // Hide it from the slot selectors so it isn't offered as a dead choice.
+ const moaSlotProviderOptions = providerOptions.filter(
+ provider => (provider.slug || '').toLowerCase() !== 'moa'
+ )
+
const selectedProviderRow = useMemo(
() => providers.find(provider => provider.slug === selectedProvider),
[providers, selectedProvider]
@@ -851,7 +858,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
- {providerOptions.map(provider => (
+ {moaSlotProviderOptions.map(provider => (
{provider.name}
@@ -930,7 +937,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
- {providerOptions.map(provider => (
+ {moaSlotProviderOptions.map(provider => (
{provider.name}
diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py
index bd74ced49f8..40828cbae5a 100644
--- a/hermes_cli/moa_config.py
+++ b/hermes_cli/moa_config.py
@@ -49,6 +49,13 @@ def _clean_slot(slot: Any) -> dict[str, str] | None:
model = str(slot.get("model") or "").strip()
if not provider or not model:
return None
+ # MoA is a virtual provider whose presets are themselves MoA runs. Allowing
+ # one as a reference or aggregator slot would create a recursive MoA tree
+ # (the runtime guards in moa_loop.py skip references / raise on aggregators,
+ # but that surfaces only mid-turn). Reject it here so it can never be saved:
+ # an invalid slot is dropped, falling back to the preset's defaults.
+ if provider.lower() == "moa":
+ return None
return {"provider": provider, "model": model}
diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py
index 630d183927d..51f80b8067b 100644
--- a/tests/hermes_cli/test_moa_config.py
+++ b/tests/hermes_cli/test_moa_config.py
@@ -134,3 +134,54 @@ def test_build_moa_turn_prompt_encodes_one_shot_default_preset():
assert decoded_prompt == "write a file then inspect it"
assert cfg is not None
assert cfg["reference_models"] == DEFAULT_MOA_REFERENCE_MODELS
+
+
+def test_moa_provider_rejected_as_reference_slot():
+ """A reference slot pointing at the moa virtual provider is dropped, so a
+ preset cannot recursively reference another MoA run."""
+ cfg = normalize_moa_config(
+ {
+ "presets": {
+ "p": {
+ "reference_models": [
+ {"provider": "moa", "model": "default"},
+ {"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"},
+ ],
+ "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"},
+ }
+ }
+ }
+ )
+
+ refs = cfg["presets"]["p"]["reference_models"]
+ assert {"provider": "moa", "model": "default"} not in refs
+ assert refs == [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}]
+
+
+def test_moa_provider_rejected_as_aggregator_slot():
+ """An aggregator slot pointing at the moa virtual provider is dropped and
+ falls back to the default aggregator, never a recursive MoA aggregator."""
+ cfg = normalize_moa_config(
+ {
+ "presets": {
+ "p": {
+ "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}],
+ "aggregator": {"provider": "moa", "model": "default"},
+ }
+ }
+ }
+ )
+
+ agg = cfg["presets"]["p"]["aggregator"]
+ assert agg["provider"] != "moa"
+ assert agg == DEFAULT_MOA_AGGREGATOR
+
+
+def test_moa_provider_rejected_case_insensitive():
+ """Case variants like ``MoA`` are also blocked."""
+ cfg = normalize_moa_config(
+ {"presets": {"p": {"aggregator": {"provider": "MoA", "model": "default"}}}}
+ )
+
+ assert cfg["presets"]["p"]["aggregator"]["provider"] != "moa"
+ assert cfg["presets"]["p"]["aggregator"] == DEFAULT_MOA_AGGREGATOR
diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx
index 709ed379c27..5bddab251ec 100644
--- a/web/src/pages/ModelsPage.tsx
+++ b/web/src/pages/ModelsPage.tsx
@@ -846,6 +846,11 @@ function MoaModelsModal({
alwaysGlobal
title="Select MoA Model"
onApply={async ({ provider, model }) => {
+ if ((provider || "").toLowerCase() === "moa") {
+ setError("MoA presets can't reference or aggregate the Mixture of Agents provider (no recursive MoA).");
+ return;
+ }
+ setError(null);
updateSelectedPreset((prev) => {
if (picker.kind === "aggregator") return { ...prev, aggregator: { provider, model } };
return {