You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by ld...@apache.org on 2026/10/05 19:54:54 UTC

(camel) 01/03: CAMEL-25310: Add semantic experts and runtime capabilities

This is an automated email from the ASF dual-hosted git repository.

luigidemasi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 783c141e5ec9416bd0ffc97595c9ab726e946b35
Author: Luigi De Masi <ld...@redhat.com>
AuthorDate: Mon Oct 5 11:41:18 2026 +0200

    CAMEL-25310: Add semantic experts and runtime capabilities
    
    Semantic evaluation previously selected one adapter for every question and
    required instructions even for fixed-purpose models. Allow declarations to
    select a named expert and let adapters describe their supported operations,
    input shapes, limits and instruction requirements through component-local
    capabilities. Declare the TypeSafe AI capabilities using the same contract.
    
    Resolve experts deterministically, report the selected expert in validation
    errors, and reject unknown names without falling back to another provider.
    Group batch requests by expert instance and publish results only after all
    groups succeed. Validate declarations before starting discovered providers.
    
    Validate replacement declarations against live expressions and their full
    candidate snapshot before publication, including predicates, state selectors
    and batch compatibility. Hold validation registrations weakly so the registry
    does not retain abandoned expressions and providers. Document the existing
    YAML reload constraint when a replacement also introduces a new expert bean.
    
    Add Java, XML and YAML coverage and regenerate the existing YAML schemas and
    catalog documentation/schema mirrors. Expert metadata remains local to the
    semantic components; the catalog model, catalog API and shared SPI annotation
    are unchanged.
    
    Co-authored-by: Codex <no...@openai.com>
    Signed-off-by: Luigi De Masi <ld...@redhat.com>
---
 .../camel/catalog/docs/semantic-language.adoc      | 135 +++++-
 .../camel/catalog/schemas/camelYamlDsl-model.json  |  24 +-
 .../src/main/docs/semantic-language.adoc           | 135 +++++-
 .../camel/language/semantic/SemanticLanguage.java  | 230 ++++++++--
 .../org/apache/camel/semantic/SemanticAdapter.java |   5 +
 .../camel/semantic/SemanticCapabilities.java       | 355 +++++++++++++++
 .../org/apache/camel/semantic/SemanticExpert.java  |  86 ++++
 .../apache/camel/semantic/SemanticQuestion.java    |  17 +-
 .../camel/semantic/SemanticQuestionBuilder.java    |  16 +-
 .../apache/camel/semantic/SemanticQuestions.java   |  23 +
 .../camel/semantic/SemanticQuestionsBuilder.java   |   4 +-
 .../apache/camel/semantic/SemanticXmlLoader.java   |   5 +-
 .../semantic/SemanticXmlRoutesBuilderLoader.java   |  96 ++--
 .../yaml/SemanticDefinitionDeserializer.java       |  56 ++-
 .../apache/camel/semantic/SemanticBatchTest.java   |  22 +-
 .../camel/semantic/SemanticCapabilitiesTest.java   |  98 ++++
 .../apache/camel/semantic/SemanticExpertTest.java  | 498 +++++++++++++++++++++
 .../camel/semantic/SemanticExpertYamlTest.java     | 170 +++++++
 .../camel/semantic/SemanticInitializationTest.java |   3 +-
 .../camel/semantic/SemanticLanguageTest.java       |  78 +++-
 .../camel/semantic/SemanticPropertiesTest.java     |   9 +-
 .../typesafeai/TypeSafeAiSemanticAdapter.java      |  20 +-
 .../typesafeai/TypeSafeAiSemanticAdapterTest.java  |  35 ++
 .../resources/schema/camelYamlDsl-canonical.json   |  15 +-
 .../resources/schema/camelYamlDsl-model.json       |  24 +-
 .../generated/resources/schema/camelYamlDsl.json   |  15 +-
 26 files changed, 1998 insertions(+), 176 deletions(-)

diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
index c654e13feb35..390b63579529 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
@@ -22,21 +22,77 @@ The provider owns credentials, model selection, request timeout,
 concurrency limits and transport resources. TypeSafe AI uses
 `camel.component.typesafe-ai.*` settings even when a route contains no TypeSafe AI endpoint.
 
-One advertised adapter is selected automatically. No provider, or multiple distinct
-providers, is an error. Select an existing bean with
-`camel.language.semantic.adapter=myAdapter`, or a class using its fully qualified name.
+An *expert* is a configured semantic evaluator, implemented by `SemanticAdapter`. It is not
+an autonomous agent. Each named question can select a registry bean using `expert`.
+Without that field, `camel.language.semantic.default-expert=myExpert` selects the default bean.
+The existing `camel.language.semantic.adapter=myAdapter` setting remains supported as the
+default when `default-expert` is absent; it also accepts a fully qualified implementation class name.
+
+Without either default, Camel selects the sole eligible registered instance or advertised adapter.
+Registry aliases for one instance count once. A registered instance supersedes discovery of its
+implementation class, including advertised superclasses of registered subclasses. Differently
+configured instances remain distinct candidates.
+No eligible expert, or multiple candidates, is an initialization error listing the candidates.
+Unknown explicit names and invalid defaults fail without fallback. Selection never uses the
+question's result type: two boolean experts can evaluate entirely different properties.
 Use plain names without `#bean:` or `#class:` prefixes. Registry lookup takes precedence over
 class resolution. Class selection uses Camel's class resolver and injector.
 Created adapters are registered as `camelSemanticAdapter` and managed by the context.
 A collision at that name is an error. Referenced beans retain their existing lifecycle owner.
 
-Camel Main and Camel JBang bind `camel.language.semantic.adapter` and
-`camel.language.semantic.default-state` to the language. Embedded applications can resolve
-`SemanticLanguage` from the context and call `setAdapter` and `setDefaultState` before route setup.
+Camel Main and Camel JBang bind `camel.language.semantic.adapter`,
+`camel.language.semantic.default-expert` and `camel.language.semantic.default-state` to the language.
+Embedded applications can resolve `SemanticLanguage` from the context and call `setDefaultExpert`,
+`setAdapter` and `setDefaultState` before route setup.
 The catalog describes the generic `language` expression model; it does not provide a dedicated
 semantic Spring Boot configuration class. Configure the language bean explicitly in Spring Boot
 until generic-language starter configuration is available.
 
+== Fixed and instruction-driven experts
+
+Instructions are optional in the common declaration model. The selected expert decides whether
+they are required, optional or unsupported. TypeSafe AI still requires nonblank instructions.
+A fixed detector can reject instructions and caller-defined criteria because its model already
+defines the question it answers. Unsupported result types, instructions, criteria and decision
+policies fail during initialization, before inference. Replacements for declarations already referenced
+by initialized expressions are validated before publication; failed replacements retain the previous definitions.
+
+For example, given registered instances `security` (a fixed injection detector) and `general`
+(an instruction-driven expert):
+
+[source,yaml]
+----
+- semantic:
+    question:
+      injection:
+        expert: security
+        type: boolean
+        state: "${body}"
+        threshold: "{{security.injection.threshold}}"
+        uncertainty: "{{security.injection.uncertainty}}"
+        uncertaintyPolicy: fail
+      department:
+        expert: general
+        type: choice
+        instructions: Which department should handle this message?
+        criteria:
+          billing: Invoices, payments and refunds
+          technical: Bugs, outages and technical problems
+----
+
+Use `ref:injection` and `ref:department` in ordinary semantic expressions. Expert names can contain
+property placeholders, resolved during runtime selection. Java uses `.expert("security")`;
+XML uses `<question name="injection" type="boolean" expert="security"/>`. An omitted instruction
+stays absent in all three DSLs; an explicitly blank instruction is invalid.
+
+The selected text is data. A fixed injection detector must not prepend a synthetic instruction.
+Its boolean `probability` is the score for the positive class (INJECTION), even when BENIGN wins.
+Threshold and uncertainty policies produce the decision. A negative result means this evaluation
+did not detect injection; it does not establish general safety or grant authorization.
+Probability is distinct from a rubric SCORE and from optional provider confidence. Missing optional
+metadata remains absent, and invalid output or provider failure remains an error. Continue,
+quarantine and review are application decisions expressed using Camel EIPs.
+
 == Named questions
 
 The xref:others:yaml-dsl.adoc[YAML DSL] supports declarations alongside routes, including declarations after their use:
@@ -96,7 +152,30 @@ Reloading a resource replaces its complete set of questions, including removing
 no longer present. Development-mode route reload also removes definitions from deleted or renamed files
 before parsing replacements. This replacement does not make the surrounding route reload transactional.
 Existing expressions resolve the current definition on their next evaluation.
-Loading declarations does not perform inference.
+Reload validates the candidate declaration snapshot for initialized expressions before publishing
+it, including expert capabilities, boolean predicate types, Simple state selectors, and compatible
+batch selectors across resources. Failed validation leaves the previous declarations available.
+Unused declarations are validated when first referenced, as at startup. Removed declarations
+remain removable; an expression that still references a removed name fails when evaluated.
+Validation registrations are weakly held: the registry does not keep expressions or their providers
+alive. Constraints can remain until an unused expression is garbage-collected; stopping a route
+does not necessarily release an expression that is still referenced elsewhere.
+Loading declarations does not perform inference or start provider resources for validation.
+
+==== YAML reload and expert bean preparation
+
+YAML declarations are registered during the loader's existing pre-parse phase, before beans in
+the resource set are prepared. On initial loading, expert validation waits until expressions
+initialize, after bean preparation. On reload, replacements for initialized expressions are
+validated during pre-parsing, so the selected expert must already be registered.
+
+For example, changing an existing question to `expert: newSecurity` and introducing a
+`newSecurity` bean in the same reload fails if that bean is not yet in the registry. Keep expert
+beans registered outside the reloaded resource, prepare the new bean before reloading the
+declarations, or restart the application when changing both together. XML declaration loading
+runs after resource-set bean preparation; Java helpers validate when `register()` is called.
+The three DSLs share declaration fields and validation rules, but this YAML bean-order constraint
+remains because the existing resolver hook runs before bean preparation.
 
 === Java declarations
 
@@ -347,8 +426,13 @@ from resource reload. Questions that need a previous answer require separate eva
 
 `SemanticAdapter.evaluateBatch(Map<String, SemanticQuestion>, Object)` has a default
 implementation that invokes the existing single-question method sequentially, preserving
-compatibility with existing adapters. TypeSafe AI overrides it to send all questions in
-one HTTP request using the component's existing timeout and concurrency limits. No results
+compatibility with existing adapters. Questions are grouped by the resolved expert instance,
+and each group is passed to that instance's batch method. Two instances of the same class remain
+separate groups. Each group must return exactly its assigned names. Camel validates the complete
+combined result before publishing decisions or diagnostics, preserving the original reference order.
+
+TypeSafe AI sends its group in one HTTP request using the component's existing timeout and
+concurrency limits. A batch spanning experts can make multiple inference requests. No results
 are cached or combined across exchanges.
 
 == EIP integration
@@ -551,6 +635,36 @@ the action. The example's threshold is a decision policy, not an accuracy guaran
 This pattern uses Validate and existing authorization services. It does not add a semantic
 `AuthorizationPolicy` implementation or replace a component's internal guardrail interfaces.
 
+== Expert capabilities
+
+`SemanticAdapter.capabilities()` describes accepted text or structured input, supported result
+types, instruction and criteria support, optional probability/confidence semantics, and known limits.
+The default reads `@SemanticExpert` from the provider class. Unannotated legacy adapters report
+`known=false`, with no claimed capabilities. The language still requires instructions for these
+legacy adapters before calling their existing `validate` method. Providers that accept absent
+instructions must explicitly advertise optional or unsupported instructions.
+An adapter can override capabilities for its configured model using the named builder:
+
+[source,java]
+----
+@Override
+public SemanticCapabilities capabilities() {
+    return SemanticAdapter.super.capabilities().toBuilder()
+            .maxChoices(32)
+            .build();
+}
+----
+
+This example narrows an annotated adapter's limits. `SemanticCapabilities.builder()` creates an
+explicit descriptor from scratch; input and result sets are empty until supplied. Descriptors
+are immutable. Capability reporting and declaration validation must not perform inference or
+load models. Runtime input and response validation still apply.
+
+Providers declare static capabilities with `org.apache.camel.semantic.SemanticExpert` from
+`camel-semantic`. The annotation describes the implementation; it neither selects an expert nor
+describes application bean names. Runtime capabilities and `validate` remain authoritative for
+configured instances. No dedicated expert catalog model or discovery API is provided.
+
 == Implementing an adapter
 
 Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the implementation in
@@ -567,7 +681,8 @@ Discovery checks the declarations for ambiguity before resolving the implementat
 explicit selection. Only the selected adapter is constructed, through Camel's `Injector`.
 Explicit bean or class selection also works when packaging does not expose discovery resources.
 
-`validate` rejects unsupported questions and decision policies before traffic. `evaluate`
+The language checks known capabilities and then calls `validate` for model-specific restrictions
+before traffic. Providers should also validate when their SPI is called directly. `evaluate`
 must be thread-safe, bound time and resource consumption, honor interruption and propagate
 operational failures without disclosing input or credentials. Implement `CamelContextAware`
 for context injection and Camel `Service` interfaces when the adapter owns resources.
diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
index 79ce18977038..3d446416f7f8 100644
--- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
+++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
@@ -20536,10 +20536,12 @@
       "children" : [ {
         "name" : "criteria",
         "type" : "object"
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
@@ -20565,10 +20567,12 @@
         "name" : "criteria",
         "type" : "object",
         "required" : true
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
@@ -20583,10 +20587,12 @@
       "children" : [ {
         "name" : "criteria",
         "type" : "any"
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
@@ -20612,10 +20618,12 @@
         "name" : "criteria",
         "type" : "array",
         "required" : true
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
diff --git a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
index c654e13feb35..390b63579529 100644
--- a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
+++ b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
@@ -22,21 +22,77 @@ The provider owns credentials, model selection, request timeout,
 concurrency limits and transport resources. TypeSafe AI uses
 `camel.component.typesafe-ai.*` settings even when a route contains no TypeSafe AI endpoint.
 
-One advertised adapter is selected automatically. No provider, or multiple distinct
-providers, is an error. Select an existing bean with
-`camel.language.semantic.adapter=myAdapter`, or a class using its fully qualified name.
+An *expert* is a configured semantic evaluator, implemented by `SemanticAdapter`. It is not
+an autonomous agent. Each named question can select a registry bean using `expert`.
+Without that field, `camel.language.semantic.default-expert=myExpert` selects the default bean.
+The existing `camel.language.semantic.adapter=myAdapter` setting remains supported as the
+default when `default-expert` is absent; it also accepts a fully qualified implementation class name.
+
+Without either default, Camel selects the sole eligible registered instance or advertised adapter.
+Registry aliases for one instance count once. A registered instance supersedes discovery of its
+implementation class, including advertised superclasses of registered subclasses. Differently
+configured instances remain distinct candidates.
+No eligible expert, or multiple candidates, is an initialization error listing the candidates.
+Unknown explicit names and invalid defaults fail without fallback. Selection never uses the
+question's result type: two boolean experts can evaluate entirely different properties.
 Use plain names without `#bean:` or `#class:` prefixes. Registry lookup takes precedence over
 class resolution. Class selection uses Camel's class resolver and injector.
 Created adapters are registered as `camelSemanticAdapter` and managed by the context.
 A collision at that name is an error. Referenced beans retain their existing lifecycle owner.
 
-Camel Main and Camel JBang bind `camel.language.semantic.adapter` and
-`camel.language.semantic.default-state` to the language. Embedded applications can resolve
-`SemanticLanguage` from the context and call `setAdapter` and `setDefaultState` before route setup.
+Camel Main and Camel JBang bind `camel.language.semantic.adapter`,
+`camel.language.semantic.default-expert` and `camel.language.semantic.default-state` to the language.
+Embedded applications can resolve `SemanticLanguage` from the context and call `setDefaultExpert`,
+`setAdapter` and `setDefaultState` before route setup.
 The catalog describes the generic `language` expression model; it does not provide a dedicated
 semantic Spring Boot configuration class. Configure the language bean explicitly in Spring Boot
 until generic-language starter configuration is available.
 
+== Fixed and instruction-driven experts
+
+Instructions are optional in the common declaration model. The selected expert decides whether
+they are required, optional or unsupported. TypeSafe AI still requires nonblank instructions.
+A fixed detector can reject instructions and caller-defined criteria because its model already
+defines the question it answers. Unsupported result types, instructions, criteria and decision
+policies fail during initialization, before inference. Replacements for declarations already referenced
+by initialized expressions are validated before publication; failed replacements retain the previous definitions.
+
+For example, given registered instances `security` (a fixed injection detector) and `general`
+(an instruction-driven expert):
+
+[source,yaml]
+----
+- semantic:
+    question:
+      injection:
+        expert: security
+        type: boolean
+        state: "${body}"
+        threshold: "{{security.injection.threshold}}"
+        uncertainty: "{{security.injection.uncertainty}}"
+        uncertaintyPolicy: fail
+      department:
+        expert: general
+        type: choice
+        instructions: Which department should handle this message?
+        criteria:
+          billing: Invoices, payments and refunds
+          technical: Bugs, outages and technical problems
+----
+
+Use `ref:injection` and `ref:department` in ordinary semantic expressions. Expert names can contain
+property placeholders, resolved during runtime selection. Java uses `.expert("security")`;
+XML uses `<question name="injection" type="boolean" expert="security"/>`. An omitted instruction
+stays absent in all three DSLs; an explicitly blank instruction is invalid.
+
+The selected text is data. A fixed injection detector must not prepend a synthetic instruction.
+Its boolean `probability` is the score for the positive class (INJECTION), even when BENIGN wins.
+Threshold and uncertainty policies produce the decision. A negative result means this evaluation
+did not detect injection; it does not establish general safety or grant authorization.
+Probability is distinct from a rubric SCORE and from optional provider confidence. Missing optional
+metadata remains absent, and invalid output or provider failure remains an error. Continue,
+quarantine and review are application decisions expressed using Camel EIPs.
+
 == Named questions
 
 The xref:others:yaml-dsl.adoc[YAML DSL] supports declarations alongside routes, including declarations after their use:
@@ -96,7 +152,30 @@ Reloading a resource replaces its complete set of questions, including removing
 no longer present. Development-mode route reload also removes definitions from deleted or renamed files
 before parsing replacements. This replacement does not make the surrounding route reload transactional.
 Existing expressions resolve the current definition on their next evaluation.
-Loading declarations does not perform inference.
+Reload validates the candidate declaration snapshot for initialized expressions before publishing
+it, including expert capabilities, boolean predicate types, Simple state selectors, and compatible
+batch selectors across resources. Failed validation leaves the previous declarations available.
+Unused declarations are validated when first referenced, as at startup. Removed declarations
+remain removable; an expression that still references a removed name fails when evaluated.
+Validation registrations are weakly held: the registry does not keep expressions or their providers
+alive. Constraints can remain until an unused expression is garbage-collected; stopping a route
+does not necessarily release an expression that is still referenced elsewhere.
+Loading declarations does not perform inference or start provider resources for validation.
+
+==== YAML reload and expert bean preparation
+
+YAML declarations are registered during the loader's existing pre-parse phase, before beans in
+the resource set are prepared. On initial loading, expert validation waits until expressions
+initialize, after bean preparation. On reload, replacements for initialized expressions are
+validated during pre-parsing, so the selected expert must already be registered.
+
+For example, changing an existing question to `expert: newSecurity` and introducing a
+`newSecurity` bean in the same reload fails if that bean is not yet in the registry. Keep expert
+beans registered outside the reloaded resource, prepare the new bean before reloading the
+declarations, or restart the application when changing both together. XML declaration loading
+runs after resource-set bean preparation; Java helpers validate when `register()` is called.
+The three DSLs share declaration fields and validation rules, but this YAML bean-order constraint
+remains because the existing resolver hook runs before bean preparation.
 
 === Java declarations
 
@@ -347,8 +426,13 @@ from resource reload. Questions that need a previous answer require separate eva
 
 `SemanticAdapter.evaluateBatch(Map<String, SemanticQuestion>, Object)` has a default
 implementation that invokes the existing single-question method sequentially, preserving
-compatibility with existing adapters. TypeSafe AI overrides it to send all questions in
-one HTTP request using the component's existing timeout and concurrency limits. No results
+compatibility with existing adapters. Questions are grouped by the resolved expert instance,
+and each group is passed to that instance's batch method. Two instances of the same class remain
+separate groups. Each group must return exactly its assigned names. Camel validates the complete
+combined result before publishing decisions or diagnostics, preserving the original reference order.
+
+TypeSafe AI sends its group in one HTTP request using the component's existing timeout and
+concurrency limits. A batch spanning experts can make multiple inference requests. No results
 are cached or combined across exchanges.
 
 == EIP integration
@@ -551,6 +635,36 @@ the action. The example's threshold is a decision policy, not an accuracy guaran
 This pattern uses Validate and existing authorization services. It does not add a semantic
 `AuthorizationPolicy` implementation or replace a component's internal guardrail interfaces.
 
+== Expert capabilities
+
+`SemanticAdapter.capabilities()` describes accepted text or structured input, supported result
+types, instruction and criteria support, optional probability/confidence semantics, and known limits.
+The default reads `@SemanticExpert` from the provider class. Unannotated legacy adapters report
+`known=false`, with no claimed capabilities. The language still requires instructions for these
+legacy adapters before calling their existing `validate` method. Providers that accept absent
+instructions must explicitly advertise optional or unsupported instructions.
+An adapter can override capabilities for its configured model using the named builder:
+
+[source,java]
+----
+@Override
+public SemanticCapabilities capabilities() {
+    return SemanticAdapter.super.capabilities().toBuilder()
+            .maxChoices(32)
+            .build();
+}
+----
+
+This example narrows an annotated adapter's limits. `SemanticCapabilities.builder()` creates an
+explicit descriptor from scratch; input and result sets are empty until supplied. Descriptors
+are immutable. Capability reporting and declaration validation must not perform inference or
+load models. Runtime input and response validation still apply.
+
+Providers declare static capabilities with `org.apache.camel.semantic.SemanticExpert` from
+`camel-semantic`. The annotation describes the implementation; it neither selects an expert nor
+describes application bean names. Runtime capabilities and `validate` remain authoritative for
+configured instances. No dedicated expert catalog model or discovery API is provided.
+
 == Implementing an adapter
 
 Implement `org.apache.camel.semantic.SemanticAdapter`. Advertise the implementation in
@@ -567,7 +681,8 @@ Discovery checks the declarations for ambiguity before resolving the implementat
 explicit selection. Only the selected adapter is constructed, through Camel's `Injector`.
 Explicit bean or class selection also works when packaging does not expose discovery resources.
 
-`validate` rejects unsupported questions and decision policies before traffic. `evaluate`
+The language checks known capabilities and then calls `validate` for model-specific restrictions
+before traffic. Providers should also validate when their SPI is called directly. `evaluate`
 must be thread-safe, bound time and resource consumption, honor interruption and propagate
 operational failures without disclosing input or credentials. Implement `CamelContextAware`
 for context injection and Camel `Service` interfaces when the adapter owns resources.
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java
index f9280466d3c3..4d477199e87d 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/language/semantic/SemanticLanguage.java
@@ -19,16 +19,19 @@ package org.apache.camel.language.semantic;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Enumeration;
 import java.util.HashSet;
+import java.util.IdentityHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.function.Consumer;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.CamelContextAware;
@@ -37,6 +40,7 @@ import org.apache.camel.Expression;
 import org.apache.camel.Predicate;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.semantic.SemanticAdapter;
+import org.apache.camel.semantic.SemanticCapabilities;
 import org.apache.camel.semantic.SemanticQuestion;
 import org.apache.camel.semantic.SemanticQuestions;
 import org.apache.camel.semantic.SemanticResult;
@@ -62,8 +66,11 @@ public class SemanticLanguage extends LanguageSupport {
     public static final String ADAPTER_RESOURCE = FactoryFinder.DEFAULT_PATH + ADAPTER_FACTORY;
 
     private String adapter;
+    private String defaultExpert;
     private String defaultState = "${body}";
     private SemanticAdapter selectedAdapter;
+    private String selectedAdapterName;
+    private ManagedAdapter managedAdapter;
 
     public String getAdapter() {
         return adapter;
@@ -77,6 +84,15 @@ public class SemanticLanguage extends LanguageSupport {
         this.adapter = adapter;
     }
 
+    public String getDefaultExpert() {
+        return defaultExpert;
+    }
+
+    /** Default expert registry bean name for declarations without an explicit expert. */
+    public void setDefaultExpert(String defaultExpert) {
+        this.defaultExpert = defaultExpert;
+    }
+
     public String getDefaultState() {
         return defaultState;
     }
@@ -156,13 +172,19 @@ public class SemanticLanguage extends LanguageSupport {
                             "Semantic adapter bean does not implement SemanticAdapter: " + configured);
                 }
                 selectedAdapter = found;
+                selectedAdapterName = configured;
                 return found;
             }
         }
         ManagedAdapter owned = null;
         try {
-            Class<?> resolved = configured == null
+            Object candidate = configured == null
                     ? discoverAdapter(context) : context.getClassResolver().resolveClass(configured);
+            if (candidate instanceof SemanticAdapter registered) {
+                selectedAdapter = registered;
+                return registered;
+            }
+            Class<?> resolved = (Class<?>) candidate;
             if (resolved == null) {
                 throw new IllegalArgumentException("No semantic adapter bean or class found: " + configured);
             }
@@ -178,7 +200,9 @@ public class SemanticLanguage extends LanguageSupport {
                 context.getRegistry().bind(ADAPTER_NAME, instance);
             }
             context.addService(owned, true, true);
+            managedAdapter = owned;
             selectedAdapter = owned.instance;
+            selectedAdapterName = type.getName();
             return selectedAdapter;
         } catch (Exception failure) {
             if (owned != null) {
@@ -189,11 +213,35 @@ public class SemanticLanguage extends LanguageSupport {
                     failure.addSuppressed(cleanup);
                 }
             }
+            if (failure instanceof IllegalArgumentException invalid) {
+                throw invalid;
+            }
+            throw RuntimeCamelException.wrapRuntimeCamelException(failure);
+        }
+    }
+
+    private synchronized void startAdapter(List<Group> groups) {
+        ManagedAdapter owned = managedAdapter;
+        if (owned == null || groups.stream().noneMatch(group -> group.expert.provider == owned.instance)) {
+            return;
+        }
+        try {
+            owned.activate();
+        } catch (Exception failure) {
+            selectedAdapter = null;
+            selectedAdapterName = null;
+            managedAdapter = null;
+            try {
+                getCamelContext().removeService(owned);
+                ServiceHelper.stopAndShutdownService(owned);
+            } catch (Exception cleanup) {
+                failure.addSuppressed(cleanup);
+            }
             throw RuntimeCamelException.wrapRuntimeCamelException(failure);
         }
     }
 
-    private Class<?> discoverAdapter(CamelContext context) throws IOException {
+    private Object discoverAdapter(CamelContext context) throws IOException {
         // FactoryFinder resolves one descriptor; check all declarations first to avoid classpath-order selection.
         Set<String> candidates = new TreeSet<>();
         Enumeration<URL> resources = context.getClassResolver().loadAllResourcesAsURL(ADAPTER_RESOURCE);
@@ -209,10 +257,29 @@ public class SemanticLanguage extends LanguageSupport {
                 candidates.add(className);
             }
         }
-        if (candidates.size() != 1) {
+        Map<String, SemanticAdapter> registered = context.getRegistry().findByTypeWithName(SemanticAdapter.class);
+        Set<SemanticAdapter> instances = Collections.newSetFromMap(new IdentityHashMap<>());
+        instances.addAll(registered.values());
+        // A configured instance supersedes discovery of its implementation; aliases are one instance.
+        registered.values().forEach(value -> {
+            for (Class<?> type = value.getClass(); type != null; type = type.getSuperclass()) {
+                candidates.remove(type.getName());
+            }
+        });
+        if (candidates.size() + instances.size() != 1) {
+            Set<String> names = new TreeSet<>(registered.keySet());
+            names.addAll(candidates);
             throw new IllegalArgumentException(
-                    "Semantic language requires exactly one advertised adapter; found "
-                                               + candidates + ". Configure camel.language.semantic.adapter explicitly");
+                    "Semantic language requires exactly one eligible expert; available experts: " + names
+                                               + ". Specify expert or configure camel.language.semantic.default-expert explicitly");
+        }
+        if (instances.size() == 1) {
+            SemanticAdapter instance = instances.iterator().next();
+            // Keep an operator-facing name; aliases have no primary name, so choose deterministically.
+            selectedAdapterName = registered.entrySet().stream()
+                    .filter(entry -> entry.getValue() == instance)
+                    .map(Map.Entry::getKey).min(String::compareTo).orElseThrow();
+            return instance;
         }
         Class<?> resolved = context.getCamelContextExtension().getDefaultFactoryFinder().findClass(ADAPTER_FACTORY)
                 .orElseThrow(() -> new IllegalArgumentException("Cannot resolve advertised semantic adapter: " + candidates));
@@ -223,6 +290,42 @@ public class SemanticLanguage extends LanguageSupport {
         return resolved;
     }
 
+    private ResolvedExpert expert(String name, SemanticQuestion question) {
+        String reference = question.getExpert() != null ? question.getExpert() : defaultExpert;
+        String label = reference != null ? reference : adapter != null ? adapter : "automatic";
+        try {
+            SemanticAdapter provider;
+            if (reference != null) {
+                label = getCamelContext().resolvePropertyPlaceholders(reference);
+                if (label.isBlank() || label.startsWith("#")) {
+                    throw new IllegalArgumentException("Expert must be a registry bean name without a # prefix");
+                }
+                Object bean = getCamelContext().getRegistry().lookupByName(label);
+                if (!(bean instanceof SemanticAdapter found)) {
+                    throw new IllegalArgumentException("Unknown expert or bean does not implement SemanticAdapter: " + label);
+                }
+                provider = found;
+            } else {
+                synchronized (this) {
+                    provider = adapter();
+                    label = selectedAdapterName;
+                }
+            }
+            SemanticCapabilities capabilities = provider.capabilities();
+            capabilities.validate(question);
+            provider.validate(question);
+            return new ResolvedExpert(provider, capabilities, label);
+        } catch (RuntimeException e) {
+            if (e instanceof IllegalArgumentException) {
+                throw new IllegalArgumentException(
+                        "Semantic evaluation '" + name + "', expert '" + label + "': " + e.getMessage(), e);
+            }
+            throw new RuntimeCamelException(
+                    "Semantic evaluation '" + name + "', expert '" + label + "': " + e.getMessage(),
+                    e instanceof RuntimeCamelException && e.getCause() != null ? e.getCause() : e);
+        }
+    }
+
     private static final class AdapterLock {
         private static final Object CREATION_LOCK = new Object();
         private final Object monitor = new Object();
@@ -243,6 +346,7 @@ public class SemanticLanguage extends LanguageSupport {
         private final CamelContext context;
         private final SemanticAdapter instance;
         private final AdapterLock lock;
+        private boolean activated;
 
         private ManagedAdapter(CamelContext context, SemanticAdapter instance, AdapterLock lock) {
             this.context = context;
@@ -250,14 +354,20 @@ public class SemanticLanguage extends LanguageSupport {
             this.lock = lock;
         }
 
-        @Override
-        protected void doInit() throws Exception {
-            ServiceHelper.initService(instance);
+        private synchronized void activate() throws Exception {
+            if (!activated) {
+                activated = true;
+                ServiceHelper.initService(instance);
+                ServiceHelper.startService(instance);
+            }
         }
 
         @Override
-        protected void doStart() throws Exception {
-            ServiceHelper.startService(instance);
+        protected synchronized void doStart() throws Exception {
+            // Own cleanup immediately, but start provider resources only after declaration validation.
+            if (activated) {
+                ServiceHelper.startService(instance);
+            }
         }
 
         @Override
@@ -283,9 +393,9 @@ public class SemanticLanguage extends LanguageSupport {
         private final List<String> names;
         private final boolean batch;
         private final boolean predicate;
+        private final Consumer<Map<String, SemanticQuestion>> validator = this::prepare;
         private volatile Compiled compiled;
         private volatile SemanticQuestions questions;
-        private volatile SemanticAdapter provider;
 
         private Evaluation(List<String> names, boolean batch, boolean predicate) {
             this.names = names;
@@ -298,11 +408,8 @@ public class SemanticLanguage extends LanguageSupport {
             super.init(context);
             questions = SemanticQuestions.get(context);
             Map<String, SemanticQuestion> selected = questions.get(names);
-            if (predicate) {
-                requireBoolean(selected.get(names.get(0)));
-            }
-            provider = adapter();
             compile(selected);
+            questions.setValidator(names, validator);
         }
 
         private void requireBoolean(SemanticQuestion question) {
@@ -313,30 +420,47 @@ public class SemanticLanguage extends LanguageSupport {
 
         private synchronized Compiled compile(Map<String, SemanticQuestion> selected) {
             if (compiled == null || !compiled.questions.equals(selected)) {
-                if (predicate) {
-                    requireBoolean(selected.get(names.get(0)));
+                Compiled candidate = prepare(selected);
+                startAdapter(candidate.groups);
+                compiled = candidate;
+            }
+            return compiled;
+        }
+
+        private Compiled prepare(Map<String, SemanticQuestion> selected) {
+            if (predicate) {
+                requireBoolean(selected.get(names.get(0)));
+            }
+            String selector = null;
+            Map<SemanticAdapter, Group> byInstance = new IdentityHashMap<>();
+            List<Group> groups = new ArrayList<>();
+            for (var entry : selected.entrySet()) {
+                SemanticQuestion question = entry.getValue();
+                ResolvedExpert resolved = expert(entry.getKey(), question);
+                Group group = byInstance.get(resolved.provider);
+                if (group == null) {
+                    group = new Group(resolved, new LinkedHashMap<>());
+                    byInstance.put(resolved.provider, group);
+                    groups.add(group);
                 }
-                String selector = null;
-                for (var entry : selected.entrySet()) {
-                    SemanticQuestion question = entry.getValue();
-                    provider.validate(question);
-                    String effective = question.getState() != null ? question.getState() : defaultState;
-                    if (effective == null || effective.isBlank()) {
-                        throw new IllegalArgumentException(
-                                "Semantic state selector must not be blank for question: " + entry.getKey());
-                    }
-                    effective = getCamelContext().resolvePropertyPlaceholders(effective);
-                    if (selector != null && !selector.equals(effective)) {
-                        throw new IllegalArgumentException(
-                                "Semantic batch questions must use the same effective state selector");
-                    }
-                    selector = effective;
+                group.questions.put(entry.getKey(), question);
+                String effective = question.getState() != null ? question.getState() : defaultState;
+                if (effective == null || effective.isBlank()) {
+                    throw new IllegalArgumentException(
+                            "Semantic state selector must not be blank for question: " + entry.getKey());
+                }
+                effective = getCamelContext().resolvePropertyPlaceholders(effective);
+                if (selector != null && !selector.equals(effective)) {
+                    throw new IllegalArgumentException(
+                            "Semantic batch questions must use the same effective state selector");
                 }
-                Expression state = getCamelContext().resolveLanguage("simple").createExpression(selector);
-                state.init(getCamelContext());
-                compiled = new Compiled(selected, state);
+                selector = effective;
             }
-            return compiled;
+            Expression state = getCamelContext().resolveLanguage("simple").createExpression(selector);
+            state.init(getCamelContext());
+            return new Compiled(
+                    selected, state, groups.stream()
+                            .map(group -> new Group(group.expert, Collections.unmodifiableMap(group.questions))).toList());
         }
 
         @Override
@@ -380,10 +504,28 @@ public class SemanticLanguage extends LanguageSupport {
                             "Unsupported state type for semantic questions: " + names
                                                        + ". Select strings, maps or lists explicitly");
                 }
+                // Check every group's input before invoking any provider.
+                for (Group group : current.groups) {
+                    try {
+                        group.expert.capabilities.validateInput(state);
+                    } catch (IllegalArgumentException invalid) {
+                        throw new IllegalArgumentException(
+                                "Semantic evaluations " + group.questions.keySet() + ", expert '" + group.expert.reference
+                                                           + "': " + invalid.getMessage(),
+                                invalid);
+                    }
+                }
                 if (batch) {
-                    Map<String, SemanticResult> results = provider.evaluateBatch(current.questions, state);
-                    if (results == null || !results.keySet().equals(current.questions.keySet())) {
-                        throw new IllegalArgumentException("Semantic batch result names must match question names");
+                    Map<String, SemanticResult> results = new LinkedHashMap<>();
+                    for (Group group : current.groups) {
+                        if (Thread.currentThread().isInterrupted()) {
+                            throw new InterruptedException("Semantic batch evaluation interrupted");
+                        }
+                        Map<String, SemanticResult> answers = group.expert.provider.evaluateBatch(group.questions, state);
+                        if (answers == null || !answers.keySet().equals(group.questions.keySet())) {
+                            throw new IllegalArgumentException("Semantic batch result names must match question names");
+                        }
+                        results.putAll(answers);
                     }
                     Map<String, Object> decisions = new LinkedHashMap<>();
                     Map<String, SemanticResult> details = new LinkedHashMap<>();
@@ -399,7 +541,7 @@ public class SemanticLanguage extends LanguageSupport {
                     return Collections.unmodifiableMap(decisions);
                 }
                 SemanticQuestion question = current.questions.get(names.get(0));
-                SemanticResult result = provider.evaluate(question, state);
+                SemanticResult result = current.groups.get(0).expert.provider.evaluate(question, state);
                 if (result == null) {
                     throw new IllegalArgumentException("Missing result for semantic question: " + names.get(0));
                 }
@@ -425,6 +567,12 @@ public class SemanticLanguage extends LanguageSupport {
         }
     }
 
-    private record Compiled(Map<String, SemanticQuestion> questions, Expression state) {
+    private record ResolvedExpert(SemanticAdapter provider, SemanticCapabilities capabilities, String reference) {
+    }
+
+    private record Group(ResolvedExpert expert, Map<String, SemanticQuestion> questions) {
+    }
+
+    private record Compiled(Map<String, SemanticQuestion> questions, Expression state, List<Group> groups) {
     }
 }
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java
index 74d0d37f951a..767b2c5e727c 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticAdapter.java
@@ -28,6 +28,11 @@ import java.util.Map;
  * Referenced registry beans retain their existing lifecycle owner.
  */
 public interface SemanticAdapter {
+    /** Static defaults, or unknown for legacy adapters. This method must not load models or contact services. */
+    default SemanticCapabilities capabilities() {
+        return SemanticCapabilities.from(getClass().getAnnotation(SemanticExpert.class));
+    }
+
     /** Reject unsupported question kinds, criteria, decision policies or input selectors before traffic starts. */
     void validate(SemanticQuestion question);
 
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticCapabilities.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticCapabilities.java
new file mode 100644
index 000000000000..9a186a8d829f
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticCapabilities.java
@@ -0,0 +1,355 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.semantic;
+
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.camel.semantic.SemanticExpert.InputType;
+import org.apache.camel.semantic.SemanticExpert.Instructions;
+import org.apache.camel.semantic.SemanticExpert.ResultType;
+
+/**
+ * Immutable capabilities of a configured expert. Unknown legacy capabilities are not a claim of support. Providers may
+ * override their static annotation with effective capabilities for the configured model. Probability/confidence flags
+ * describe availability, not guarantees that every response includes optional metadata.
+ */
+public final class SemanticCapabilities {
+    private final boolean known;
+    private final String name;
+    private final String description;
+    private final String provider;
+    private final String artifactId;
+    private final Set<InputType> inputTypes;
+    private final Set<ResultType> resultTypes;
+    private final Instructions instructions;
+    private final boolean callerDefinedCriteria;
+    private final boolean booleanProbability;
+    private final boolean choiceProbabilities;
+    private final Set<ResultType> confidenceTypes;
+    private final String probabilityMeaning;
+    private final String confidenceMeaning;
+    private final String trueMeaning;
+    private final int maxChoices;
+    private final int maxScoreLevels;
+
+    private SemanticCapabilities(boolean known, Builder builder) {
+        this.known = known;
+        this.name = builder.name;
+        this.description = builder.description;
+        this.provider = builder.provider;
+        this.artifactId = builder.artifactId;
+        this.inputTypes = immutableSet(InputType.class, builder.inputTypes);
+        this.resultTypes = immutableSet(ResultType.class, builder.resultTypes);
+        this.instructions = Objects.requireNonNull(builder.instructions, "Instruction support is required");
+        this.callerDefinedCriteria = builder.callerDefinedCriteria;
+        this.booleanProbability = builder.booleanProbability;
+        this.choiceProbabilities = builder.choiceProbabilities;
+        this.confidenceTypes = immutableSet(ResultType.class, builder.confidenceTypes);
+        this.probabilityMeaning = builder.probabilityMeaning;
+        this.confidenceMeaning = builder.confidenceMeaning;
+        this.trueMeaning = builder.trueMeaning;
+        this.maxChoices = builder.maxChoices;
+        this.maxScoreLevels = builder.maxScoreLevels;
+    }
+
+    private static <E extends Enum<E>> Set<E> immutableSet(Class<E> type, Set<E> values) {
+        EnumSet<E> copy = EnumSet.noneOf(type);
+        copy.addAll(values);
+        return Collections.unmodifiableSet(copy);
+    }
+
+    /** Build explicit capabilities using named attributes. Empty input/result sets claim no support. */
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /**
+     * Start an explicit capability declaration from this descriptor, for example to narrow a configured model's limits.
+     */
+    public Builder toBuilder() {
+        return new Builder(this);
+    }
+
+    public static SemanticCapabilities unknown() {
+        return new SemanticCapabilities(false, new Builder());
+    }
+
+    /** Reads static capabilities without loading a model or transport. */
+    public static SemanticCapabilities from(SemanticExpert expert) {
+        if (expert == null) {
+            return unknown();
+        }
+        return builder()
+                .name(expert.name())
+                .description(expert.description())
+                .provider(expert.provider())
+                .artifactId(expert.artifactId())
+                .inputTypes(expert.inputTypes())
+                .resultTypes(expert.resultTypes())
+                .instructions(expert.instructions())
+                .callerDefinedCriteria(expert.callerDefinedCriteria())
+                .booleanProbability(expert.booleanProbability())
+                .choiceProbabilities(expert.choiceProbabilities())
+                .confidenceTypes(expert.confidenceTypes())
+                .probabilityMeaning(expert.probabilityMeaning())
+                .confidenceMeaning(expert.confidenceMeaning())
+                .trueMeaning(expert.trueMeaning())
+                .maxChoices(expert.maxChoices())
+                .maxScoreLevels(expert.maxScoreLevels())
+                .build();
+    }
+
+    public boolean isKnown() {
+        return known;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public String getProvider() {
+        return provider;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public Set<InputType> getInputTypes() {
+        return inputTypes;
+    }
+
+    public Set<ResultType> getResultTypes() {
+        return resultTypes;
+    }
+
+    public Instructions getInstructions() {
+        return instructions;
+    }
+
+    public boolean isCallerDefinedCriteria() {
+        return callerDefinedCriteria;
+    }
+
+    public boolean isBooleanProbability() {
+        return booleanProbability;
+    }
+
+    public boolean isChoiceProbabilities() {
+        return choiceProbabilities;
+    }
+
+    public Set<ResultType> getConfidenceTypes() {
+        return confidenceTypes;
+    }
+
+    public String getProbabilityMeaning() {
+        return probabilityMeaning;
+    }
+
+    public String getConfidenceMeaning() {
+        return confidenceMeaning;
+    }
+
+    public String getTrueMeaning() {
+        return trueMeaning;
+    }
+
+    public int getMaxChoices() {
+        return maxChoices;
+    }
+
+    public int getMaxScoreLevels() {
+        return maxScoreLevels;
+    }
+
+    /** Validate the common contract before the provider checks any model-specific restrictions. */
+    public void validate(SemanticQuestion question) {
+        if ((!known || instructions == Instructions.REQUIRED) && question.getInstructions() == null) {
+            throw new IllegalArgumentException("Question instructions are required");
+        }
+        if (!known) {
+            return;
+        }
+        if (!resultTypes.contains(ResultType.valueOf(question.getType().name()))) {
+            throw new IllegalArgumentException(
+                    "Requires " + question.getType() + "; supports " + resultTypes + ": " + description);
+        }
+        if (instructions == Instructions.UNSUPPORTED && question.getInstructions() != null) {
+            throw new IllegalArgumentException("Instructions are unsupported by this fixed expert");
+        }
+        if (!callerDefinedCriteria && (!question.getCriteria().isEmpty() || !question.getLevels().isEmpty())) {
+            throw new IllegalArgumentException("Caller-defined criteria and score levels are unsupported by this fixed expert");
+        }
+        if (question.getType() == SemanticQuestion.Type.CHOICE && maxChoices > 0
+                && question.getCriteria().size() > maxChoices) {
+            throw new IllegalArgumentException("Supports at most " + maxChoices + " choice criteria");
+        }
+        if (question.getType() == SemanticQuestion.Type.SCORE && maxScoreLevels > 0
+                && question.getLevels().size() > maxScoreLevels) {
+            throw new IllegalArgumentException("Supports at most " + maxScoreLevels + " score levels");
+        }
+        if (question.getType() == SemanticQuestion.Type.BOOLEAN && !booleanProbability
+                && (question.getThreshold() != 0.5 || question.getUncertainty() != 0
+                        || question.getUncertaintyPolicy() != SemanticQuestion.UncertaintyPolicy.FAIL)) {
+            throw new IllegalArgumentException("Boolean decision policy requires positive-class probability support");
+        }
+    }
+
+    /** Input shape can only be checked once the state selector has been evaluated. */
+    public void validateInput(Object state) {
+        if (known && !(state instanceof String && inputTypes.contains(InputType.TEXT)
+                || (state instanceof Map<?, ?> || state instanceof List<?>) && inputTypes.contains(InputType.STRUCTURED))) {
+            throw new IllegalArgumentException("Unsupported selected state; accepts " + inputTypes + " input");
+        }
+    }
+
+    /** Named construction of an immutable, explicitly supported contract. */
+    public static final class Builder {
+        private String name;
+        private String description;
+        private String provider;
+        private String artifactId;
+        private Set<InputType> inputTypes = Set.of();
+        private Set<ResultType> resultTypes = Set.of();
+        private Instructions instructions = Instructions.REQUIRED;
+        private boolean callerDefinedCriteria;
+        private boolean booleanProbability;
+        private boolean choiceProbabilities;
+        private Set<ResultType> confidenceTypes = Set.of();
+        private String probabilityMeaning;
+        private String confidenceMeaning;
+        private String trueMeaning;
+        private int maxChoices;
+        private int maxScoreLevels;
+
+        private Builder() {
+        }
+
+        private Builder(SemanticCapabilities capabilities) {
+            this.name = capabilities.name;
+            this.description = capabilities.description;
+            this.provider = capabilities.provider;
+            this.artifactId = capabilities.artifactId;
+            this.inputTypes = capabilities.inputTypes;
+            this.resultTypes = capabilities.resultTypes;
+            this.instructions = capabilities.instructions;
+            this.callerDefinedCriteria = capabilities.callerDefinedCriteria;
+            this.booleanProbability = capabilities.booleanProbability;
+            this.choiceProbabilities = capabilities.choiceProbabilities;
+            this.confidenceTypes = capabilities.confidenceTypes;
+            this.probabilityMeaning = capabilities.probabilityMeaning;
+            this.confidenceMeaning = capabilities.confidenceMeaning;
+            this.trueMeaning = capabilities.trueMeaning;
+            this.maxChoices = capabilities.maxChoices;
+            this.maxScoreLevels = capabilities.maxScoreLevels;
+        }
+
+        public Builder name(String name) {
+            this.name = name;
+            return this;
+        }
+
+        public Builder description(String description) {
+            this.description = description;
+            return this;
+        }
+
+        public Builder provider(String provider) {
+            this.provider = provider;
+            return this;
+        }
+
+        public Builder artifactId(String artifactId) {
+            this.artifactId = artifactId;
+            return this;
+        }
+
+        public Builder inputTypes(InputType... inputTypes) {
+            this.inputTypes = Set.of(inputTypes);
+            return this;
+        }
+
+        public Builder resultTypes(ResultType... resultTypes) {
+            this.resultTypes = Set.of(resultTypes);
+            return this;
+        }
+
+        public Builder instructions(Instructions instructions) {
+            this.instructions = instructions;
+            return this;
+        }
+
+        public Builder callerDefinedCriteria(boolean callerDefinedCriteria) {
+            this.callerDefinedCriteria = callerDefinedCriteria;
+            return this;
+        }
+
+        public Builder booleanProbability(boolean booleanProbability) {
+            this.booleanProbability = booleanProbability;
+            return this;
+        }
+
+        public Builder choiceProbabilities(boolean choiceProbabilities) {
+            this.choiceProbabilities = choiceProbabilities;
+            return this;
+        }
+
+        public Builder confidenceTypes(ResultType... confidenceTypes) {
+            this.confidenceTypes = Set.of(confidenceTypes);
+            return this;
+        }
+
+        public Builder probabilityMeaning(String probabilityMeaning) {
+            this.probabilityMeaning = probabilityMeaning;
+            return this;
+        }
+
+        public Builder confidenceMeaning(String confidenceMeaning) {
+            this.confidenceMeaning = confidenceMeaning;
+            return this;
+        }
+
+        public Builder trueMeaning(String trueMeaning) {
+            this.trueMeaning = trueMeaning;
+            return this;
+        }
+
+        public Builder maxChoices(int maxChoices) {
+            this.maxChoices = maxChoices;
+            return this;
+        }
+
+        public Builder maxScoreLevels(int maxScoreLevels) {
+            this.maxScoreLevels = maxScoreLevels;
+            return this;
+        }
+
+        public SemanticCapabilities build() {
+            return new SemanticCapabilities(true, this);
+        }
+    }
+}
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticExpert.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticExpert.java
new file mode 100644
index 000000000000..ce77381f8c8a
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticExpert.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.semantic;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Static semantic expert capabilities, available without constructing providers or loading models.
+ *
+ * @since 4.23
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Inherited
+@Target(ElementType.TYPE)
+public @interface SemanticExpert {
+    enum InputType {
+        TEXT,
+        STRUCTURED
+    }
+
+    enum ResultType {
+        BOOLEAN,
+        CHOICE,
+        SCORE
+    }
+
+    enum Instructions {
+        REQUIRED,
+        OPTIONAL,
+        UNSUPPORTED
+    }
+
+    String name();
+
+    String description();
+
+    String provider();
+
+    String artifactId();
+
+    InputType[] inputTypes();
+
+    ResultType[] resultTypes();
+
+    Instructions instructions();
+
+    boolean callerDefinedCriteria();
+
+    boolean booleanProbability() default false;
+
+    boolean choiceProbabilities() default false;
+
+    ResultType[] confidenceTypes() default {};
+
+    String probabilityMeaning() default "";
+
+    String confidenceMeaning() default "";
+
+    String trueMeaning() default "";
+
+    /** Zero means no statically known limit. */
+    int maxChoices() default 0;
+
+    /** Zero means no statically known limit. */
+    int maxScoreLevels() default 0;
+}
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestion.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestion.java
index 4948557e76d5..cd7a4126110e 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestion.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestion.java
@@ -38,6 +38,7 @@ public final class SemanticQuestion {
     }
 
     private final Type type;
+    private final String expert;
     private final String instructions;
     private final String state;
     private final Map<String, String> criteria;
@@ -48,8 +49,18 @@ public final class SemanticQuestion {
 
     public SemanticQuestion(Type type, String instructions, String state, Map<String, String> criteria,
                             List<String> levels, double threshold, double uncertainty, UncertaintyPolicy uncertaintyPolicy) {
+        this(type, instructions, state, criteria, levels, threshold, uncertainty, uncertaintyPolicy, null);
+    }
+
+    public SemanticQuestion(Type type, String instructions, String state, Map<String, String> criteria,
+                            List<String> levels, double threshold, double uncertainty, UncertaintyPolicy uncertaintyPolicy,
+                            String expert) {
         this.type = Objects.requireNonNull(type, "Question type is required");
-        if (instructions == null || instructions.isBlank()) {
+        if (expert != null && expert.isBlank()) {
+            throw new IllegalArgumentException("Question expert must not be blank");
+        }
+        this.expert = expert;
+        if (instructions != null && instructions.isBlank()) {
             throw new IllegalArgumentException("Question instructions must not be blank");
         }
         if (state != null && state.isBlank()) {
@@ -84,6 +95,10 @@ public final class SemanticQuestion {
         this.uncertaintyPolicy = Objects.requireNonNull(uncertaintyPolicy, "Uncertainty policy is required");
     }
 
+    public String getExpert() {
+        return expert;
+    }
+
     public Type getType() {
         return type;
     }
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java
index 584837ba5697..164a6ec95d67 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionBuilder.java
@@ -30,6 +30,7 @@ public final class SemanticQuestionBuilder {
     private final Map<String, String> criteria = new LinkedHashMap<>();
     private final List<String> levels = new ArrayList<>();
     private String type;
+    private String expert;
     private String instructions;
     private String state;
     private String threshold;
@@ -46,7 +47,17 @@ public final class SemanticQuestionBuilder {
         return this;
     }
 
-    /** The instructions sent to the provider. */
+    /** Select a configured expert by registry bean name. */
+    public SemanticQuestionBuilder expert(String expert) {
+        this.expert = expert;
+        return this;
+    }
+
+    String getExpert() {
+        return expert;
+    }
+
+    /** The instructions sent to the provider, if supported. */
     public SemanticQuestionBuilder instructions(String instructions) {
         this.instructions = instructions;
         return this;
@@ -126,7 +137,8 @@ public final class SemanticQuestionBuilder {
                 uncertainty == null ? 0 : parseDouble(context, uncertainty, "uncertainty"),
                 uncertaintyPolicy == null
                         ? SemanticQuestion.UncertaintyPolicy.FAIL
-                        : enumeration(uncertaintyPolicy, SemanticQuestion.UncertaintyPolicy.class));
+                        : enumeration(uncertaintyPolicy, SemanticQuestion.UncertaintyPolicy.class),
+                expert);
     }
 
     private static double parseDouble(CamelContext context, String value, String field) {
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java
index 037e9b67df7b..17c74e59f871 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestions.java
@@ -21,6 +21,8 @@ import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.function.Consumer;
 
 import org.apache.camel.CamelContext;
 import org.apache.camel.spi.Resource;
@@ -31,6 +33,14 @@ public final class SemanticQuestions {
     private final Map<String, Map<String, SemanticQuestion>> sources = new HashMap<>();
     private final Map<String, Resource> resources = new HashMap<>();
     private volatile Map<String, SemanticQuestion> questions = Map.of();
+    private final Map<Consumer<Map<String, SemanticQuestion>>, List<String>> validators = new WeakHashMap<>();
+
+    /** Validate current declarations and weakly track a callback owned by its initialized expression. */
+    public synchronized void setValidator(List<String> names, Consumer<Map<String, SemanticQuestion>> validator) {
+        // A replacement may have occurred between the expression's initial compilation and registration.
+        validator.accept(get(names));
+        validators.put(validator, List.copyOf(names));
+    }
 
     public static SemanticQuestions get(CamelContext context) {
         synchronized (CREATION_LOCK) {
@@ -63,6 +73,19 @@ public final class SemanticQuestions {
                 throw new IllegalArgumentException("Duplicate semantic question: " + name);
             }
         });
+        validators.forEach((validator, names) -> {
+            if (!Collections.disjoint(names, definitions.keySet())) {
+                Map<String, SemanticQuestion> selected = new LinkedHashMap<>();
+                names.forEach(name -> {
+                    SemanticQuestion question = replacement.get(name);
+                    // Removed declarations remain removable; their existing expressions fail if evaluated again.
+                    if (question != null) {
+                        selected.put(name, question);
+                    }
+                });
+                validator.accept(Collections.unmodifiableMap(selected));
+            }
+        });
         if (definitions.isEmpty()) {
             sources.remove(source);
         } else {
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java
index b2b122bd7bc0..5eade0c90818 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticQuestionsBuilder.java
@@ -74,7 +74,9 @@ public final class SemanticQuestionsBuilder {
             try {
                 definitions.put(name, question.build(context));
             } catch (IllegalArgumentException e) {
-                throw new IllegalArgumentException("Invalid semantic question '" + name + "': " + e.getMessage(), e);
+                String expert = question.getExpert() != null ? question.getExpert() : "default/automatic";
+                throw new IllegalArgumentException(
+                        "Invalid semantic question '" + name + "': " + e.getMessage() + " (expert '" + expert + "')", e);
             }
         });
         SemanticQuestions registry = SemanticQuestions.get(context);
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
index b3be7899bd4a..6e2c4a87d67d 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
@@ -73,10 +73,7 @@ final class SemanticXmlLoader extends RoutesBuilderLoaderSupport {
     @Override
     public void preParseRoute(Resource resource) throws Exception {
         Resource snapshot = snapshot(resource);
-        if (hasDeclarations(snapshot)) {
-            semantic.setCamelContext(getCamelContext());
-            semantic.preParseRoute(snapshot);
-        } else {
+        if (!hasDeclarations(snapshot)) {
             delegate().preParseRoute(snapshot);
         }
     }
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
index fcbc0e9986b7..17bf179090ac 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
@@ -50,14 +50,9 @@ public class SemanticXmlRoutesBuilderLoader extends RoutesBuilderLoaderSupport {
         return "semantic.xml";
     }
 
-    @Override
-    public void preParseRoute(Resource resource) throws Exception {
-        // Register before any consuming route (including another DSL/resource) is configured.
-        parse(resource);
-    }
-
     @Override
     public RoutesBuilder loadRoutesBuilder(Resource resource) throws Exception {
+        // Resource-set bean preparation is complete; declarations still precede every route configuration.
         RoutesDefinition routes = parse(resource);
         RouteBuilder builder = new RouteBuilder(getCamelContext()) {
             @Override
@@ -147,47 +142,62 @@ public class SemanticXmlRoutesBuilderLoader extends RoutesBuilderLoaderSupport {
             if (!"question".equals(element.getLocalName())) {
                 throw new IllegalArgumentException("Unexpected semantic element: " + element.getTagName());
             }
-            attributes(element, Set.of("name", "type", "state", "threshold", "uncertainty", "uncertaintyPolicy"));
-            SemanticQuestionBuilder question = questions.question(element.getAttribute("name"));
-            if (element.hasAttribute("type")) {
-                question.type(element.getAttribute("type"));
-            }
-            if (element.hasAttribute("state")) {
-                question.state(element.getAttribute("state"));
-            }
-            if (element.hasAttribute("threshold")) {
-                question.threshold(element.getAttribute("threshold"));
+            try {
+                question(element, questions);
+            } catch (IllegalArgumentException e) {
+                String expert = element.hasAttribute("expert") ? element.getAttribute("expert") : "default/automatic";
+                throw new IllegalArgumentException(
+                        "Invalid semantic question '" + element.getAttribute("name") + "': " + e.getMessage()
+                                                   + " (expert '" + expert + "')",
+                        e);
             }
-            if (element.hasAttribute("uncertainty")) {
-                question.uncertainty(element.getAttribute("uncertainty"));
-            }
-            if (element.hasAttribute("uncertaintyPolicy")) {
-                question.uncertaintyPolicy(element.getAttribute("uncertaintyPolicy"));
-            }
-            boolean instructions = false;
-            for (Element child : children(element)) {
-                switch (child.getLocalName()) {
-                    case "instructions" -> {
-                        if (instructions) {
-                            throw new IllegalArgumentException("Duplicate instructions for semantic question");
-                        }
-                        instructions = true;
-                        attributes(child, Set.of());
-                        question.instructions(text(child));
-                    }
-                    case "criterion" -> {
-                        attributes(child, Set.of("key", "value"));
-                        if (!children(child).isEmpty()) {
-                            throw new IllegalArgumentException("Semantic criterion must not contain elements");
-                        }
-                        question.criterion(child.getAttribute("key"), child.getAttribute("value"));
+        }
+    }
+
+    private static void question(Element element, SemanticQuestionsBuilder questions) {
+        attributes(element, Set.of("name", "type", "expert", "state", "threshold", "uncertainty", "uncertaintyPolicy"));
+        SemanticQuestionBuilder question = questions.question(element.getAttribute("name"));
+        if (element.hasAttribute("type")) {
+            question.type(element.getAttribute("type"));
+        }
+        if (element.hasAttribute("expert")) {
+            question.expert(element.getAttribute("expert"));
+        }
+        if (element.hasAttribute("state")) {
+            question.state(element.getAttribute("state"));
+        }
+        if (element.hasAttribute("threshold")) {
+            question.threshold(element.getAttribute("threshold"));
+        }
+        if (element.hasAttribute("uncertainty")) {
+            question.uncertainty(element.getAttribute("uncertainty"));
+        }
+        if (element.hasAttribute("uncertaintyPolicy")) {
+            question.uncertaintyPolicy(element.getAttribute("uncertaintyPolicy"));
+        }
+        boolean instructions = false;
+        for (Element child : children(element)) {
+            switch (child.getLocalName()) {
+                case "instructions" -> {
+                    if (instructions) {
+                        throw new IllegalArgumentException("Duplicate instructions for semantic question");
                     }
-                    case "level" -> {
-                        attributes(child, Set.of());
-                        question.level(text(child));
+                    instructions = true;
+                    attributes(child, Set.of());
+                    question.instructions(text(child));
+                }
+                case "criterion" -> {
+                    attributes(child, Set.of("key", "value"));
+                    if (!children(child).isEmpty()) {
+                        throw new IllegalArgumentException("Semantic criterion must not contain elements");
                     }
-                    default -> throw new IllegalArgumentException("Unexpected question element: " + child.getTagName());
+                    question.criterion(child.getAttribute("key"), child.getAttribute("value"));
+                }
+                case "level" -> {
+                    attributes(child, Set.of());
+                    question.level(text(child));
                 }
+                default -> throw new IllegalArgumentException("Unexpected question element: " + child.getTagName());
             }
         }
     }
diff --git a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java
index f2cd3ef54248..c70c592866d9 100644
--- a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java
+++ b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/yaml/SemanticDefinitionDeserializer.java
@@ -26,6 +26,7 @@ import org.apache.camel.dsl.yaml.common.YamlDeserializationContext;
 import org.apache.camel.dsl.yaml.common.YamlDeserializerResolver;
 import org.apache.camel.dsl.yaml.common.YamlDeserializerSupport;
 import org.apache.camel.dsl.yaml.common.exception.InvalidEnumException;
+import org.apache.camel.dsl.yaml.common.exception.InvalidNodeTypeException;
 import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException;
 import org.apache.camel.semantic.SemanticQuestion;
 import org.apache.camel.semantic.SemanticQuestions;
@@ -48,7 +49,7 @@ import org.snakeyaml.engine.v2.nodes.SequenceNode;
 })
 public class SemanticDefinitionDeserializer extends YamlDeserializerSupport implements ConstructNode, YamlDeserializerResolver {
     private static final Set<String> FIELDS
-            = Set.of("type", "instructions", "state", "criteria", "threshold", "uncertainty", "uncertaintyPolicy");
+            = Set.of("type", "expert", "instructions", "state", "criteria", "threshold", "uncertainty", "uncertaintyPolicy");
 
     @Override
     public ConstructNode resolve(String id) {
@@ -108,28 +109,34 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl
             if (name.isBlank()) {
                 throw new YamlDeserializationException(definition, "Semantic question requires a nonblank name");
             }
+            Map<String, Node> values = fields(definition, "semantic question '" + name + "'");
+            String expert = values.containsKey("expert") ? asText(values.get("expert")) : "default/automatic";
             try {
-                result.put(name, readQuestion(context, name, definition));
-            } catch (IllegalArgumentException e) {
+                result.put(name, readQuestion(context, name, expert, definition, values));
+            } catch (IllegalArgumentException | InvalidNodeTypeException e) {
                 throw new YamlDeserializationException(
-                        definition, "Invalid semantic question '" + name + "': " + e.getMessage(), e);
+                        definition, "Invalid semantic question '" + name + "': " + e.getMessage()
+                                    + " (expert '" + expert + "')",
+                        e);
             }
         });
         return result;
     }
 
-    private static SemanticQuestion readQuestion(CamelContext context, String name, Node definition) {
-        Map<String, Node> values = fields(definition, "semantic question '" + name + "'");
+    private static SemanticQuestion readQuestion(
+            CamelContext context, String name, String expert, Node definition, Map<String, Node> values) {
+        String description = "semantic question '" + name + "'";
+        String expertContext = " (expert '" + expert + "')";
         values.forEach((field, value) -> {
             if (!FIELDS.contains(field)) {
                 throw new YamlDeserializationException(
-                        value, "Unknown property '" + field + "' in semantic question '" + name + "'");
+                        value, "Unknown property '" + field + "' in " + description + expertContext);
             }
         });
         if (!values.containsKey("type")) {
-            throw new YamlDeserializationException(definition, "Semantic question type is required: " + name);
+            throw new YamlDeserializationException(definition, "Semantic question type is required: " + name + expertContext);
         }
-        SemanticQuestion.Type type = enumeration(values.get("type"), name, "type", SemanticQuestion.Type.class);
+        SemanticQuestion.Type type = enumeration(values.get("type"), name, expert, "type", SemanticQuestion.Type.class);
         Map<String, String> criteria = new LinkedHashMap<>();
         List<String> levels = List.of();
         if (values.containsKey("criteria")) {
@@ -137,36 +144,38 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl
                 levels = asSequenceNode(values.get("criteria")).getValue().stream().map(YamlDeserializerSupport::asText)
                         .toList();
             } else {
-                fields(values.get("criteria"), "criteria for semantic question '" + name + "'")
+                fields(values.get("criteria"), "criteria for " + description + expertContext)
                         .forEach((key, value) -> criteria.put(key, asText(value)));
             }
         }
         if (type != SemanticQuestion.Type.BOOLEAN && (values.containsKey("threshold") || values.containsKey("uncertainty")
                 || values.containsKey("uncertaintyPolicy"))) {
             throw new YamlDeserializationException(
-                    definition, "Threshold and uncertainty policy require a boolean question: " + name);
+                    definition, "Threshold and uncertainty policy require a boolean question: " + name + expertContext);
         }
         SemanticQuestion.UncertaintyPolicy policy = values.containsKey("uncertaintyPolicy")
-                ? enumeration(values.get("uncertaintyPolicy"), name, "uncertaintyPolicy",
+                ? enumeration(values.get("uncertaintyPolicy"), name, expert, "uncertaintyPolicy",
                         SemanticQuestion.UncertaintyPolicy.class)
                 : SemanticQuestion.UncertaintyPolicy.FAIL;
         return new SemanticQuestion(
                 type, asText(values.get("instructions")), asText(values.get("state")),
-                criteria, levels, number(context, values, name, "threshold", 0.5),
-                number(context, values, name, "uncertainty", 0), policy);
+                criteria, levels, number(context, values, name, expert, "threshold", 0.5),
+                number(context, values, name, expert, "uncertainty", 0), policy, asText(values.get("expert")));
     }
 
-    private static <T extends Enum<T>> T enumeration(Node node, String question, String field, Class<T> type) {
+    private static <T extends Enum<T>> T enumeration(Node node, String question, String expert, String field, Class<T> type) {
         try {
             return asEnum(node, type);
         } catch (InvalidEnumException e) {
             throw new YamlDeserializationException(
-                    node, "Invalid value for '" + field + "' in semantic question '" + question + "': " + asText(node), e);
+                    node, "Invalid value for '" + field + "' in semantic question '" + question + "': " + asText(node)
+                          + " (expert '" + expert + "')",
+                    e);
         }
     }
 
     private static double number(
-            CamelContext context, Map<String, Node> values, String question, String name, double fallback) {
+            CamelContext context, Map<String, Node> values, String question, String expert, String name, double fallback) {
         if (!values.containsKey(name)) {
             return fallback;
         }
@@ -177,7 +186,9 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl
         } catch (NumberFormatException e) {
             throw new YamlDeserializationException(
                     node,
-                    "Invalid numeric value for '" + name + "' in semantic question '" + question + "': " + raw, e);
+                    "Invalid numeric value for '" + name + "' in semantic question '" + question + "': " + raw
+                          + " (expert '" + expert + "')",
+                    e);
         }
     }
 
@@ -208,7 +219,8 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl
 
     @YamlType(properties = {
             @YamlProperty(name = "type", type = "enum:boolean", required = true),
-            @YamlProperty(name = "instructions", type = "string", required = true),
+            @YamlProperty(name = "expert", type = "string"),
+            @YamlProperty(name = "instructions", type = "string"),
             @YamlProperty(name = "state", type = "string"),
             @YamlProperty(name = "criteria", type = "map:string"),
             @YamlProperty(name = "threshold", type = "number"),
@@ -220,7 +232,8 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl
 
     @YamlType(properties = {
             @YamlProperty(name = "type", type = "enum:choice", required = true),
-            @YamlProperty(name = "instructions", type = "string", required = true),
+            @YamlProperty(name = "expert", type = "string"),
+            @YamlProperty(name = "instructions", type = "string"),
             @YamlProperty(name = "state", type = "string"),
             @YamlProperty(name = "criteria", type = "map:string", required = true)
     })
@@ -229,7 +242,8 @@ public class SemanticDefinitionDeserializer extends YamlDeserializerSupport impl
 
     @YamlType(properties = {
             @YamlProperty(name = "type", type = "enum:score", required = true),
-            @YamlProperty(name = "instructions", type = "string", required = true),
+            @YamlProperty(name = "expert", type = "string"),
+            @YamlProperty(name = "instructions", type = "string"),
             @YamlProperty(name = "state", type = "string"),
             @YamlProperty(name = "criteria", type = "array:string", required = true)
     })
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java
index ff40c43e190b..44b329d4f058 100644
--- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticBatchTest.java
@@ -278,10 +278,26 @@ class SemanticBatchTest {
         assertThatThrownBy(() -> expression.evaluate(exchange, Map.class))
                 .hasMessageContaining("Unknown semantic question: second");
         assertThat(exchange.getProperty(SemanticLanguage.RESULTS)).isNull();
-        questions.replace("test", Map.of("first", old, "second",
-                question(SemanticQuestion.Type.BOOLEAN, "${header.changed}", 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL)));
-        assertThatThrownBy(() -> expression.evaluate(exchange, Map.class))
+        assertThatThrownBy(() -> questions.replace("test", Map.of("first", old, "second",
+                question(SemanticQuestion.Type.BOOLEAN, "${header.changed}", 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL))))
+                .hasMessageContaining("same effective state selector");
+        assertThat(questions.get("first")).isSameAs(old);
+        assertThatThrownBy(() -> questions.get("second")).hasMessageContaining("Unknown semantic question");
+        assertThatThrownBy(() -> expression.evaluate(exchange, Map.class)).hasMessageContaining("Unknown semantic question");
+    }
+
+    @Test
+    void reloadChecksBatchSelectorsAcrossResourcesAndRetainsThePreviousSnapshot() {
+        SemanticQuestions questions = SemanticQuestions.get(context);
+        SemanticQuestion original = questions.get("urgent");
+        questions.replace("other", Map.of("other", original));
+        Expression expression = language.createExpression("refs:urgent,other");
+        assertThatThrownBy(() -> questions.replace("other", Map.of("other",
+                question(SemanticQuestion.Type.BOOLEAN, "${header.changed}", 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL))))
                 .hasMessageContaining("same effective state selector");
+        assertThat(adapter.calls).isEmpty();
+        assertThat(questions.get("other")).isSameAs(original);
+        assertThat(expression.evaluate(exchange, Map.class)).containsEntry("urgent", true).containsEntry("other", true);
     }
 
     @Test
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticCapabilitiesTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticCapabilitiesTest.java
new file mode 100644
index 000000000000..3f307f20ec1e
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticCapabilitiesTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.semantic;
+
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.semantic.SemanticExpert.InputType;
+import org.apache.camel.semantic.SemanticExpert.Instructions;
+import org.apache.camel.semantic.SemanticExpert.ResultType;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SemanticCapabilitiesTest {
+    @Test
+    void configuredCapabilitiesAreIndependentImmutableAndOrdered() {
+        ResultType[] types = { ResultType.SCORE, ResultType.BOOLEAN, ResultType.CHOICE };
+        var builder = SemanticCapabilities.builder().name("general").inputTypes(InputType.STRUCTURED, InputType.TEXT)
+                .resultTypes(types).confidenceTypes(types).callerDefinedCriteria(true).maxChoices(10);
+        var capabilities = builder.build();
+        types[0] = ResultType.BOOLEAN;
+        builder.resultTypes(ResultType.BOOLEAN).maxChoices(1);
+        var narrowed = capabilities.toBuilder().resultTypes(ResultType.BOOLEAN).maxChoices(2).build();
+        assertThat(capabilities.isKnown()).isTrue();
+        assertThat(capabilities.getResultTypes()).containsExactly(ResultType.BOOLEAN, ResultType.CHOICE, ResultType.SCORE);
+        assertThat(capabilities.getInputTypes()).containsExactly(InputType.TEXT, InputType.STRUCTURED);
+        assertThat(capabilities.getConfidenceTypes()).containsExactly(ResultType.BOOLEAN, ResultType.CHOICE, ResultType.SCORE);
+        assertThatThrownBy(() -> capabilities.getResultTypes().clear()).isInstanceOf(UnsupportedOperationException.class);
+        assertThat(capabilities.getMaxChoices()).isEqualTo(10);
+        assertThat(narrowed.getMaxChoices()).isEqualTo(2);
+        assertThat(narrowed.getName()).isEqualTo("general");
+        assertThat(narrowed.getResultTypes()).containsExactly(ResultType.BOOLEAN);
+        assertThatThrownBy(() -> narrowed.validate(question(SemanticQuestion.Type.CHOICE, Map.of("a", "A"), List.of())))
+                .hasMessageContaining("supports [BOOLEAN]");
+    }
+
+    @Test
+    void choiceLimitsDoNotApplyToBooleanCriteriaOrUnlimitedScores() {
+        var capabilities = SemanticCapabilities.builder().resultTypes(ResultType.values())
+                .callerDefinedCriteria(true).maxChoices(1).build();
+        Map<String, String> criteria = Map.of("true", "Yes", "false", "No");
+        assertThatCode(() -> capabilities.validate(question(SemanticQuestion.Type.BOOLEAN, criteria, List.of())))
+                .doesNotThrowAnyException();
+        assertThatCode(() -> capabilities.validate(question(SemanticQuestion.Type.CHOICE, Map.of("a", "A"), List.of())))
+                .doesNotThrowAnyException();
+        assertThatThrownBy(() -> capabilities.validate(question(SemanticQuestion.Type.CHOICE, criteria, List.of())))
+                .hasMessage("Supports at most 1 choice criteria");
+        assertThatCode(() -> capabilities.validate(question(SemanticQuestion.Type.SCORE, Map.of(), List.of("low", "high"))))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void scoreLimitReportsOnlyTheLimitedDimension() {
+        var capabilities = SemanticCapabilities.builder().resultTypes(ResultType.values())
+                .callerDefinedCriteria(true).maxScoreLevels(1).build();
+        assertThatCode(() -> capabilities.validate(question(SemanticQuestion.Type.SCORE, Map.of(), List.of("low"))))
+                .doesNotThrowAnyException();
+        assertThatThrownBy(() -> capabilities.validate(question(SemanticQuestion.Type.SCORE, Map.of(), List.of("low", "high"))))
+                .hasMessage("Supports at most 1 score levels");
+        assertThatCode(
+                () -> capabilities.validate(question(SemanticQuestion.Type.CHOICE, Map.of("a", "A", "b", "B"), List.of())))
+                .doesNotThrowAnyException();
+    }
+
+    @Test
+    void explicitOptionalInstructionsDoNotClaimUnspecifiedResultSupport() {
+        var capabilities = SemanticCapabilities.builder().instructions(Instructions.OPTIONAL).build();
+        var question = new SemanticQuestion(
+                SemanticQuestion.Type.BOOLEAN, null, null, null, null, 0.5, 0,
+                SemanticQuestion.UncertaintyPolicy.FAIL);
+        assertThatThrownBy(() -> capabilities.validate(question)).hasMessageContaining("supports []");
+        assertThatCode(() -> capabilities.toBuilder().resultTypes(ResultType.BOOLEAN).build().validate(question))
+                .doesNotThrowAnyException();
+    }
+
+    private SemanticQuestion question(SemanticQuestion.Type type, Map<String, String> criteria, List<String> levels) {
+        return new SemanticQuestion(
+                type, "Evaluate this", null, criteria, levels, 0.5, 0,
+                SemanticQuestion.UncertaintyPolicy.FAIL);
+    }
+}
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticExpertTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticExpertTest.java
new file mode 100644
index 000000000000..9d0d2faf4505
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticExpertTest.java
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.semantic;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+
+import org.apache.camel.Service;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.language.semantic.SemanticLanguage;
+import org.apache.camel.semantic.SemanticExpert.InputType;
+import org.apache.camel.semantic.SemanticExpert.Instructions;
+import org.apache.camel.semantic.SemanticExpert.ResultType;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.PluginHelper;
+import org.apache.camel.support.ResourceHelper;
+import org.apache.camel.support.service.ServiceSupport;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SemanticExpertTest {
+    private DefaultCamelContext context;
+    private SemanticLanguage language;
+    private FixedExpert security;
+    private FixedExpert other;
+    private DefaultExchange exchange;
+
+    @BeforeEach
+    void setup() throws Exception {
+        context = new DefaultCamelContext();
+        security = new FixedExpert();
+        other = new FixedExpert();
+        other.probability = 0.1;
+        context.getRegistry().bind("security", security);
+        context.getRegistry().bind("other", other);
+        language = (SemanticLanguage) context.resolveLanguage("semantic");
+        context.start();
+        exchange = new DefaultExchange(context);
+        exchange.getMessage().setBody("untrusted text, passed unchanged");
+    }
+
+    @AfterEach
+    void cleanup() throws Exception {
+        context.close();
+    }
+
+    @Test
+    void explicitDefaultAndSoleExpertSelection() {
+        define("security", "other", 0.5);
+        language.setDefaultExpert("missing-default");
+        var expression = language.createExpression("ref:first");
+        assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+        assertThatThrownBy(() -> define(null, null, 0.5)).hasMessageContaining("missing-default");
+        language.setDefaultExpert("other");
+        define(null, null, 0.5);
+        assertThat(language.createExpression("ref:first").evaluate(exchange, Boolean.class)).isFalse();
+        language.setDefaultExpert(null);
+        assertThatThrownBy(() -> language.createExpression("ref:first"))
+                .hasMessageContaining("first").hasMessageContaining("security").hasMessageContaining("other")
+                .hasMessageContaining("Specify expert");
+        context.getRegistry().unbind("other");
+        context.getRegistry().bind("alias", security);
+        assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void automaticExpertErrorsRetainAStableRegistryName(boolean aliases) {
+        context.getRegistry().unbind("other");
+        var registered = new FixedExpert() {
+        };
+        context.getRegistry().bind("security", registered);
+        if (aliases) {
+            context.getRegistry().bind("z-security", registered);
+            context.getRegistry().bind("a-security", registered);
+        }
+        String name = aliases ? "a-security" : "security";
+        SemanticQuestions.get(context).replace("test", Map.of("first",
+                question(null, "Unsupported", Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5)));
+        assertThatThrownBy(() -> language.createExpression("ref:first"))
+                .hasMessageContaining("evaluation 'first', expert '" + name + "'")
+                .hasMessageContaining("Instructions are unsupported");
+        define(null, null, 0.5);
+        var expression = language.createExpression("ref:first");
+        exchange.getMessage().setBody(Map.of("text", "input"));
+        assertThatThrownBy(() -> expression.evaluate(exchange, Boolean.class))
+                .hasMessageContaining("evaluations [first], expert '" + name + "'")
+                .hasMessageContaining("accepts [TEXT]");
+        assertThat(registered.calls).isZero();
+    }
+
+    @Test
+    void unknownExplicitExpertNeverFallsBackToDefaultOrClassLoading() {
+        language.setDefaultExpert("security");
+        for (String name : List.of("missing", FixedExpert.class.getName(), "#security")) {
+            define(name, "security", 0.5);
+            assertThatThrownBy(() -> language.createExpression("ref:first"))
+                    .hasMessageContaining("first").hasMessageContaining(name);
+        }
+        assertThat(security.calls).isZero();
+        assertThat(other.calls).isZero();
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void legacyAdaptersReportUnknownRatherThanUniversalCapabilities(boolean automatic) {
+        SemanticAdapter legacy = new SemanticAdapter() {
+            @Override
+            public void validate(SemanticQuestion question) {
+            }
+
+            @Override
+            public SemanticResult evaluate(SemanticQuestion question, Object state) {
+                return new SemanticResult(true, null, null, null, null);
+            }
+        };
+        assertThat(legacy.capabilities().isKnown()).isFalse();
+        assertThat(legacy.capabilities().getResultTypes()).isEmpty();
+        context.getRegistry().bind("legacy", legacy);
+        if (automatic) {
+            context.getRegistry().unbind("security");
+            context.getRegistry().unbind("other");
+        }
+        String reference = automatic ? null : "legacy";
+        define(reference, "security", 0.5);
+        assertThatThrownBy(() -> language.createExpression("ref:first"))
+                .hasMessageContaining("first").hasMessageContaining("expert 'legacy'")
+                .hasMessageContaining("instructions are required");
+        SemanticQuestions.get(context).replace("test", Map.of("first",
+                question(reference, "Is this valid?", Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5)));
+        assertThat(language.createExpression("ref:first").evaluate(exchange, Boolean.class)).isTrue();
+    }
+
+    @Test
+    void fixedExpertRejectsInstructionsCriteriaAndChoiceBeforeInference() {
+        List<SemanticQuestion> invalid = List.of(
+                question("security", "Is it valid?", Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5),
+                question("security", null, Map.of("true", "valid"), SemanticQuestion.Type.BOOLEAN, 0.5),
+                question("security", null, Map.of("billing", "invoices"), SemanticQuestion.Type.CHOICE, 0.5));
+        for (SemanticQuestion question : invalid) {
+            SemanticQuestions.get(context).replace("test", Map.of("first", question));
+            assertThatThrownBy(() -> language.createExpression("ref:first"))
+                    .hasMessageContaining("first").hasMessageContaining("security");
+        }
+        assertThat(security.calls).isZero();
+    }
+
+    @Test
+    void mixedBatchGroupsByIdentityAndPublishesResultsInReferenceOrder() {
+        context.getRegistry().bind("alias", security);
+        define("security", "other", 0.5);
+        SemanticQuestions.get(context).replace("alias", Map.of("third", question("alias", null, Map.of(),
+                SemanticQuestion.Type.BOOLEAN, 0.5)));
+        Map<?, ?> decisions = language.createExpression("refs:first,second,third").evaluate(exchange, Map.class);
+        assertThat(decisions.keySet().toArray()).containsExactly("first", "second", "third");
+        assertThat(decisions.get("first")).isEqualTo(true);
+        assertThat(decisions.get("second")).isEqualTo(false);
+        assertThat(decisions.get("third")).isEqualTo(true);
+        assertThat(security.batches).containsExactly(List.of("first", "third"));
+        assertThat(other.batches).containsExactly(List.of("second"));
+        assertThat(security.states).containsOnly(exchange.getMessage().getBody());
+        assertThat(exchange.getProperty(SemanticLanguage.RESULTS, Map.class)).hasSize(3);
+    }
+
+    @Test
+    void anyGroupFailureClearsDiagnosticsAndDoesNotPublishPartialResults() {
+        define("security", "other", 0.5);
+        var expression = language.createExpression("refs:first,second");
+        assertThat(expression.evaluate(exchange, Map.class)).hasSize(2);
+        other.fail = true;
+        exchange.setProperty(SemanticLanguage.RESULT, "stale");
+        assertThatThrownBy(() -> expression.evaluate(exchange, Map.class)).hasMessageContaining("provider unavailable");
+        assertThat(exchange.getProperty(SemanticLanguage.RESULTS)).isNull();
+        assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull();
+        other.fail = false;
+        other.probability = Double.NaN;
+        assertThatThrownBy(() -> expression.evaluate(exchange, Map.class)).isInstanceOf(RuntimeException.class);
+        assertThat(exchange.getProperty(SemanticLanguage.RESULTS)).isNull();
+    }
+
+    @Test
+    void fixedExpertUsesPositiveProbabilityWithThresholdAndUncertaintyPolicies() {
+        define("security", "other", 0.9);
+        var expression = language.createExpression("ref:first");
+        assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+        security.probability = 0.899;
+        assertThat(expression.evaluate(exchange, Boolean.class)).isFalse();
+        SemanticQuestions.get(context).replace("test", Map.of("first", new SemanticQuestion(
+                SemanticQuestion.Type.BOOLEAN, null, null, null, null, 0.9, 0.05,
+                SemanticQuestion.UncertaintyPolicy.FAIL, "security")));
+        assertThatThrownBy(() -> expression.evaluate(exchange, Boolean.class)).hasMessageContaining("uncertain");
+        assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull();
+        SemanticQuestions.get(context).replace("test", Map.of("first", new SemanticQuestion(
+                SemanticQuestion.Type.BOOLEAN, null, null, null, null, 0.9, 0.05,
+                SemanticQuestion.UncertaintyPolicy.NON_MATCH, "security")));
+        assertThat(expression.evaluate(exchange, Boolean.class)).isFalse();
+    }
+
+    @Test
+    void changedDeclarationsAreValidatedBeforePublicationAndReselectExpert() {
+        define("security", "other", 0.5);
+        var expression = language.createExpression("ref:first");
+        var previous = SemanticQuestions.get(context).get("first");
+        assertThatThrownBy(() -> define("missing", "other", 0.5)).hasMessageContaining("missing");
+        assertThat(SemanticQuestions.get(context).get("first")).isSameAs(previous);
+        define("other", "security", 0.5);
+        assertThat(expression.evaluate(exchange, Boolean.class)).isFalse();
+        assertThat(security.calls).isZero();
+        assertThat(other.calls).isOne();
+    }
+
+    @Test
+    void reloadValidatesAllInitializedReferencesAndLeavesUnusedDraftsAlone() {
+        Map<String, SemanticQuestion> definitions = new LinkedHashMap<>();
+        for (String name : List.of("first", "second", "third")) {
+            definitions.put(name, question("security", null, Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5));
+        }
+        definitions.put("draft", question(null, null, Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5));
+        SemanticQuestions questions = SemanticQuestions.get(context);
+        questions.replace("test", definitions);
+        var single = language.createExpression("ref:first");
+        var batch = language.createExpression("refs:second,third");
+        questions.replace("test", definitions);
+        assertThatThrownBy(() -> language.createExpression("ref:draft"))
+                .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("exactly one eligible expert");
+        // A failed expression must not make an unused draft mandatory on the next reload.
+        questions.replace("test", definitions);
+        for (String name : List.of("first", "second", "third")) {
+            Map<String, SemanticQuestion> invalid = new LinkedHashMap<>(definitions);
+            invalid.put(name, question("missing", null, Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5));
+            assertThatThrownBy(() -> questions.replace("test", invalid))
+                    .isInstanceOf(IllegalArgumentException.class).hasMessageContaining(name).hasMessageContaining("missing");
+            assertThat(questions.get(name)).isSameAs(definitions.get(name));
+        }
+        assertThat(single.evaluate(exchange, Boolean.class)).isTrue();
+        assertThat(batch.evaluate(exchange, Map.class)).containsEntry("second", true).containsEntry("third", true);
+    }
+
+    @Test
+    void allInputShapesAreCheckedBeforeAnyBatchInference() {
+        define("security", "other", 0.5);
+        var expression = language.createExpression("refs:first,second");
+        exchange.getMessage().setBody(Map.of("text", "input"));
+        assertThatThrownBy(() -> expression.evaluate(exchange, Map.class))
+                .hasMessageContaining("evaluations [first]").hasMessageContaining("expert 'security'")
+                .hasMessageContaining("accepts [TEXT]");
+        assertThatThrownBy(() -> language.createExpression("ref:second").evaluate(exchange, Boolean.class))
+                .hasMessageContaining("evaluations [second]").hasMessageContaining("expert 'other'");
+        assertThat(security.calls).isZero();
+        assertThat(other.calls).isZero();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "java", "xml" })
+    void declarationsSupportExpertsWithoutInstructionsAndPreservePlaceholders(String dsl) throws Exception {
+        Properties properties = new Properties();
+        properties.setProperty("selected.expert", "security");
+        properties.setProperty("injection.threshold", "0.95");
+        context.getPropertiesComponent().setInitialProperties(properties);
+        if (dsl.equals("java")) {
+            context.addRoutes(new RouteBuilder() {
+                @Override
+                public void configure() {
+                    semanticQuestions(this).question("injection").type("boolean").expert("{{selected.expert}}")
+                            .threshold("{{injection.threshold}}").register();
+                    from("direct:expert").setBody().language("semantic", "ref:injection");
+                }
+            });
+        } else {
+            PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("expert.xml",
+                    """
+                            <routes xmlns="http://camel.apache.org/schema/xml-io">
+                              <semantic><question name="injection" type="boolean" expert="{{selected.expert}}"
+                                                  threshold="{{injection.threshold}}"/></semantic>
+                              <route><from uri="direct:expert"/><setBody><language language="semantic">ref:injection</language></setBody></route>
+                            </routes>
+                            """));
+        }
+        assertThat(SemanticQuestions.get(context).get("injection").getExpert()).isEqualTo("{{selected.expert}}");
+        assertThat(security.calls).isZero();
+        try (var template = context.createProducerTemplate()) {
+            assertThat(template.requestBody("direct:expert", "text", Boolean.class)).isFalse();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "java", "xml" })
+    void invalidDeclarationsNameTheirExpert(String dsl) {
+        for (String threshold : List.of("-0.1", "not-a-number")) {
+            assertThatThrownBy(() -> {
+                if (dsl.equals("java")) {
+                    context.addRoutes(new RouteBuilder() {
+                        @Override
+                        public void configure() {
+                            semanticQuestions(this).question("invalid").type("boolean").expert("security")
+                                    .threshold(threshold).register();
+                        }
+                    });
+                } else {
+                    PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("invalid.xml",
+                            """
+                                    <semantic xmlns="http://camel.apache.org/schema/semantic">
+                                      <question name="invalid" type="boolean" expert="security" threshold="%s"/>
+                                    </semantic>
+                                    """.formatted(threshold)));
+                }
+            }).hasStackTraceContaining("semantic question 'invalid'").hasStackTraceContaining("expert 'security'");
+            assertThat(security.calls).isZero();
+        }
+    }
+
+    @Test
+    void invalidXmlAttributesNameTheirExpert() {
+        assertThatThrownBy(() -> PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString("invalid.xml",
+                """
+                        <semantic xmlns="http://camel.apache.org/schema/semantic">
+                          <question name="invalid" type="boolean" expert="security" thresholdd="0.9"/>
+                        </semantic>
+                        """)))
+                .hasStackTraceContaining("semantic question 'invalid'").hasStackTraceContaining("expert 'security'")
+                .hasStackTraceContaining("thresholdd");
+    }
+
+    @Test
+    void validationDoesNotInitializeOrStartOwnedProviderResources() {
+        ManagedExpert.initialized = 0;
+        ManagedExpert.started = 0;
+        language.setAdapter(ManagedExpert.class.getName());
+        SemanticQuestions.get(context).replace("test", Map.of("first",
+                question(null, "Unsupported", Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5)));
+        assertThatThrownBy(() -> language.createExpression("ref:first")).hasMessageContaining("Instructions are unsupported");
+        assertThat(ManagedExpert.initialized).isZero();
+        assertThat(ManagedExpert.started).isZero();
+        define(null, null, 0.5);
+        language.createExpression("ref:first");
+        assertThat(ManagedExpert.initialized).isOne();
+        assertThat(ManagedExpert.started).isOne();
+    }
+
+    @SemanticExpert(name = "managed", description = "Fixed detection", provider = "test", artifactId = "test",
+                    inputTypes = InputType.TEXT, resultTypes = ResultType.BOOLEAN, instructions = Instructions.UNSUPPORTED,
+                    callerDefinedCriteria = false)
+    public static class ManagedExpert extends ServiceSupport implements SemanticAdapter {
+        static int initialized;
+        static int started;
+
+        @Override
+        protected void doInit() {
+            initialized++;
+        }
+
+        @Override
+        protected void doStart() {
+            started++;
+        }
+
+        @Override
+        public void validate(SemanticQuestion question) {
+        }
+
+        @Override
+        public SemanticResult evaluate(SemanticQuestion question, Object state) {
+            return new SemanticResult(true, null, null, null, null);
+        }
+    }
+
+    @Test
+    void directServiceImplementationIsInitializedBeforeStartup() {
+        DirectService.events.clear();
+        language.setAdapter(DirectService.class.getName());
+        define(null, null, 0.5);
+        language.createExpression("ref:first");
+        language.createExpression("ref:second");
+        assertThat(DirectService.events).containsExactly("init", "start");
+    }
+
+    public static class DirectService extends FixedExpert implements Service {
+        static final List<String> events = new ArrayList<>();
+
+        @Override
+        public void init() {
+            events.add("init");
+        }
+
+        @Override
+        public void start() {
+            events.add("start");
+        }
+
+        @Override
+        public void stop() {
+            events.add("stop");
+        }
+    }
+
+    @Test
+    void booleanOnlyExpertRejectsProbabilityPoliciesBeforeStarting() {
+        context.getRegistry().bind("label", new ManagedExpert());
+        define("label", "security", 0.8);
+        assertThatThrownBy(() -> language.createExpression("ref:first"))
+                .hasMessageContaining("first").hasMessageContaining("label").hasMessageContaining("probability support");
+    }
+
+    @Test
+    void instructionDrivenAndFixedExpertsShareOneBatch() {
+        context.getRegistry().bind("general", new SemanticLanguageTest.LabelAdapter());
+        SemanticQuestions.get(context).replace("test", Map.of(
+                "injection", question("security", null, Map.of(), SemanticQuestion.Type.BOOLEAN, 0.5),
+                "department", question("general", "Which department?", Map.of("billing", "invoices"),
+                        SemanticQuestion.Type.CHOICE, 0.5)));
+        var result = language.createExpression("refs:injection,department").evaluate(exchange, Map.class);
+        assertThat(result).containsEntry("injection", true).containsEntry("department", "billing");
+        assertThat(security.batches).containsExactly(List.of("injection"));
+    }
+
+    private void define(String first, String second, double threshold) {
+        SemanticQuestions.get(context).replace("test", Map.of(
+                "first", question(first, null, Map.of(), SemanticQuestion.Type.BOOLEAN, threshold),
+                "second", question(second, null, Map.of(), SemanticQuestion.Type.BOOLEAN, threshold)));
+    }
+
+    private static SemanticQuestion question(
+            String expert, String instructions, Map<String, String> criteria,
+            SemanticQuestion.Type type, double threshold) {
+        return new SemanticQuestion(
+                type, instructions, null, criteria, List.of(), threshold, 0,
+                SemanticQuestion.UncertaintyPolicy.FAIL, expert);
+    }
+
+    @SemanticExpert(name = "fixed", description = "Prompt-injection detection", provider = "test", artifactId = "test",
+                    inputTypes = InputType.TEXT, resultTypes = ResultType.BOOLEAN, instructions = Instructions.UNSUPPORTED,
+                    callerDefinedCriteria = false, booleanProbability = true, trueMeaning = "Injection detected",
+                    probabilityMeaning = "Probability of INJECTION, even when BENIGN wins")
+    public static class FixedExpert implements SemanticAdapter {
+        double probability = 0.9;
+        boolean fail;
+        int calls;
+        final List<List<String>> batches = new ArrayList<>();
+        final List<Object> states = new ArrayList<>();
+
+        @Override
+        public void validate(SemanticQuestion question) {
+            capabilities().validate(question);
+        }
+
+        @Override
+        public SemanticResult evaluate(SemanticQuestion question, Object state) {
+            calls++;
+            states.add(state);
+            if (fail) {
+                throw new IllegalStateException("provider unavailable");
+            }
+            return new SemanticResult(null, probability, null, null, Map.of("provider", "fixed"));
+        }
+
+        @Override
+        public Map<String, SemanticResult> evaluateBatch(Map<String, SemanticQuestion> questions, Object state)
+                throws Exception {
+            batches.add(List.copyOf(questions.keySet()));
+            return SemanticAdapter.super.evaluateBatch(questions, state);
+        }
+
+        @Override
+        public boolean equals(Object other) {
+            return other instanceof FixedExpert;
+        }
+
+        @Override
+        public int hashCode() {
+            return 1;
+        }
+    }
+}
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticExpertYamlTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticExpertYamlTest.java
new file mode 100644
index 000000000000..f5923f097912
--- /dev/null
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticExpertYamlTest.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.semantic;
+
+import org.apache.camel.dsl.yaml.common.YamlDeserializationContext;
+import org.apache.camel.dsl.yaml.common.exception.YamlDeserializationException;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.semantic.SemanticExpert.InputType;
+import org.apache.camel.semantic.SemanticExpert.Instructions;
+import org.apache.camel.semantic.SemanticExpert.ResultType;
+import org.apache.camel.support.DefaultExchange;
+import org.apache.camel.support.ResourceHelper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.snakeyaml.engine.v2.api.LoadSettings;
+import org.snakeyaml.engine.v2.api.lowlevel.Compose;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SemanticExpertYamlTest {
+    private static final String DECLARATIONS = """
+            - semantic:
+                question:
+                  injection:
+                    expert: "{{security.expert:security}}"
+                    type: boolean
+                    threshold: "{{security.threshold:0.9}}"
+            """;
+
+    @Test
+    void initialPreParseAcceptsAnExpertRegisteredBeforeExpressionInitialization() throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            preParse(context, DECLARATIONS);
+            var expert = new FixedExpert();
+            context.getRegistry().bind("security", expert);
+            context.start();
+            var expression = context.resolveLanguage("semantic").createExpression("ref:injection");
+            var exchange = new DefaultExchange(context);
+            exchange.getMessage().setBody("text");
+            assertThat(expression.evaluate(exchange, Boolean.class)).isFalse();
+            var question = SemanticQuestions.get(context).get("injection");
+            assertThat(question.getExpert()).isEqualTo("{{security.expert:security}}");
+            assertThat(question.getInstructions()).isNull();
+            assertThat(expert.calls).isEqualTo(1);
+        }
+    }
+
+    @Test
+    void invalidReloadRetainsPreviousDeclarationAndReportsYamlLocation() throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            var expert = new FixedExpert();
+            context.getRegistry().bind("security", expert);
+            preParse(context, DECLARATIONS);
+            context.start();
+            var expression = context.resolveLanguage("semantic").createExpression("ref:injection");
+            var question = SemanticQuestions.get(context).get("injection");
+            assertThatThrownBy(() -> preParse(context,
+                    DECLARATIONS.replace("type: boolean", "type: boolean\n        instructions: unsupported")))
+                    .isInstanceOfSatisfying(YamlDeserializationException.class, error -> {
+                        assertThat(error).hasMessageContaining("injection").hasMessageContaining("security")
+                                .hasMessageContaining("Instructions are unsupported");
+                        assertThat(error.getProblemMark()).hasValueSatisfying(mark -> {
+                            assertThat(mark.getName()).isEqualTo("questions.yaml");
+                            assertThat(mark.getLine()).isZero();
+                        });
+                    });
+            assertThat(SemanticQuestions.get(context).get("injection")).isSameAs(question);
+            assertThat(expert.calls).isZero();
+            preParse(context, DECLARATIONS.replace("threshold: \"{{security.threshold:0.9}}\"", "threshold: 0.7"));
+            var exchange = new DefaultExchange(context);
+            exchange.getMessage().setBody("text");
+            assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "threshold: -0.1", "threshold: invalid", "uncertaintyPolicy: invalid", "thresholdd: 0.9",
+            "instructions: {invalid: shape}", "state: [invalid]", "type: score\n        criteria: {invalid: shape}" })
+    void invalidDeclarationsNameTheirExpert(String invalid) throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            String yaml = DECLARATIONS.replace("{{security.expert:security}}", "security")
+                    .replace("threshold: \"{{security.threshold:0.9}}\"", invalid);
+            if (invalid.startsWith("type:")) {
+                yaml = yaml.replace("        type: boolean\n", "");
+            }
+            String declaration = yaml;
+            assertThatThrownBy(() -> preParse(context, declaration))
+                    .isInstanceOf(YamlDeserializationException.class)
+                    .hasMessageContaining("semantic question 'injection'").hasMessageContaining("expert 'security'");
+        }
+    }
+
+    @Test
+    void reloadRequiresNewExpertsBeforePreParse() throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            context.getRegistry().bind("security", new FixedExpert());
+            preParse(context, DECLARATIONS);
+            context.start();
+            var expression = context.resolveLanguage("semantic").createExpression("ref:injection");
+            var previous = SemanticQuestions.get(context).get("injection");
+            String replacement = DECLARATIONS.replace("{{security.expert:security}}", "newSecurity");
+            assertThatThrownBy(() -> preParse(context, replacement))
+                    .isInstanceOf(YamlDeserializationException.class).hasMessageContaining("newSecurity");
+            assertThat(SemanticQuestions.get(context).get("injection")).isSameAs(previous);
+            context.getRegistry().bind("newSecurity", new FixedExpert());
+            preParse(context, replacement);
+            var exchange = new DefaultExchange(context);
+            exchange.getMessage().setBody("text");
+            assertThat(expression.evaluate(exchange, Boolean.class)).isFalse();
+        }
+    }
+
+    @Test
+    void unusedDeclarationsDoNotMakeAnUnchangedReloadFail() throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            context.getRegistry().bind("security", new FixedExpert());
+            context.getRegistry().bind("other", new FixedExpert());
+            String declarations = DECLARATIONS + "      draft: {type: boolean}\n";
+            preParse(context, declarations);
+            context.start();
+            context.resolveLanguage("semantic").createExpression("ref:injection");
+            preParse(context, declarations);
+            assertThat(SemanticQuestions.get(context).get("draft").getExpert()).isNull();
+        }
+    }
+
+    private void preParse(DefaultCamelContext context, String yaml) throws Exception {
+        var settings = LoadSettings.builder().setLabel("questions.yaml").build();
+        try (var deserialization = new YamlDeserializationContext(settings)) {
+            deserialization.setCamelContext(context);
+            deserialization.setResource(ResourceHelper.fromString("questions.yaml", yaml));
+            deserialization.start();
+            deserialization.preParse(new Compose(settings).composeString(yaml).orElseThrow());
+        }
+    }
+
+    @SemanticExpert(name = "fixed", description = "Fixed test expert", provider = "test", artifactId = "test",
+                    inputTypes = InputType.TEXT, resultTypes = ResultType.BOOLEAN,
+                    instructions = Instructions.UNSUPPORTED, callerDefinedCriteria = false, booleanProbability = true)
+    private static class FixedExpert implements SemanticAdapter {
+        private int calls;
+
+        @Override
+        public void validate(SemanticQuestion question) {
+        }
+
+        @Override
+        public SemanticResult evaluate(SemanticQuestion question, Object state) {
+            calls++;
+            return new SemanticResult(null, 0.8, null, null, null);
+        }
+    }
+}
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
index 8c9745ae5324..eccd55d9fe4a 100644
--- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticInitializationTest.java
@@ -27,7 +27,6 @@ import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
-import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.impl.DefaultCamelContext;
 import org.apache.camel.language.semantic.SemanticLanguage;
 import org.apache.camel.support.service.ServiceSupport;
@@ -96,7 +95,7 @@ class SemanticInitializationTest {
                     try {
                         language.createExpression("ref:q");
                         return "created";
-                    } catch (RuntimeCamelException e) {
+                    } catch (IllegalArgumentException e) {
                         assertThat(e).hasRootCauseInstanceOf(IllegalArgumentException.class)
                                 .hasRootCauseMessage(
                                         "Semantic adapter registry name is already bound: " + SemanticLanguage.ADAPTER_NAME);
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java
index c95d7f1488cd..4bfbc9467f15 100644
--- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticLanguageTest.java
@@ -150,7 +150,7 @@ class SemanticLanguageTest {
         language.setAdapter(null);
         discovery(declaration);
         assertThatThrownBy(() -> language.createExpression("ref:q"))
-                .isInstanceOf(RuntimeCamelException.class).hasCauseInstanceOf(IllegalArgumentException.class)
+                .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("requires a class property").hasMessageContaining("adapter-0");
         assertThat(CountingAdapter.constructed).hasValue(0);
     }
@@ -177,7 +177,7 @@ class SemanticLanguageTest {
                     }
                 });
         assertThatThrownBy(() -> language.createExpression("ref:q"))
-                .isInstanceOf(RuntimeCamelException.class).hasCauseInstanceOf(IllegalArgumentException.class)
+                .isInstanceOf(IllegalArgumentException.class)
                 .hasMessageContaining("does not match advertised adapter");
         assertThat(CountingAdapter.constructed).hasValue(0);
     }
@@ -214,6 +214,30 @@ class SemanticLanguageTest {
         assertThat(CountingAdapter.started).hasValue(bean ? 0 : 1);
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = { false, true })
+    void registeredSubclassesSupersedeDiscoveryButDistinctInstancesRemainAmbiguous(boolean distinct) throws Exception {
+        language.setAdapter(null);
+        discovery("class=" + CountingAdapter.class.getName());
+        CountingAdapter bean = new CountingAdapter() {
+        };
+        context.getRegistry().bind("custom", bean);
+        context.getRegistry().bind("alias", distinct ? new CountingAdapter() {
+        } : bean);
+        if (distinct) {
+            assertThatThrownBy(() -> language.createExpression("ref:q"))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("available experts: [alias, custom]")
+                    .hasMessageNotContaining("java.lang.IllegalArgumentException:");
+            assertThat(CountingAdapter.constructed).hasValue(2);
+        } else {
+            language.createExpression("ref:q");
+            assertThat(CountingAdapter.constructed).hasValue(1);
+        }
+        assertThat(CountingAdapter.started).hasValue(0);
+        assertThat(context.getRegistry().lookupByName(SemanticLanguage.ADAPTER_NAME)).isNull();
+    }
+
     private void discovery(String... declarations) throws Exception {
         List<URL> urls = new ArrayList<>();
         for (int i = 0; i < declarations.length; i++) {
@@ -379,6 +403,39 @@ class SemanticLanguageTest {
         assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull();
     }
 
+    @Test
+    void initializationChecksReplacementsBeforeRegisteringItsValidator() {
+        AtomicInteger validations = new AtomicInteger();
+        context.getRegistry().bind("changing", new CountingAdapter() {
+            @Override
+            public void validate(SemanticQuestion question) {
+                if (validations.incrementAndGet() == 1) {
+                    questions(question(SemanticQuestion.Type.CHOICE, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL));
+                }
+            }
+        });
+        language.setAdapter("changing");
+        assertThatThrownBy(() -> language.createPredicate("ref:q"))
+                .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("boolean");
+        // A failed initialization must not register a predicate constraint.
+        questions(question(SemanticQuestion.Type.CHOICE, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL));
+        assertThat(language.createExpression("ref:q")).isNotNull();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "${header.broken", "{{missing.selector}}" })
+    void reloadRejectsInvalidStateSelectorsBeforePublication(String selector) {
+        Expression expression = language.createExpression("ref:q");
+        SemanticQuestion previous = SemanticQuestions.get(context).get("q");
+        assertThatThrownBy(() -> questions(
+                question(SemanticQuestion.Type.BOOLEAN, selector, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL)))
+                .isInstanceOf(RuntimeException.class);
+        assertThat(SemanticQuestions.get(context).get("q")).isSameAs(previous);
+        var exchange = new DefaultExchange(context);
+        exchange.getMessage().setBody("content");
+        assertThat(expression.evaluate(exchange, Boolean.class)).isTrue();
+    }
+
     @Test
     void reloadReplacesDefinitionsAndRemovedReferencesFail() {
         Expression expression = language.createExpression("ref:q");
@@ -399,16 +456,20 @@ class SemanticLanguageTest {
     }
 
     @Test
-    void predicateReloadFailureClearsPreviousResult() {
+    void predicateReloadRejectsIncompatibleTypesBeforePublication() {
         Predicate predicate = language.createPredicate("ref:q");
         var exchange = new DefaultExchange(context);
         exchange.getMessage().setBody("content");
         assertThat(predicate.matches(exchange)).isTrue();
         assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNotNull();
-        questions(question(SemanticQuestion.Type.CHOICE, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL));
-        assertThatThrownBy(() -> predicate.matches(exchange))
+        // Registering an expression for the same name must not replace the predicate's validation.
+        language.createExpression("ref:q");
+        SemanticQuestion previous = SemanticQuestions.get(context).get("q");
+        assertThatThrownBy(() -> questions(
+                question(SemanticQuestion.Type.CHOICE, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL)))
                 .isExactlyInstanceOf(IllegalArgumentException.class).hasMessageContaining("boolean");
-        assertThat(exchange.getProperty(SemanticLanguage.RESULT)).isNull();
+        assertThat(SemanticQuestions.get(context).get("q")).isSameAs(previous);
+        assertThat(predicate.matches(exchange)).isTrue();
         exchange.setProperty(SemanticLanguage.RESULT, "old");
         SemanticQuestions.get(context).replace("test", Map.of());
         assertThatThrownBy(() -> predicate.matches(exchange))
@@ -429,8 +490,9 @@ class SemanticLanguageTest {
         assertThat(result.getProbabilities()).isEmpty();
         assertThat(result.getConfidence()).isNull();
         assertThatThrownBy(() -> language.createPredicate("ref:q")).hasMessageContaining("boolean");
-        questions(question(SemanticQuestion.Type.SCORE, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL));
-        assertThatThrownBy(() -> expression.evaluate(exchange, Object.class)).hasMessageContaining("only choice");
+        assertThatThrownBy(() -> questions(question(SemanticQuestion.Type.SCORE, null, 0.5, 0,
+                SemanticQuestion.UncertaintyPolicy.FAIL))).hasMessageContaining("only choice");
+        assertThat(expression.evaluate(exchange, String.class)).isEqualTo("billing");
     }
 
     public static class CountingAdapter extends ServiceSupport implements SemanticAdapter {
diff --git a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java
index bf8927d96612..efc18abe3711 100644
--- a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java
+++ b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticPropertiesTest.java
@@ -31,18 +31,19 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class SemanticPropertiesTest {
     @ParameterizedTest
-    @ValueSource(strings = { "bean", "class", "placeholder" })
+    @ValueSource(strings = { "bean", "class", "placeholder", "defaultExpert" })
     void camelMainBindsAdapterAndDefaultState(String selection) throws Exception {
         Main main = new Main();
         SemanticLanguageTest.LabelAdapter bean = new SemanticLanguageTest.LabelAdapter();
         main.bind("classifier", bean);
         String adapter = switch (selection) {
-            case "bean" -> "classifier";
+            case "bean", "defaultExpert" -> "classifier";
             case "placeholder" -> "{{adapter.name}}";
             default -> SemanticLanguageTest.LabelAdapter.class.getName();
         };
         main.addProperty("adapter.name", "classifier");
-        main.addProperty("camel.language.semantic.adapter", adapter);
+        main.addProperty(selection.equals("defaultExpert")
+                ? "camel.language.semantic.default-expert" : "camel.language.semantic.adapter", adapter);
         main.addProperty("camel.language.semantic.default-state", "${header.selected}");
         main.configure().addRoutesBuilder(new RouteBuilder() {
             public void configure() {
@@ -54,7 +55,7 @@ class SemanticPropertiesTest {
         });
         try {
             main.start();
-            if (selection.equals("bean") || selection.equals("placeholder")) {
+            if (!selection.equals("class")) {
                 assertThat(main.getCamelContext().getRegistry().lookupByName(SemanticLanguage.ADAPTER_NAME)).isNull();
             } else {
                 assertThat(main.getCamelContext().getRegistry().lookupByName(SemanticLanguage.ADAPTER_NAME))
diff --git a/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java b/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java
index 3dd1ed114fef..67c3df0e8cf0 100644
--- a/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java
+++ b/components/camel-ai/camel-typesafe-ai/src/main/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapter.java
@@ -23,6 +23,10 @@ import java.util.Map;
 import org.apache.camel.CamelContext;
 import org.apache.camel.CamelContextAware;
 import org.apache.camel.semantic.SemanticAdapter;
+import org.apache.camel.semantic.SemanticExpert;
+import org.apache.camel.semantic.SemanticExpert.InputType;
+import org.apache.camel.semantic.SemanticExpert.Instructions;
+import org.apache.camel.semantic.SemanticExpert.ResultType;
 import org.apache.camel.semantic.SemanticQuestion;
 import org.apache.camel.semantic.SemanticResult;
 import org.apache.camel.spi.annotations.JdkService;
@@ -30,6 +34,17 @@ import org.apache.camel.util.json.JsonObject;
 
 /** Maps common questions to TypeSafe AI using the component's configured, managed transport. */
 @JdkService("semantic-adapter")
+@SemanticExpert(name = "typesafe-ai", provider = "typesafe-ai", artifactId = "camel-typesafe-ai",
+                description = "Instruction-driven boolean decisions, choices and rubric scores using TypeSafe AI",
+                inputTypes = { InputType.TEXT, InputType.STRUCTURED },
+                resultTypes = { ResultType.BOOLEAN, ResultType.CHOICE, ResultType.SCORE },
+                instructions = Instructions.REQUIRED, callerDefinedCriteria = true,
+                booleanProbability = true, choiceProbabilities = true,
+                confidenceTypes = { ResultType.BOOLEAN, ResultType.CHOICE, ResultType.SCORE },
+                probabilityMeaning = "Boolean: probability of true; choice: probability of each supplied category",
+                confidenceMeaning = "Optional provider-reported confidence; distinct from the normalized decision",
+                trueMeaning = "The supplied boolean instructions and criteria hold for the selected state",
+                maxChoices = 255, maxScoreLevels = 10)
 public class TypeSafeAiSemanticAdapter implements SemanticAdapter, CamelContextAware {
     private CamelContext camelContext;
     private volatile TypeSafeAiEndpoint endpoint;
@@ -46,10 +61,7 @@ public class TypeSafeAiSemanticAdapter implements SemanticAdapter, CamelContextA
 
     @Override
     public void validate(SemanticQuestion question) {
-        if (question.getType() == SemanticQuestion.Type.CHOICE && question.getCriteria().size() > 255
-                || question.getType() == SemanticQuestion.Type.SCORE && question.getLevels().size() > 10) {
-            throw new IllegalArgumentException("TypeSafe AI supports at most 255 choice criteria or 10 score levels");
-        }
+        capabilities().validate(question);
     }
 
     private TypeSafeAiEndpoint endpoint() {
diff --git a/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java b/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java
index 49fd663b6bd1..739a7b1a0b1f 100644
--- a/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java
+++ b/components/camel-ai/camel-typesafe-ai/src/test/java/org/apache/camel/component/typesafeai/TypeSafeAiSemanticAdapterTest.java
@@ -24,6 +24,7 @@ import java.util.stream.IntStream;
 import org.apache.camel.Exchange;
 import org.apache.camel.Expression;
 import org.apache.camel.language.semantic.SemanticLanguage;
+import org.apache.camel.semantic.SemanticExpert.ResultType;
 import org.apache.camel.semantic.SemanticQuestion;
 import org.apache.camel.semantic.SemanticQuestions;
 import org.apache.camel.semantic.SemanticResult;
@@ -39,6 +40,40 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class TypeSafeAiSemanticAdapterTest extends TypeSafeAiTestSupport {
+    @Test
+    void advertisesCapabilitiesAndRequiresInstructionsWithoutTransport() {
+        TypeSafeAiSemanticAdapter adapter = new TypeSafeAiSemanticAdapter();
+        var capabilities = adapter.capabilities();
+        assertThat(capabilities.isKnown()).isTrue();
+        assertThat(capabilities.getName()).isEqualTo("typesafe-ai");
+        assertThat(capabilities.getArtifactId()).isEqualTo("camel-typesafe-ai");
+        assertThat(capabilities.getMaxChoices()).isEqualTo(255);
+        assertThat(capabilities.getMaxScoreLevels()).isEqualTo(10);
+        assertThat(capabilities.getResultTypes()).containsExactly(ResultType.BOOLEAN, ResultType.CHOICE, ResultType.SCORE);
+        assertThat(capabilities.isBooleanProbability()).isTrue();
+        SemanticQuestion fixed = new SemanticQuestion(
+                SemanticQuestion.Type.BOOLEAN, null, null,
+                null, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL);
+        assertThatThrownBy(() -> adapter.validate(fixed)).hasMessageContaining("instructions are required");
+        assertThat(requests).isEmpty();
+    }
+
+    @Test
+    void subclassesRetainCapabilitiesAndProviderValidation() {
+        TypeSafeAiSemanticAdapter subclass = new TypeSafeAiSemanticAdapter() {
+        };
+        assertThat(subclass.capabilities()).usingRecursiveComparison()
+                .isEqualTo(new TypeSafeAiSemanticAdapter().capabilities());
+        SemanticQuestion missing = new SemanticQuestion(
+                SemanticQuestion.Type.BOOLEAN, null, null,
+                null, null, 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL);
+        assertThatThrownBy(() -> subclass.validate(missing)).hasMessageContaining("instructions are required");
+        SemanticQuestion score = new SemanticQuestion(
+                SemanticQuestion.Type.SCORE, "Score", null, null,
+                IntStream.range(0, 11).mapToObj(i -> "Level " + i).toList(), 0.5, 0, SemanticQuestion.UncertaintyPolicy.FAIL);
+        assertThatThrownBy(() -> subclass.validate(score)).hasMessageContaining("10 score levels");
+    }
+
     @Test
     void directEvaluationInitializesTransportWithoutPriorValidation() throws Exception {
         respond = request -> result(Map.of("question", Map.of("type", "noul", "noul", 0.9)));
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json
index 2d6f52330f59..0a77ca002da4 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-canonical.json
@@ -13602,6 +13602,9 @@
               "type" : "string"
             }
           },
+          "expert" : {
+            "type" : "string"
+          },
           "instructions" : {
             "type" : "string"
           },
@@ -13623,7 +13626,7 @@
             "enum" : [ "fail", "non-match" ]
           }
         },
-        "required" : [ "instructions", "type" ]
+        "required" : [ "type" ]
       },
       "org.apache.camel.semantic.yaml.SemanticDefinitionDeserializer$ChoiceSchema" : {
         "type" : "object",
@@ -13635,6 +13638,9 @@
               "type" : "string"
             }
           },
+          "expert" : {
+            "type" : "string"
+          },
           "instructions" : {
             "type" : "string"
           },
@@ -13646,7 +13652,7 @@
             "enum" : [ "choice" ]
           }
         },
-        "required" : [ "criteria", "instructions", "type" ]
+        "required" : [ "criteria", "type" ]
       },
       "org.apache.camel.semantic.yaml.SemanticDefinitionDeserializer$QuestionSchema" : {
         "type" : "object",
@@ -13670,6 +13676,9 @@
               "type" : "string"
             }
           },
+          "expert" : {
+            "type" : "string"
+          },
           "instructions" : {
             "type" : "string"
           },
@@ -13681,7 +13690,7 @@
             "enum" : [ "score" ]
           }
         },
-        "required" : [ "criteria", "instructions", "type" ]
+        "required" : [ "criteria", "type" ]
       }
     },
     "properties" : {
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
index 79ce18977038..3d446416f7f8 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
@@ -20536,10 +20536,12 @@
       "children" : [ {
         "name" : "criteria",
         "type" : "object"
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
@@ -20565,10 +20567,12 @@
         "name" : "criteria",
         "type" : "object",
         "required" : true
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
@@ -20583,10 +20587,12 @@
       "children" : [ {
         "name" : "criteria",
         "type" : "any"
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
@@ -20612,10 +20618,12 @@
         "name" : "criteria",
         "type" : "array",
         "required" : true
+      }, {
+        "name" : "expert",
+        "type" : "string"
       }, {
         "name" : "instructions",
-        "type" : "string",
-        "required" : true
+        "type" : "string"
       }, {
         "name" : "state",
         "type" : "string"
diff --git a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json
index 31a46e9a5ee5..6dc8dda2f6c1 100644
--- a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json
+++ b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl.json
@@ -16502,6 +16502,9 @@
               "type" : "string"
             }
           },
+          "expert" : {
+            "type" : "string"
+          },
           "instructions" : {
             "type" : "string"
           },
@@ -16523,7 +16526,7 @@
             "enum" : [ "fail", "non-match" ]
           }
         },
-        "required" : [ "instructions", "type" ]
+        "required" : [ "type" ]
       },
       "org.apache.camel.semantic.yaml.SemanticDefinitionDeserializer$ChoiceSchema" : {
         "type" : "object",
@@ -16535,6 +16538,9 @@
               "type" : "string"
             }
           },
+          "expert" : {
+            "type" : "string"
+          },
           "instructions" : {
             "type" : "string"
           },
@@ -16546,7 +16552,7 @@
             "enum" : [ "choice" ]
           }
         },
-        "required" : [ "criteria", "instructions", "type" ]
+        "required" : [ "criteria", "type" ]
       },
       "org.apache.camel.semantic.yaml.SemanticDefinitionDeserializer$QuestionSchema" : {
         "type" : "object",
@@ -16570,6 +16576,9 @@
               "type" : "string"
             }
           },
+          "expert" : {
+            "type" : "string"
+          },
           "instructions" : {
             "type" : "string"
           },
@@ -16581,7 +16590,7 @@
             "enum" : [ "score" ]
           }
         },
-        "required" : [ "criteria", "instructions", "type" ]
+        "required" : [ "criteria", "type" ]
       },
       "org.apache.camel.dsl.yaml.deserializers.OutputAwareFromDefinition$Inline" : {
         "type" : "object",