You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ba...@apache.org on 2026/10/08 08:31:57 UTC

(hop) branch main updated: AI Assistant hardening #8757 (#8784)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new 798fe0afc1 AI Assistant hardening #8757 (#8784)
798fe0afc1 is described below

commit 798fe0afc156b82d7484465251f1487764082d7c
Author: Bart Maertens <ba...@know.bi>
AuthorDate: Thu Oct 8 10:31:50 2026 +0200

    AI Assistant hardening #8757 (#8784)
    
    * Issue #8757 : AI Assistant hardening
    
    Fix first:
    - AI Provider keeps its type on save; files saved without one can be repaired
    - Checked context (and the log of a new run) is sent on every turn
    - Secrets masked by encoder prefix and field name suffix, URL credentials masked
    - Sharing line shows what is actually sent
    - Sessions matched on the pipeline/workflow, not its name
    - Ctrl/Cmd+Shift shortcuts work from text fields
    
    Context and prompts:
    - Context size and max output tokens on AI Provider (Ollama num_ctx 16k default)
    - Prompt size check before sending, compact plugin catalog, transform settings
    - Tagged context, answer in the user's language, proposals only when asked
    - Proposals repaired once (JSON-only), recovered from text, normalised before review
    - Evaluation set against real models (skipped unless configured)
    
    Proposals and data safety:
    - Protected metadata types, overwrite confirmation and undo, all-or-nothing apply
    - Deletes, replacements and overwrites opt-in; AGENTS.md no longer read by default
    - Payload preview and All button in the metadata picker
    
    Sessions:
    - Scoped to the project, linkable to the open file, kept per project in the audit folder
    - Prompt built on the UI thread, Stop frees the session, closed files released
    
    Window and provider editor:
    - One name (AI Assistant), i18n, empty states, Send feedback, review dialog states
    - Float/Dock move the view and are remembered; question field grows with its text
    - Prompt history with Up/Down, focus line, Sharing explanations
    - Provider editor in tabs, model column from Refresh models, validation on save
    
    * Issue #8757 : Close the gaps found in review: structured answers, all-or-nothing apply, metadata allow list, close option, log choice per run, i18n, window tests
    
    * Issue #8757 : Fix findings from the PR review: rollback keeps the changed flag, hop reversal, alias scoping, undo metadata confirmation, structured answer retry, repair results
    
    * Issue #8757 : Fix findings from the second review: keep applied changes until answered and across restarts, block hop loops of any length, keep options that are off, stop waiting questions when Hop GUI exits
---
 config-doc/pom.xml                                 |  16 +
 .../apache/hop/config/doc/ConfigDocGenerator.java  |  13 +-
 .../hop/ai/advisor/AiAdvisorOpenRequest.java       |   7 +
 .../apache/hop/ai/advisor/AiAdvisorResponse.java   |   6 +
 .../apache/hop/ai/advisor/AiProposalParser.java    |  59 +-
 .../hop/ai/advisor/AiProposalValidation.java       |  11 +
 .../hop/ai/provider/AiProviderObjectFactory.java   |  44 +-
 .../ai/provider/messages/messages_en_US.properties |  20 +
 .../hop/ai/advisor/AiProposalParserTest.java       |  41 +
 .../hop/ai/provider/AiProviderPluginTypeTest.java  |  16 +
 .../modules/ROOT/pages/ai-advisor-plugins.adoc     |  64 +-
 .../modules/ROOT/pages/hop-gui/index.adoc          |   2 +-
 .../ROOT/pages/hop-gui/perspective-ai-advisor.adoc | 144 ++--
 .../ROOT/pages/metadata-types/ai-provider.adoc     |  31 +-
 .../modules/ROOT/pages/technology/ai/index.adoc    |  14 +-
 .../partials/config/HopAiConfigOptionPlugin.adoc   |  32 +-
 .../modules/ROOT/partials/config/all-plugins.adoc  |  32 +-
 plugins/tech/ai/pom.xml                            |   7 +
 .../hop/ai/advisors/AiAdvisorInclusions.java       |   1 +
 .../ai/advisors/pipeline/PipelineAiAdvisor.java    |  13 +-
 .../pipeline/PipelineAiContextBuilder.java         | 145 ++--
 .../pipeline/PipelineAiProposalApplier.java        | 125 ++-
 .../pipeline/PipelineAiProposalValidator.java      | 301 +++++--
 .../ai/advisors/workflow/WorkflowAiAdvisor.java    |  13 +-
 .../workflow/WorkflowAiContextBuilder.java         | 145 ++--
 .../workflow/WorkflowAiProposalApplier.java        | 123 ++-
 .../workflow/WorkflowAiProposalValidator.java      | 148 +++-
 .../apache/hop/ai/config/AiRequestOnClose.java}    |  34 +-
 .../java/org/apache/hop/ai/config/HopAiConfig.java |  28 +-
 .../hop/ai/config/HopAiConfigOptionPlugin.java     |  69 +-
 .../apache/hop/ai/config/HopAiConfigSingleton.java |   2 +
 .../hop/ai/engine/AiActionPluginSupport.java       |   6 +-
 .../org/apache/hop/ai/engine/AiAdvisorEngine.java  | 572 ++++++++++++-
 .../hop/ai/engine/AiAdvisorMetadataContext.java    |  25 +-
 .../org/apache/hop/ai/engine/AiChatFactory.java    | 174 +++-
 .../apache/hop/ai/engine/AiChatModelFactory.java   |  77 +-
 .../apache/hop/ai/engine/AiM2PromptSupport.java    |  23 +-
 .../AiMetadataBackup.java}                         |  37 +-
 .../hop/ai/engine/AiMetadataProposalSupport.java   | 206 ++++-
 .../org/apache/hop/ai/engine/AiModelCatalog.java   |  25 +-
 .../org/apache/hop/ai/engine/AiNodeSettings.java   | 147 ++++
 .../org/apache/hop/ai/engine/AiPluginCatalog.java  |  80 ++
 .../apache/hop/ai/engine/AiProposalNormalizer.java | 515 ++++++++++++
 .../org/apache/hop/ai/engine/AiProposalSchema.java | 224 +++++
 .../hop/ai/engine/AiProposalTextRecovery.java      | 128 +++
 .../org/apache/hop/ai/engine/AiProposalTypes.java  |  38 +
 .../org/apache/hop/ai/engine/AiProposalUndo.java   |  49 ++
 .../apache/hop/ai/engine/AiProposalXmlSupport.java |  26 +-
 .../org/apache/hop/ai/engine/AiProviderLoader.java |   9 +-
 .../apache/hop/ai/engine/AiProviderSettings.java   |  94 ++-
 .../java/org/apache/hop/ai/engine/AiTextUtil.java  | 175 +++-
 .../hop/ai/engine/AiTransformPluginSupport.java    |   6 +-
 .../org/apache/hop/ai/engine/AiUserException.java} |  30 +-
 .../org/apache/hop/ai/metadata/AiProvider.java     | 137 +++-
 .../apache/hop/ai/metadata/AiProviderEditor.java   |  89 +-
 .../apache/hop/ai/metadata/AiProviderModel.java    |   4 +-
 .../apache/hop/ai/session/AiAdvisorSession.java    |  53 +-
 .../hop/ai/session/AiAdvisorSessionArchive.java    | 329 ++++++++
 .../hop/ai/session/AiAdvisorSessionStore.java      | 304 ++++++-
 .../org/apache/hop/ai/session/AiAdvisorTurn.java   |  26 +
 .../java/org/apache/hop/ai/ui/AiAdvisorDialog.java |  12 +
 .../ai/ui/AiAdvisorMetadataSelectionDialog.java    |  19 +-
 .../org/apache/hop/ai/ui/AiAdvisorPerspective.java |   2 +
 .../org/apache/hop/ai/ui/AiAdvisorPlugins.java     |   8 +-
 .../ai/ui/AiAdvisorProjectEnabledExtension.java    |  47 ++
 .../hop/ai/ui/AiAdvisorProposalReviewDialog.java   | 128 ++-
 .../org/apache/hop/ai/ui/AiAdvisorSessionPane.java | 913 ++++++++++++++++++---
 .../apache/hop/ai/ui/AiAdvisorTranscriptPanel.java |  81 +-
 .../java/org/apache/hop/ai/ui/AiAdvisorViews.java  |  90 ++
 .../org/apache/hop/ai/ui/AiAdvisorWorkbench.java   | 129 ++-
 .../hop/ai/ui/HopGuiAiAdvisorWorkbenchHost.java    |   5 +
 .../apache/hop/ai/ui/IAiAdvisorWorkbenchHost.java  |  11 +
 .../org/apache/hop/ai/ui/PipelineAiGuiPlugin.java  |  27 +-
 .../org/apache/hop/ai/ui/WorkflowAiGuiPlugin.java  |  27 +-
 plugins/tech/ai/src/main/resources/ai-context.md   |   2 +-
 .../main/resources/ai-context/pipeline-advisor.md  |   6 +-
 .../main/resources/ai-context/workflow-advisor.md  |   2 +-
 .../pipeline/messages/messages_en_US.properties    |  48 +-
 .../workflow/messages/messages_en_US.properties    |  48 +-
 .../ai/config/messages/messages_en_US.properties   |  15 +-
 .../ai/engine/messages/messages_en_US.properties   |  72 ++
 .../ai/metadata/messages/messages_en_US.properties |  15 +-
 .../prompts/hop-proposals/hop-proposals-schema.txt |  27 +-
 .../hop/ai/prompts/hop-proposals/preamble-m2.txt   |   6 +-
 .../ai/prompts/hop-proposals/savable-metadata.txt  |   1 +
 .../ai/prompts/hop-proposals/structured-answer.txt |   1 +
 .../hop/ai/prompts/pipeline/pipeline-general.txt   |   2 +-
 .../hop/ai/prompts/pipeline/preamble-hop.txt       |  15 +-
 .../ai/prompts/pipeline/transform-selection.txt    |   2 +-
 .../hop/ai/prompts/workflow/action-selection.txt   |   2 +-
 .../hop/ai/prompts/workflow/preamble-hop.txt       |  15 +-
 .../hop/ai/prompts/workflow/workflow-general.txt   |   2 +-
 .../ai/session/messages/messages_en_US.properties  |  20 +
 .../hop/ai/ui/messages/messages_en_US.properties   | 108 ++-
 .../pipeline/AiProposalNormalizerTest.java         | 215 +++++
 .../advisors/pipeline/PipelineAiAdvisorTest.java   |   5 +-
 .../pipeline/PipelineAiContextBuilderTest.java     |  30 +-
 .../pipeline/PipelineAiProposalApplierTest.java    |  83 ++
 .../pipeline/PipelineAiProposalValidatorTest.java  |  81 ++
 .../advisors/workflow/WorkflowAiAdvisorTest.java   |   5 +-
 .../workflow/WorkflowAiContextBuilderTest.java     |  21 +-
 .../workflow/WorkflowAiProposalApplierTest.java    |  54 ++
 .../apache/hop/ai/engine/AiAdvisorEngineTest.java  | 104 +++
 .../ai/engine/AiAdvisorMetadataContextTest.java    |   5 +-
 .../apache/hop/ai/engine/AiChatFactoryTest.java    |  60 ++
 .../hop/ai/engine/AiM2PromptSupportTest.java       |  28 +-
 .../ai/engine/AiMetadataProposalSupportTest.java   | 132 ++-
 .../apache/hop/ai/engine/AiNodeSettingsTest.java   | 110 +++
 .../apache/hop/ai/engine/AiPromptLayoutTest.java   | 150 ++++
 .../apache/hop/ai/engine/AiProposalSchemaTest.java | 220 +++++
 .../hop/ai/engine/AiProposalTextRecoveryTest.java  |  77 ++
 .../hop/ai/engine/AiProviderSettingsTest.java      |   4 +-
 .../org/apache/hop/ai/engine/AiTextUtilTest.java   |  67 ++
 .../org/apache/hop/ai/engine/TestConnection.java}  |  28 +-
 .../apache/hop/ai/eval/AiAdvisorEvaluation.java    | 544 ++++++++++++
 .../hop/ai/metadata/AiProviderLayoutUiTest.java    | 139 ++++
 .../ai/metadata/AiProviderSerializationTest.java   | 104 +++
 .../ai/session/AiAdvisorSessionArchiveTest.java    | 109 +++
 .../hop/ai/session/AiAdvisorSessionStoreTest.java  | 199 +++++
 .../hop/ai/ui/AiAdvisorAppliedChangesTest.java     |  73 ++
 .../hop/ai/ui/AiAdvisorErrorReportingTest.java     |  14 +
 .../hop/ai/ui/AiAdvisorPluginsLocationTest.java    |  10 +
 .../ai/ui/AiAdvisorProposalReviewDialogUiTest.java |  95 +++
 .../hop/ai/ui/AiAdvisorSessionPaneUiTest.java      | 306 +++++++
 .../hop/ai/ui/AiAdvisorSharingSummaryTest.java     | 119 +++
 .../hop/ai/ui/AiAdvisorTranscriptPanelUiTest.java  | 124 +++
 .../apache/hop/ai/ui/AiAdvisorWorkbenchUiTest.java | 319 +++++++
 .../baseline/ollama-phi3_latest-structured.json    |  19 +
 .../hop/ai/eval/baseline/ollama-phi3_latest.json   |  19 +
 ...ma-qwen3.5_35b-a3b-coding-nvfp4-structured.json |  19 +
 .../ollama-qwen3.5_35b-a3b-coding-nvfp4.json       |  19 +
 .../resources/org/apache/hop/ai/eval/cases.json    | 481 +++++++++++
 .../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java |   6 +-
 .../apache/hop/ui/hopgui/HopGuiKeyHandlerTest.java |  47 ++
 134 files changed, 10543 insertions(+), 923 deletions(-)

diff --git a/config-doc/pom.xml b/config-doc/pom.xml
index 02250f12be..1094648d93 100644
--- a/config-doc/pom.xml
+++ b/config-doc/pom.xml
@@ -41,6 +41,22 @@
             <version>${jandex.version}</version>
         </dependency>
 
+        <!-- Configuration classes are instantiated to read their defaults. Some refer to hop-core
+             types, such as an enum that implements IEnumHasCodeAndDescription, so hop-core itself
+             has to be on the classpath; what it depends on does not. -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-core</artifactId>
+            <version>${project.version}</version>
+            <scope>provided</scope>
+            <exclusions>
+                <exclusion>
+                    <groupId>*</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+
         <!-- The hop-* dependencies below are not used in code. They are the modules that declare a
              @ConfigPlugin, and they are here so the reactor builds this one after all of them: the
              generator reads the whole tree's build output, so running it earlier would document a
diff --git a/config-doc/src/main/java/org/apache/hop/config/doc/ConfigDocGenerator.java b/config-doc/src/main/java/org/apache/hop/config/doc/ConfigDocGenerator.java
index 4d88928202..58d30b9612 100644
--- a/config-doc/src/main/java/org/apache/hop/config/doc/ConfigDocGenerator.java
+++ b/config-doc/src/main/java/org/apache/hop/config/doc/ConfigDocGenerator.java
@@ -476,11 +476,14 @@ public class ConfigDocGenerator {
       if (isLongDefault(o.dflt())) {
         // An AsciiDoc cell, so the default can be a block under the description where there is
         // room for it. The Default column is a narrow one and a long value wraps to a word a line.
-        sb.append("a|")
-            .append(text(description))
-            .append("\n\n.Default\n[listing]\n----\n")
-            .append(o.dflt().strip())
-            .append("\n----\n");
+        // Not a listing block: that never wraps, and its longest line sets the width of the whole
+        // table. One line of inline code per line of the value keeps the line breaks and wraps.
+        sb.append("a|").append(text(description)).append("\n\n.Default\n[%hardbreaks]\n");
+        for (String line : o.dflt().strip().split("\\R")) {
+          if (!line.isBlank()) {
+            sb.append(literal(line.strip())).append("\n");
+          }
+        }
         sb.append("|_(with the description)_\n\n");
       } else {
         sb.append("|").append(text(description)).append("\n");
diff --git a/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorOpenRequest.java b/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorOpenRequest.java
index ea5143892b..cdbf7e46c8 100644
--- a/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorOpenRequest.java
+++ b/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorOpenRequest.java
@@ -54,6 +54,13 @@ public class AiAdvisorOpenRequest {
   private String focusNodeName;
   private Object artifact;
   private Supplier<String> logSupplier;
+
+  /**
+   * Identifies the latest run of the pipeline or workflow, for example its log channel id, or
+   * returns null before the first run. A new value means the log belongs to a new run.
+   */
+  private Supplier<String> runIdSupplier;
+
   private boolean reuseExisting = true;
 
   /**
diff --git a/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorResponse.java b/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorResponse.java
index c3f8d47ee8..d2bb9a2ea6 100644
--- a/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorResponse.java
+++ b/core/src/main/java/org/apache/hop/ai/advisor/AiAdvisorResponse.java
@@ -33,6 +33,12 @@ public class AiAdvisorResponse {
   /** True when the raw text contained a {@code hop_proposals} fence, even if it did not parse. */
   private boolean proposalBlockPresent;
 
+  /**
+   * Why a {@code hop_proposals} block could not be read, for example invalid JSON, or null when it
+   * was read or there was none.
+   */
+  private String proposalParseError;
+
   /** Prompt tokens reported by the provider, if any. */
   private Integer inputTokenCount;
 
diff --git a/core/src/main/java/org/apache/hop/ai/advisor/AiProposalParser.java b/core/src/main/java/org/apache/hop/ai/advisor/AiProposalParser.java
index 998ff44f66..4cef810aa5 100644
--- a/core/src/main/java/org/apache/hop/ai/advisor/AiProposalParser.java
+++ b/core/src/main/java/org/apache/hop/ai/advisor/AiProposalParser.java
@@ -18,7 +18,6 @@
 package org.apache.hop.ai.advisor;
 
 import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
@@ -56,37 +55,63 @@ public final class AiProposalParser {
     List<AiProposal> proposals = new ArrayList<>();
     boolean blockPresent = false;
     Matcher matcher = PROPOSAL_BLOCK.matcher(rawResponse);
+    String error = null;
     while (matcher.find()) {
       blockPresent = true;
       advice = advice.replace(matcher.group(0), "").trim();
-      proposals.addAll(parseProposalJson(matcher.group(1)));
+      try {
+        proposals.addAll(parseProposalJson(matcher.group(1)));
+      } catch (IllegalArgumentException e) {
+        error = e.getMessage();
+      }
     }
     response.setProposalBlockPresent(blockPresent);
+    response.setProposalParseError(error);
     response.setMarkdownAdvice(advice.trim());
     response.setProposals(proposals);
     return response;
   }
 
+  /**
+   * @throws IllegalArgumentException with a short reason when the block is not a proposals object
+   */
   private static List<AiProposal> parseProposalJson(String jsonText) {
     List<AiProposal> proposals = new ArrayList<>();
     if (Utils.isEmpty(jsonText)) {
-      return proposals;
+      throw new IllegalArgumentException("The hop_proposals block is empty.");
     }
+    JsonNode root;
     try {
-      ObjectMapper mapper = HopJson.newMapper();
-      JsonNode root = mapper.readTree(jsonText.trim());
-      JsonNode array = root.path("proposals");
-      if (!array.isArray()) {
-        return proposals;
-      }
-      for (JsonNode node : array) {
-        AiProposal proposal = toProposal(node);
-        if (proposal != null) {
-          proposals.add(proposal);
+      root = HopJson.newMapper().readTree(jsonText.trim());
+    } catch (Exception e) {
+      String reason = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
+      int newline = reason.indexOf('\n');
+      throw new IllegalArgumentException(
+          "The hop_proposals block is not valid JSON: "
+              + (newline > 0 ? reason.substring(0, newline) : reason));
+    }
+    JsonNode array = root == null ? null : root.path("proposals");
+    if (array == null || !array.isArray()) {
+      throw new IllegalArgumentException(
+          "The hop_proposals block has no \"proposals\" array at the top level.");
+    }
+    for (JsonNode node : array) {
+      AiProposal proposal = toProposal(node);
+      if (proposal != null) {
+        // Small models copy a list of allowed types into one proposal ("ADD_TRANSFORM|ADD_HOP").
+        // That can never be applied; reading it as unreadable gets it corrected.
+        String type = proposal.getType();
+        if (type != null && (type.contains("|") || type.contains(","))) {
+          throw new IllegalArgumentException(
+              "Proposal '"
+                  + (Utils.isEmpty(proposal.getDescription()) ? type : proposal.getDescription())
+                  + "' has several types ("
+                  + type
+                  + "). Give each proposal exactly one type: adding a transform and its hop takes"
+                  + " two proposals.");
         }
+        proposals.add(proposal);
       }
-    } catch (Exception ignored) {
-      // Malformed blocks are dropped; advice text is still shown.
     }
     return proposals;
   }
@@ -95,10 +120,8 @@ public final class AiProposalParser {
     if (node == null || node.isNull()) {
       return null;
     }
+    // An item without a type is kept: the validator blocks it and the user sees why.
     String typeValue = node.path("type").asText("");
-    if (Utils.isEmpty(typeValue)) {
-      return null;
-    }
     AiProposal proposal = new AiProposal();
     String id = node.path("id").asText("");
     proposal.setId(Utils.isEmpty(id) ? null : id);
diff --git a/core/src/main/java/org/apache/hop/ai/advisor/AiProposalValidation.java b/core/src/main/java/org/apache/hop/ai/advisor/AiProposalValidation.java
index fa2cf5402e..49b128ecbb 100644
--- a/core/src/main/java/org/apache/hop/ai/advisor/AiProposalValidation.java
+++ b/core/src/main/java/org/apache/hop/ai/advisor/AiProposalValidation.java
@@ -32,4 +32,15 @@ public class AiProposalValidation {
   private boolean blocked;
   private String reason;
   private String warning;
+
+  /**
+   * Not selected in the review by default: the user has to choose it. Used for changes that remove
+   * or replace existing work, such as deletes, replacements and overwritten metadata.
+   */
+  private boolean optIn;
+
+  /** The constructor from before {@link #optIn} existed, kept for advisors in other plugins. */
+  public AiProposalValidation(String proposalId, boolean blocked, String reason, String warning) {
+    this(proposalId, blocked, reason, warning, false);
+  }
 }
diff --git a/core/src/main/java/org/apache/hop/ai/provider/AiProviderObjectFactory.java b/core/src/main/java/org/apache/hop/ai/provider/AiProviderObjectFactory.java
index fdcaa0c159..bfb1a1a147 100644
--- a/core/src/main/java/org/apache/hop/ai/provider/AiProviderObjectFactory.java
+++ b/core/src/main/java/org/apache/hop/ai/provider/AiProviderObjectFactory.java
@@ -21,30 +21,66 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopMissingPluginsException;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataObjectFactory;
 
 /** Instantiates {@link IAiProvider} plugins by id when deserializing AI provider metadata. */
 public class AiProviderObjectFactory implements IHopMetadataObjectFactory {
 
+  private static final Class<?> PKG = AiProviderObjectFactory.class;
+
+  /**
+   * The id written by earlier versions that saved a provider without its plugin id. Such a file
+   * loads as a provider without a type, so the user can pick one and save it again.
+   */
+  static final String MISSING_ID = "null";
+
   @Override
   public Object createObject(String id, Object parentObject)
       throws HopException, HopMissingPluginsException {
+    if (Utils.isEmpty(id) || MISSING_ID.equals(id)) {
+      return new MissingTypeAiProvider();
+    }
     PluginRegistry registry = PluginRegistry.getInstance();
     IPlugin plugin = registry.findPluginWithId(AiProviderPluginType.class, id);
     if (plugin == null) {
       HopMissingPluginsException missing =
-          new HopMissingPluginsException("AI provider plugin not found: " + id);
+          new HopMissingPluginsException(
+              BaseMessages.getString(PKG, "AiProviderObjectFactory.PluginNotFound", id));
       missing.addMissingPluginDetails(AiProviderPluginType.class, id);
       throw missing;
     }
-    return registry.loadClass(plugin);
+    Object object = registry.loadClass(plugin);
+    if (object instanceof IAiProvider provider) {
+      provider.setPluginId(plugin.getIds()[0]);
+      provider.setPluginName(plugin.getName());
+    }
+    return object;
   }
 
   @Override
   public String getObjectId(Object object) throws HopException {
     if (!(object instanceof IAiProvider provider)) {
-      throw new HopException("Object is not an IAiProvider but " + object.getClass().getName());
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "AiProviderObjectFactory.NotAProvider", object.getClass().getName()));
     }
-    return provider.getPluginId();
+    String pluginId = provider.getPluginId();
+    if (Utils.isEmpty(pluginId)) {
+      IPlugin plugin = PluginRegistry.getInstance().getPlugin(AiProviderPluginType.class, object);
+      if (plugin != null) {
+        pluginId = plugin.getIds()[0];
+        provider.setPluginId(pluginId);
+        provider.setPluginName(plugin.getName());
+      }
+    }
+    if (Utils.isEmpty(pluginId)) {
+      throw new HopException(BaseMessages.getString(PKG, "AiProviderObjectFactory.NoType"));
+    }
+    return pluginId;
   }
+
+  /** Stands in for a provider that was saved without its plugin id. It has no type. */
+  static final class MissingTypeAiProvider extends BaseAiProvider {}
 }
diff --git a/core/src/main/resources/org/apache/hop/ai/provider/messages/messages_en_US.properties b/core/src/main/resources/org/apache/hop/ai/provider/messages/messages_en_US.properties
new file mode 100644
index 0000000000..4bdc2759c5
--- /dev/null
+++ b/core/src/main/resources/org/apache/hop/ai/provider/messages/messages_en_US.properties
@@ -0,0 +1,20 @@
+#
+# 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.
+#
+
+AiProviderObjectFactory.PluginNotFound=AI provider type ''{0}'' is not installed. Install the plugin that provides it, or open the provider in the Metadata perspective and select another type.
+AiProviderObjectFactory.NotAProvider=Object is not an IAiProvider but {0}
+AiProviderObjectFactory.NoType=The AI provider has no provider type. Select a provider type before saving.
diff --git a/core/src/test/java/org/apache/hop/ai/advisor/AiProposalParserTest.java b/core/src/test/java/org/apache/hop/ai/advisor/AiProposalParserTest.java
index f91e07efee..a7b8fac715 100644
--- a/core/src/test/java/org/apache/hop/ai/advisor/AiProposalParserTest.java
+++ b/core/src/test/java/org/apache/hop/ai/advisor/AiProposalParserTest.java
@@ -19,6 +19,7 @@ package org.apache.hop.ai.advisor;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import org.junit.jupiter.api.Test;
@@ -76,6 +77,46 @@ class AiProposalParserTest {
     assertTrue(response.isProposalBlockPresent());
     assertTrue(response.getProposals().isEmpty());
     assertTrue(response.getMarkdownAdvice().contains("Keep this advice."));
+    assertTrue(response.getProposalParseError().contains("not valid JSON"));
+  }
+
+  @Test
+  void blockWithoutProposalsArrayReportsWhy() {
+    String raw =
+        """
+        ```hop_proposals
+        [{"type":"ADD_TRANSFORM"}]
+        ```
+        """;
+    AiAdvisorResponse response = AiProposalParser.parse(raw);
+    assertTrue(response.getProposals().isEmpty());
+    assertTrue(response.getProposalParseError().contains("\"proposals\" array"));
+  }
+
+  @Test
+  void combinedTypesAreReportedSoTheModelCanCorrectThem() {
+    String raw =
+        """
+        ```hop_proposals
+        {"proposals":[{"description":"Add Dummy","type":"ADD_TRANSFORM|ADD_PIPELINE_HOP"}]}
+        ```
+        """;
+    AiAdvisorResponse response = AiProposalParser.parse(raw);
+    assertTrue(response.getProposalParseError().contains("several types"));
+  }
+
+  @Test
+  void itemWithoutTypeIsKeptSoTheValidatorCanBlockIt() {
+    String raw =
+        """
+        ```hop_proposals
+        {"proposals":[{"description":"Add a filter","parameters":{"name":"Filter"}}]}
+        ```
+        """;
+    AiAdvisorResponse response = AiProposalParser.parse(raw);
+    assertEquals(1, response.getProposals().size());
+    assertEquals("", response.getProposals().get(0).getType());
+    assertNull(response.getProposalParseError());
   }
 
   @Test
diff --git a/core/src/test/java/org/apache/hop/ai/provider/AiProviderPluginTypeTest.java b/core/src/test/java/org/apache/hop/ai/provider/AiProviderPluginTypeTest.java
index ef9ae3c7b9..e857db136b 100644
--- a/core/src/test/java/org/apache/hop/ai/provider/AiProviderPluginTypeTest.java
+++ b/core/src/test/java/org/apache/hop/ai/provider/AiProviderPluginTypeTest.java
@@ -20,6 +20,7 @@ package org.apache.hop.ai.provider;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -79,6 +80,21 @@ class AiProviderPluginTypeTest {
     assertEquals("fake-openai", new AiProviderObjectFactory().getObjectId(provider));
   }
 
+  @Test
+  void objectFactoryLoadsProviderSavedWithoutType() throws Exception {
+    Object object = new AiProviderObjectFactory().createObject("null", null);
+    assertTrue(object instanceof IAiProvider);
+    assertNull(((IAiProvider) object).getPluginId());
+  }
+
+  @Test
+  void objectFactoryRefusesToSaveProviderWithoutType() throws Exception {
+    PluginRegistry.addPluginType(AiProviderPluginType.getInstance());
+    AiProviderObjectFactory factory = new AiProviderObjectFactory();
+    Object object = factory.createObject("null", null);
+    assertThrows(HopException.class, () -> factory.getObjectId(object));
+  }
+
   @Test
   void baseProviderCloneCopiesIdentity() {
     FakeProvider original = new FakeProvider();
diff --git a/docs/hop-dev-manual/modules/ROOT/pages/ai-advisor-plugins.adoc b/docs/hop-dev-manual/modules/ROOT/pages/ai-advisor-plugins.adoc
index 75be26b12b..c12474f2c4 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/ai-advisor-plugins.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/ai-advisor-plugins.adoc
@@ -80,10 +80,10 @@ That is how privacy-sensitive installs remove AI.
 |Constant |When Hop uses it
 
 |`pipeline-graph`
-|Pipeline canvas and pipeline toolbar *AI Help*
+|Pipeline canvas and pipeline toolbar *AI Assistant*
 
 |`workflow-graph`
-|Workflow canvas and workflow toolbar *AI Help*
+|Workflow canvas and workflow toolbar *AI Assistant*
 
 |`perspective`
 |`CTRL-SHIFT-A`, *New session*, Tools menu
@@ -178,7 +178,7 @@ public class VaultAiGuiPlugin {
 |`@AiAdvisorPlugin` id to select in the workbench combo.
 
 |`location`
-|Free-form id. Session reuse keys on this plus advisor id and artifact name.
+|Free-form id. Session reuse keys on this plus advisor id and the artifact itself (see `reuseExisting`).
 
 |`areaLabel`
 |Session-tree group ("Pipelines", "Data Vault", "Lineage"). Falls back to "General" when empty.
@@ -205,9 +205,13 @@ Keep SWT widgets *off* this object; pass a `logSupplier` for logs.
 |Optional `Supplier<String>` read on the UI thread before the background call.
 Use it for execution logs that live in SWT widgets.
 
+|`runIdSupplier`
+|Optional `Supplier<String>` that identifies the latest run, for example the log channel id of the pipeline or workflow engine, or returns null before the first run.
+When the user switched the log inclusion off, that choice holds until this value changes: the log of a new run is switched on again.
+
 |`reuseExisting`
 |Default `true`.
-The store reuses a session with the same advisor id, location and artifact name.
+The store reuses a session with the same advisor id and location for the same artifact object, then for the same file name (`IHasFilename`), and only for a request without an artifact by name.
 
 |`attributes`
 |A `Map<String,Object>` copied onto the session (new session: replace; reuse: merge, incoming keys win).
@@ -309,20 +313,25 @@ public class DataVaultAiAdvisor implements IAiAdvisor {
       throw new HopException("No Data Vault model is bound to this session.");
     }
     String system = loadPreambleAndScenario(request.getScenarioId());
+    // Context goes in tagged blocks, the question last. The preamble tells the model the blocks
+    // are data gathered by Hop, not instructions, and not to mention them in its answer.
     StringBuilder user = new StringBuilder();
-    user.append("User question:\n").append(request.getUserPrompt()).append("\n\n");
-    user.append("Model structure JSON:\n").append(serializeStructure(model)).append("\n\n");
+    section(user, "model_structure", serializeStructure(model));
     if (request.inclusionEnabled("checks")) {
-      user.append("Check results JSON:\n").append(serializeChecks(model, request)).append("\n\n");
+      section(user, "check_results", serializeChecks(model, request));
     }
     if (request.inclusionEnabled("catalog")) {
-      user.append("Catalog sources: ")
-          .append(request.selectedInclusionIds("catalog"))
-          .append("\n\n");
+      section(user, "catalog_sources", String.valueOf(request.selectedInclusionIds("catalog")));
     }
     // Workbench appends selected metadata when inclusion id is "metadata".
+    section(user, "question", request.getUserPrompt());
     return new AiAdvisorPrompt(system, user.toString());
   }
+
+  private static void section(StringBuilder prompt, String tag, String content) {
+    prompt.append('<').append(tag).append(">\n").append(content).append("\n</").append(tag);
+    prompt.append(">\n\n");
+  }
 }
 ----
 
@@ -386,7 +395,8 @@ Default-off inclusions (full XML, logs, metadata) are the ones that can leak sec
 
 |`followUp`
 |`true` after a successful prior turn.
-Large context (catalog, XML, logs) is usually first-turn only; you decide.
+The conversation history only carries earlier questions and answers, not the context sent with them.
+Send what is checked on every turn, as Hop's own advisors do, so a follow-up question still has the context and a new execution log reaches the model.
 
 |`attributes`
 |Copied from the session (which came from `AiAdvisorOpenRequest.attributes`).
@@ -407,7 +417,7 @@ After `buildPrompt` returns, the workbench appends standing notes to the *system
   * The same paths on the plugin classpath (JAR root) if the folder file is missing
   * The folder file *wins* when both exist, so an install can edit notes without rebuilding
 . `IAiAdvisor.getStandingContext()` when non-empty (computed notes; prefer files)
-. Configuration → *Extra context* and *Context files* (default `+${PROJECT_HOME}/AGENTS.md+`)
+. Configuration → *Extra context* and *Context files* (empty by default)
 
 Missing files are skipped. Your `buildPrompt` does not load these.
 
@@ -436,6 +446,34 @@ Custom file types should still implement `afterApply` for undo using `request.ge
 
 Chat-only advisors leave apply / afterApply / summarize as the defaults.
 
+The review applies a selection all or nothing: when `applyProposals` throws, the workbench restores the metadata objects saved for that selection and shows the error.
+Restore your own artifact in `applyProposals` before you throw, as the pipeline and workflow appliers do.
+Proposals with `riskLevel` `HIGH` start unchecked in the review, whatever the advisor's validator says.
+
+The proposal schema check and *Structured answers* (an option on the AI Provider) only apply to the pipeline and workflow assistants, whose proposal types are fixed.
+Answers of other advisors are parsed from text as before.
+
+[[evaluation]]
+=== Check prompt changes with the evaluation set
+
+`AiAdvisorEvaluation` in the tests of `plugins/tech/ai` asks the pipeline and workflow assistants a fixed set of questions with a real model and checks the answers: the language, no proposals when only an explanation was asked for, valid proposals when a change was asked for, a SQL comment that tries to give instructions, the log of a new run.
+The cases are in `src/test/resources/org/apache/hop/ai/eval/cases.json`. A case can expect no proposals or some, the language of the answer, words the answer mentions, proposals the review would not block (`validTypes`), the only proposal types that fit the question (`types`), and a minimum number of proposals of a type (`requiredTypes`).
+It is not part of the normal build. Run it once with a small local model and once with a hosted one:
+
+[source,bash]
+----
+HOP_AI_EVAL_PROVIDER=ollama HOP_AI_EVAL_MODEL=phi3:latest HOP_AI_EVAL_CONTEXT_SIZE=16384 \
+  ./mvnw -pl plugins/tech/ai test -Dtest=AiAdvisorEvaluation -Dsurefire.failIfNoSpecifiedTests=false
+
+HOP_AI_EVAL_PROVIDER=openai HOP_AI_EVAL_MODEL=gpt-4o-mini HOP_AI_EVAL_API_KEY=... \
+  ./mvnw -pl plugins/tech/ai test -Dtest=AiAdvisorEvaluation -Dsurefire.failIfNoSpecifiedTests=false
+----
+
+Add `HOP_AI_EVAL_STRUCTURED=Y` to run with *Structured answers*, and `HOP_AI_EVAL_CASES=explain-en,diagnose-log` to run a few cases.
+The report with every answer is written to `target/ai-eval/<provider>-<model>.md`, with the result per case in a `.json` file next to it.
+When `src/test/resources/org/apache/hop/ai/eval/baseline/` holds a results file of the same name, the report lists the cases that passed there and fail now. Copy a new results file there when a change is accepted.
+Answers vary between runs: read the report, and run a failing case again before you conclude a prompt change broke it.
+
 == Classloading
 
 `IAiAdvisor` lives in *hop-core*.
@@ -454,7 +492,7 @@ Third-party advisor plugins:
 If `plugins/tech/ai` is removed, `openAiAdvisorSession` is still a silent no-op; the third-party advisor plugin still loads.
 
 The workbench combo lists advisors whose `locations()` match the session (plus advisors with empty locations).
-A `data-vault-graph` session does not offer Pipeline AI Help.
+A `data-vault-graph` session does not offer the pipeline assistant.
 The unbound perspective lists every advisor.
 
 Do not put hopper-edw location ids in `AiAdvisorLocations`.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
index a37b6ddc89..6bc7c4d5ed 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/index.adoc
@@ -35,7 +35,7 @@ Covered here are:
 * xref:hop-gui/hop-gui-popup-dialog.adoc[Popup Dialog]: the versatile popup dialog to work with metadata, workflows, pipelines and hops
 * xref:hop-gui/notes.adoc[Notes on pipelines and workflows]: canvas notes, including Markdown formatting, types, links, and images
 * xref:hop-gui/perspectives.adoc[Perspectives]: the various perspectives in the Hop Gui
-* xref:hop-gui/perspective-ai-advisor.adoc[AI Assistant]: multi-session pipeline and workflow advisory (`CTRL-SHIFT-A`; off by default)
+* xref:hop-gui/perspective-ai-advisor.adoc[AI Assistant]: multi-session assistant for pipelines and workflows (`CTRL-SHIFT-A`; off by default)
 * xref:hop-gui/perspective-search.adoc[Search]: Search Everywhere (`CTRL-F`), limits, and results panel
 * xref:hop-gui/hop-gui-git.adoc[Working with git]: Manage files in the git version control system
 * xref:hop-gui/shortcuts.adoc[Keyboard Shortcuts]: a list of the keyboard shortcuts that are available in Hop Gui.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-ai-advisor.adoc b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-ai-advisor.adoc
index c14077f3c0..0e78118d99 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-ai-advisor.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-ai-advisor.adoc
@@ -15,7 +15,7 @@ specific language governing permissions and limitations
 under the License.
 ////
 :imagesdir: ../assets/images
-:description: Multi-session AI advisory for pipelines and workflows, hosted as a perspective, floating window, or bottom-dock tab.
+:description: Multi-session AI Assistant for pipelines and workflows, hosted as a perspective, floating window, or bottom-dock tab.
 
 = AI Assistant
 
@@ -35,19 +35,25 @@ Open it as:
 
 It is *off by default*. Nothing is sent to a language model until you enable it and create an xref:metadata-types/ai-provider.adoc[AI Provider].
 
-See xref:technology/ai/index.adoc[AI technology] for providers, privacy, and how other plugins add advisors.
+See xref:technology/ai/index.adoc[AI technology] for providers, privacy, and how other plugins add assistants.
 
-image::hop-gui/ai-advisor/ai-advisor-perspective.jpg[The AI Assistant perspective: session list, advisor and provider, transcript, and question field,width="90%"]
+image::hop-gui/ai-advisor/ai-advisor-perspective.jpg[The AI Assistant perspective: session list, assistant and provider, transcript, and question field,width="90%"]
 
-== Enable AI advisory
+== Enable the AI Assistant
 
 . Open the Configuration perspective → *Plugins* → *AI Assistant*.
-. Check *Enable AI advisory*.
+. Check *Enable the AI Assistant*.
 . Optionally pick a *Default AI provider*.
 . *Extra context* is sent with every question. The default includes that metadata names are case-sensitive (`local` is not `Local`), that pipeline and workflow run configurations are different metadata types, and that Hop uses pipelines/workflows (not Kettle transformations/jobs). Add project conventions here.
-. *Context files* defaults to `+${PROJECT_HOME}/AGENTS.md+`. Existing files are appended automatically; missing files are skipped. Add more VFS paths, one per line.
-. Leave *Allow sending full XML* unchecked unless you explicitly want the first prompt of a session to include the full pipeline or workflow XML.
+. *Context files* is empty by default. List VFS paths, one per line, of files with standing instructions for the assistant, for example `+${PROJECT_HOME}/ai-assistant.md+`. Existing files are appended to the instructions; missing files are skipped.
+  Only list files you wrote for the AI Assistant. A project's `AGENTS.md` is usually written for coding agents, and a file from a project you did not write can steer the assistant.
+. Leave *Allow sending full XML* unchecked unless you explicitly want questions to include the full pipeline or workflow XML.
   This is a second gate: the session checkbox cannot send XML until this option is on.
+. *When a window closes during a question* decides what happens to a question that is still waiting for its answer when you close the floating window or the dock tab.
+  *Finish in the background* (the default) records the answer in the session, so you find it when you open the session again.
+  *Cancel the question* stops waiting, as *Stop* does.
+  Exiting Hop GUI always stops a question that is still waiting; the session shows that it was not answered.
+  Moving the assistant with *Float* or *Dock* is not a close: the question goes on in the new place.
 
 Create at least one named provider in the Metadata perspective (*AI Provider*) and use *Test* on the editor to confirm the endpoint responds.
 Put the API key in an xref:projects/projects-environments.adoc[environment] variable or a xref:metadata-types/variable-resolver/index.adoc[variable resolver], not as a literal in the metadata JSON. See <<secrets-and-personal-information>>.
@@ -71,34 +77,50 @@ The assistant shows this reminder on an empty session, in Configuration → *Plu
 
 From a pipeline or workflow graph:
 
-* toolbar *AI Help*
-* right-click the canvas → *Basic* → *AI Help*
-* right-click a transform or action → *Basic* → *AI Help* (sets that node as the focus)
+* toolbar *AI Assistant*
+* right-click the canvas → *Basic* → *AI Assistant*
+* right-click a transform or action → *Basic* → *AI Assistant* (sets that node as the focus)
 
 Those actions open a *floating window* so the pipeline or workflow stays on screen.
 If the assistant is already docked or already floating, that layout is reused.
 
-The workbench reuses an existing session for the same advisor and file when one is already open.
-`CTRL-SHIFT-A` opens the perspective without binding a file; use *New session* for a general conversation.
+The workbench reuses the session of that pipeline or workflow when there is one, also after you saved a new file for the first time, or closed and reopened it.
+Two files with the same name in different folders each get their own session.
+
+*New session* (`+`) starts a session for the pipeline or workflow that is open in the Explorer perspective.
+With no file open it starts a session that is not linked to one; the pipeline and workflow advisors need a file, so open one and click *Link to the open file* on the session toolbar.
+`CTRL-SHIFT-A` opens the perspective.
+
+Sessions belong to the project they were started in: switching projects shows that project's sessions.
+With *Keep conversations after Hop GUI closes* (Configuration → *Plugins* → *AI Assistant*, on by default) they are saved per project in the audit folder, `+${HOP_AUDIT_FOLDER}+`, where Hop GUI also remembers the open files of each project.
+That folder is on your machine and outside the project, so conversations do not end up in version control.
+Questions, answers, proposals and the session options are saved as plain text; the context sent with a question is not.
+What you type in a question is saved as you typed it, so do not paste secrets or personal data into questions, or turn the option off.
+When you close a pipeline or workflow, its session lets go of it; open the file again to continue the conversation.
+Closing a session that has a conversation asks for confirmation first, and stops a question that is still running.
+Closing the window or dock tab during a question lets it finish in the background, unless *When a window closes during a question* is set to *Cancel the question*.
 
 == Using the workbench
 
 Each session has:
 
-* *Advisor* — Pipeline AI Help or Workflow AI Help (other plugins add advisors for their own file types; the combo only lists advisors that match this session)
+* *Assistant* — the pipeline or workflow assistant (other plugins add assistants for their own file types; the combo only lists the assistants that match this session)
 * *Scenario* — General, transform/action selection, error diagnosis, or design
-* *Provider* — named xref:metadata-types/ai-provider.adoc[AI Provider] metadata
-* *Sharing* — a one-line summary of what this send includes. Expand it to opt in to extra context (see <<context-inclusions>>). Most extras start off; the *plugin catalog* is on for the first turn so proposed transform/action ids match installed plugins. *Select metadata…* chooses which named metadata objects to send. Other plugins may add a *Select…* button beside an inclusion (for example catalog sources).
-* a transcript and a question field at the bottom (question-mark icon, pastel input, Hop logo to send; `Ctrl-Enter` / `Cmd-Enter` also sends)
+* *Provider* — named xref:metadata-types/ai-provider.adoc[AI Provider] metadata. A new session takes the *Default AI provider* of the configuration, or else the provider you picked last in this project, also after Hop GUI restarts
+* *Sharing* — a one-line summary of what this send includes: what you chose first (the focused node, settings, catalog, log, …), then *the basics* that always go along. Hover it for the full list. Expand it to opt in to extra context (see <<context-inclusions>>). Most extras start off; the *plugin catalog* is on by default so proposed transform/action ids match installed plugins. *Select metadata…* chooses which named metadata objects to send. Other plugins may add a *Select…* button bes [...]
+* a transcript and a question field at the bottom (pastel input, Hop logo to send; `Ctrl-Enter` / `Cmd-Enter` also sends). Drag the bar between them to give either more room.
+* when the session was opened on a transform or action, a line under *Provider* names it: its settings go with each question. *Clear* removes the focus so you ask about the whole pipeline or workflow
 
-The first turn of a session can include the larger context (summary, catalog, optional XML and logs).
-Follow-up turns send the structure JSON plus your question so token use stays down.
+Every question sends the context that is checked at that moment, including follow-up questions.
+After the pipeline or workflow has run, *Include execution log excerpt* switches on by itself, visibly in the *Sharing* line, and the log of the latest run is sent.
+When you switch it off, that holds until the next run: a new run brings a new log, and the option switches on again.
+The previous questions and answers of the session are sent as conversation history.
 
 [[context-inclusions]]
 == Context inclusions: send the minimum
 
 The advisor plugin decides *what can be shared*.
-By default *little extra is shared* — the collapsed *Sharing* line is the short version of the current send. The plugin catalog is on for the first turn so apply-able proposals can use real plugin ids.
+By default *little extra is shared* — the collapsed *Sharing* line is the short version of the current send. The plugin catalog is on by default so apply-able proposals can use real plugin ids.
 
 Click the line (or the arrow) to expand the checkboxes.
 Every checked inclusion is copied into the prompt that goes to the language-model service.
@@ -106,17 +128,25 @@ That has two costs: *tokens* (money and latency) and *exposure* (anything in the
 Turn on only what the current question needs, then collapse the panel.
 
 Hover a checkbox for a short reminder of cost and risk.
+The collapsed *Sharing* line lists what you chose first and sums up the items that always go along as *the basics*; hover it for the full list with a short explanation of each. *Show what is sent…* shows the exact text.
+
+Before a question is sent, the assistant estimates its size: the instructions, the checked context, the conversation history and room for the answer.
+When that clearly exceeds the *Context size* of the xref:metadata-types/ai-provider.adoc[AI Provider], the question is not sent and the transcript explains what to uncheck.
+This matters most for local Ollama models, which would otherwise drop the start of the prompt without a word.
+For a hosted provider without a *Context size*, the question is checked against 200000 tokens for Anthropic and 128000 for the others.
+
+The assistant answers in the language of your question.
 
 === Always sent
 
 Regardless of the checkboxes, every turn includes:
 
 * your question
-* a compact *structure JSON* (transform or action names, plugin ids, hops, optional focus node)
-* *Extra context* and *Context files* from Configuration → *Plugins* → *AI Assistant* (when notes are filled in, or when listed files such as `+${PROJECT_HOME}/AGENTS.md+` exist)
-* plugin notes from `ai-context.md` in the advisor plugin folder (Hop ships case-sensitivity and run-configuration facts here; other plugins can add their own)
+* a compact *structure* of the open file (transform or action names, plugin ids, hops, optional focus node)
+* *Extra context* and *Context files* from Configuration → *Plugins* → *AI Assistant* (when notes are filled in, or when listed files such as `+${PROJECT_HOME}/ai-assistant.md+` exist)
+* plugin notes from `ai-context.md`, and `ai-context/pipeline-advisor.md` or `ai-context/workflow-advisor.md`, in the AI plugin folder (`plugins/tech/ai`). Hop ships facts there that small models often get wrong, such as case-sensitive metadata names and run-configuration types. You can read and edit them; other plugins can add their own
 
-The first turn of a session also includes a short *summary JSON* (file name, counts, parameter *names* — not values), *metadata type keys* (for example `rdbms`), and *database plugin ids* (`POSTGRESQL`, `MYSQL`, …) so `SAVE_METADATA` can set the connection type.
+Every turn also includes a short *summary* (file name, counts, parameter *names* — not values), *metadata type keys* (for example `rdbms`), and *database plugin ids* (`POSTGRESQL`, `MYSQL`, …) so `SAVE_METADATA` can set the connection type.
 
 That is usually enough for "what does this graph do?", hop problems, and naming questions.
 
@@ -126,6 +156,11 @@ That is usually enough for "what does this graph do?", hop problems, and naming
 |===
 |Checkbox |Default |When it is sent |What it adds, and when to use it
 
+|Include transform / action settings
+|On
+|Every turn while checked
+|The settings of each transform or action, as shown in their dialogs: SQL, file names, connection names, field lists and so on. Without them the model can only see names and hops, and cannot explain what a pipeline does. Password fields are never sent, long texts and lists are shortened, and the total is capped. Uncheck it when settings hold values that must not leave your machine.
+
 |Include pipeline / workflow check results
 |Off
 |Every turn while checked
@@ -133,18 +168,18 @@ That is usually enough for "what does this graph do?", hop problems, and naming
 
 |Include transform / action plugin catalog
 |On
-|First turn of the session only
+|Every turn while checked
 |Ids and names of installed plugins (capped). Needed so `hop_proposals` use real plugin ids (for example `TableInput`, `TypeExitExcelWriterTransform`). Uncheck to save tokens.
 
 |Include full pipeline / workflow XML
 |Off
-|First turn only, and only if Configuration → *Plugins* → *AI Assistant* → *Allow sending full XML* is also on
-|The saved topology XML (SQL, filenames, notes, field mappings). Highest token cost and highest leak risk. Use only when the structure JSON is not enough to answer, and only after secrets are variables rather than literals.
+|Every turn while checked, and only if Configuration → *Plugins* → *AI Assistant* → *Allow sending full XML* is also on
+|The saved topology XML (SQL, filenames, notes, field mappings). Highest token cost and highest leak risk. Use only when the structure and settings are not enough to answer, and only after secrets are variables rather than literals.
 
 |Include execution log excerpt
-|Off
-|First turn only
-|A truncated excerpt of the last execution log. Needed for runtime errors. Logs can contain row samples, paths and *resolved* values. Leave *off* unless you are diagnosing a failed run.
+|Off, on after a run
+|Every turn while checked
+|A truncated excerpt of the log of the latest run. Switches on by itself after a run; when you switch it off, that holds until the next run. Needed for runtime errors. Logs can contain row samples, paths and *resolved* values. Leave *off* unless you are diagnosing a failed run.
 
 |Include selected metadata
 |Off
@@ -191,14 +226,13 @@ Check only the elements the current question needs, then OK.
 |On (only if needed)
 
 |Follow-up after the first answer
-|Leave as-is
-|Ignored
-|Ignored
-|Ignored
+|As needed
+|As needed
+|As needed
+|As needed
 |===
 
-Follow-up turns already drop the catalog, XML and log excerpt even if those boxes stay checked.
-Uncheck them anyway so the next *new* session does not send them by accident.
+Follow-up questions send what is checked, like the first one. Uncheck what the next question does not need.
 
 [[secrets-and-personal-information]]
 == Secrets and personal information
@@ -206,15 +240,24 @@ Uncheck them anyway so the next *new* session does not send them by accident.
 The assistant is only as safe as the project you point it at.
 If a password, token or personal record is hard-coded in a pipeline, workflow, metadata object, note, SQL statement or Data Grid sample, it can appear in XML, check results or logs and be sent to the AI service.
 
-Set this up *before* you enable AI advisory:
+Set this up *before* you enable the AI Assistant:
 
 . *Never hard-code secrets.* Not in pipelines, workflows, metadata (including xref:metadata-types/ai-provider.adoc[AI Provider] API keys), notes, SQL, or sample rows.
 . Put values in an xref:projects/projects-environments.adoc[environment] configuration file as variables, for example `+${DB_PASSWORD}+` or `+${AI_API_KEY}+`. Keep those files *outside* the project folder and out of the project git repository. See xref:best-practices/index.adoc[Best practices: security].
 . *Better:* store the secret in a keystore or vault and reference it with a xref:metadata-types/variable-resolver/index.adoc[variable resolver], for example `#{vault:secret/data/database:password}`. The environment file then holds only the resolver expression.
-. Give secret variable names a recognizable pattern (`PASSWORD`, `SECRET`, `TOKEN`, `PASSWD`). Hop Gui masks those values in the `Ctrl-Space` picker. The advisor also redacts XML tags, attributes and JSON keys whose names match `password`, `pass`, `pwd`, `secret`, `apikey` / `api-key`, `token` and `access-token`.
+. Give secret variable names a recognizable pattern (`PASSWORD`, `SECRET`, `TOKEN`, `PASSWD`). Hop Gui masks those values in the `Ctrl-Space` picker.
 . Keep *personal information* out of anything the assistant might send: no customer names, emails, national ids or row samples in notes, Data Grids, log messages, or transform names.
 
-Redaction does *not* catch literals in SQL, notes, oddly named fields, or free-text logs.
+Before anything is sent, the assistant masks:
+
+* every value Hop stores encrypted: password fields of metadata objects (connections, storage accounts, Git connections, …) and of transforms and actions, whatever the field is called
+* values of fields, XML tags and attributes whose names end in a secret-like word, such as `password`, `passphrase`, `secret`, `secretKey`, `accessKey`, `privateKey`, `accountKey`, `sasKey`, `token`, `apiKey`, `clientSecret`, `credential` or `authorization`
+* the password in URLs such as `+jdbc:postgresql://user:password@host/db+`
+
+A value that only references a variable, such as `+${DB_PASSWORD}+`, is not a secret and is sent as is, so the model can see which variable holds it.
+
+Redaction does *not* catch literals in SQL, notes, free-text logs, or a plain (unencrypted) secret in a field with an ordinary name.
+Host names and user names are sent.
 The checkboxes above are the real control.
 
 The AI Provider API key is never copied into advisor prompts.
@@ -224,22 +267,28 @@ It is still project metadata if you paste it: use `+${AI_API_KEY}+` or a resolve
 
 The same workbench can run outside the perspective, like the Database perspective and Search:
 
-* *AI Help* on a pipeline or workflow toolbar (and canvas *Basic → AI Help*) opens that floating window so the graph stays visible.
+* *AI Assistant* on a pipeline or workflow toolbar (and canvas *Basic → AI Assistant*) opens that floating window so the graph stays visible.
 * *Float* on the session toolbar (or **Tools → AI Assistant window**) opens a non-modal window you can put beside the canvas and the execution log.
 * *Dock* opens it as a tab in the bottom dock, in the same panel as the terminal, search, database, and VFS file explorer.
 
+Hop GUI remembers where you put it: after *Dock*, *AI Assistant* on a toolbar opens it in the dock, and the dock tab is opened again when Hop GUI starts.
+*Float* and *Dock* move the assistant: moving it from the dock to a floating window closes the dock tab, and the other way around. The button of the place you are in is disabled.
+
 image::hop-gui/ai-advisor/ai-advisor-floating-window.jpg[AI Assistant floating over a failed workflow and its execution log,width="90%"]
 
 The example above is a typical error-diagnosis session: the workflow graph and Logging tab stay visible while the assistant explains that the failure is a missing execution-information cache folder, not a broken hop.
 
 == Review and apply graph edits
 
-When the model suggests concrete topology changes it appends a `hop_proposals` JSON block.
+When you ask for a change (build, add, connect, fix, create …), the model appends proposals to its answer.
+Questions that only ask to explain or diagnose get a written answer without proposals.
 The transcript then shows **Review N proposed change(s)**.
+When the proposals in an answer cannot be read, or do not follow the proposal schema (a known type, a risk level of LOW, MEDIUM or HIGH, and parameters), the assistant asks the model once to correct them; if that fails too, the transcript says why.
+With *Structured answers* on the xref:metadata-types/ai-provider.adoc[AI Provider], the provider holds the model to that schema, so the proposals cannot come back broken.
 
-. Check or uncheck individual proposals. Blocked items stay unchecked and show the validation reason.
-. Read the preview pane for type, risk, and parameters.
-. Click **Apply selected**. Graph edits update the open pipeline or workflow and register undo. Clipboard proposals copy Hop XML/JSON so you can paste on the canvas (Ctrl-V). `SAVE_METADATA` writes the metadata object. Work on a Git feature branch first so you can revert if needed.
+. The first column says per proposal whether it is applied: *✔ Apply*, *Skip* or *Blocked*. Click it, or double-click the row, to switch; *All* and *None* do every row. Blocked items show the validation reason. Proposals that delete or replace something, change the settings of an existing transform or action, overwrite an existing metadata object, or that the model itself rates HIGH risk, start unchecked: select them yourself.
+. Click a row to read its preview: whether it will be applied, any warning, its type, risk and parameters.
+. Click **Apply selected**. The selection is applied as a whole: when one proposal fails, none of them is applied. The pipeline or workflow is put back as it was and metadata objects saved by the selection are restored. Graph edits update the open pipeline or workflow and register undo. Clipboard proposals copy Hop XML/JSON so you can paste on the canvas (Ctrl-V). `SAVE_METADATA` writes the metadata object, after a confirmation when it replaces an existing one; *Undo metadata changes* in [...]
 . Follow-up turns include summaries of what you applied.
 
 Supported pipeline types: add/delete/rename transform, add/delete hop, set location, add note, copy transform XML to the clipboard, replace an existing transform from XML.
@@ -248,14 +297,15 @@ Both sessions also accept copy-metadata-JSON and save-metadata proposals.
 
 `ADD_TRANSFORM` and `ADD_ACTION` load the plugin with **defaults only**. Field mappings, SQL and connection names are not applied that way.
 
-To change an existing node's configuration, apply `REPLACE_TRANSFORM` / `REPLACE_ACTION` (plugin type must match; name and canvas location stay). To add a fully configured node, apply `CLIPBOARD_TRANSFORMS` / `CLIPBOARD_ACTIONS` and paste on the canvas. `SAVE_METADATA` creates or overwrites a named metadata object; it is not undone with graph Undo.
-
-Malformed `hop_proposals` blocks are ignored; the advice text is still shown.
+To change an existing node's configuration, apply `REPLACE_TRANSFORM` / `REPLACE_ACTION` (plugin type must match; name and canvas location stay). To add a fully configured node, apply `CLIPBOARD_TRANSFORMS` / `CLIPBOARD_ACTIONS` and paste on the canvas. `SAVE_METADATA` creates or overwrites a named metadata object; it is not undone with graph Undo but with *Undo metadata changes* in the transcript.
+Proposals can only save the metadata a user typically asks the assistant to create: relational database connections, Cassandra, MongoDB and Neo4j connections, Neo4j graph models, data sets, unit tests, file definitions, schema definitions and partition schemas.
+Other metadata can decide where prompts, secrets, files, logs or execution go (AI providers, variable resolvers, run configurations, Hop servers, logging, web services, VFS and Git connections, …). Create or change it yourself in the Metadata perspective.
 
 == Privacy and removal
 
 * Credentials live on AI Provider metadata and are never copied into prompts. Still prefer a variable or resolver for the key itself.
-* Passwords, API keys and similar *named* values are redacted from check results, logs and XML before send. See <<secrets-and-personal-information>>.
+* Passwords, API keys and similar values are masked before send. See <<secrets-and-personal-information>>.
+* *Show what is sent…* under *Sharing* shows the exact text of the next question, with the current options, without sending it.
 * Full XML is included only when both the session checkbox *and* the configuration option are on.
 * To hide the UI without deleting the plugin, add ids to `disabledGuiElements.xml` (see xref:hop-gui/disable-ui-elements.adoc[Customize the UI]):
 ** perspective `175-HopAiAdvisorPerspective`
diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
index b8432a3ea5..756357a44d 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
@@ -96,6 +96,9 @@ A GitHub Copilot-style OAuth provider is an extension point; it is not bundled.
 
 == Options
 
+The editor shows *Name* and *Provider* at the top, and the other options on three tabs: *Connection* (base URL, API key, timeout), *Model* (model name, temperature, context size, max output tokens, structured answers) and *Models per role*.
+Saving checks that a provider type is selected and that the numbers are valid; values that use a variable are checked when the provider is used.
+
 |===
 |Option |Description
 
@@ -119,6 +122,27 @@ A GitHub Copilot-style OAuth provider is an extension point; it is not bundled.
 
 |Temperature
 |Sampling temperature, when the provider supports it. When set, it overrides the temperature configured on a Language Model Chat transform that uses this provider.
+
+|Context size (tokens)
+|How many tokens the model can read at once: prompt, conversation history and answer together.
+The AI Assistant estimates the size of every question before sending it and stops with an explanation when it clearly does not fit.
+For Ollama this also sets the context window the model is loaded with (`num_ctx`).
+Leave it empty for 16384 with Ollama. For the other types an empty field is not sent; questions are then checked against 200000 tokens for Anthropic and 128000 for the others. Set the real window of the model if it is smaller.
+Ollama's own default (2048 or 4096, depending on the version) is too small for a typical assistant question and is cut silently, so set a value here rather than relying on the server.
+A larger window uses more memory on the Ollama server.
+
+|Max output tokens
+|The longest answer the model may write, in tokens.
+Leave it empty for the provider's default, which is 4096 for Anthropic (Anthropic requires a value).
+A value that is too low cuts answers and proposals short.
+
+|Structured answers
+|Off by default.
+When checked, the AI Assistant asks the provider to hold the model to a JSON schema: the answer text plus a list of proposals with a known type, a risk level and their parameters.
+The proposals then cannot come back broken, which helps small models most.
+Works with Ollama, OpenAI, Anthropic and Mistral. Other types (Gemini, Grok, custom OpenAI-compatible servers, Hugging Face) answer as usual.
+When the provider refuses the schema, the question is asked again without it and the log says so.
+Some models write shorter answers this way: compare with and without it on the models you use, for example with the evaluation set described in xref:technology/ai/index.adoc[AI technology].
 |===
 
 When Language Model Chat points at an AI Provider, extra transform options (proxy, retries, mock, I/O fields) are not taken from the provider.
@@ -133,7 +157,7 @@ Each row pairs a role with a model name:
 |Role |Used by
 
 |`CHAT`
-|Conversation and completion, for example Language Model Chat, the AI Advisor and xref:pipeline/transforms/structuredextract.adoc[Structured extract].
+|Conversation and completion, for example Language Model Chat, the AI Assistant and xref:pipeline/transforms/structuredextract.adoc[Structured extract].
 
 |`EMBEDDING`
 |Turning text into a vector, for example xref:pipeline/transforms/embedtext.adoc[Embed text].
@@ -154,6 +178,9 @@ Use at most one row per role, since that is what makes the lookup unambiguous. A
 
 === Relationship to Model name
 
-**Model name** above is the chat model, and it stays that way. A provider with no rows in this table behaves exactly as before: `CHAT` falls back to **Model name**, and nothing else resolves.
+**Model name** on the *Model* tab is the chat model. A provider with no rows in this table behaves exactly as before: `CHAT` falls back to **Model name**, and nothing else resolves.
+A `CHAT` row replaces **Model name**: the AI Assistant, Language Model Chat and *Test* then use the model of that row.
+
+The *Model name* column offers the models that *Refresh models* loaded, and accepts variables such as `+'${EMBEDDING_MODEL}'+`.
 
 The fallback is deliberately limited to `CHAT`. Handing a chat model to an embedding endpoint fails inside the provider with a message that is hard to act on, so a provider with no `EMBEDDING` row reports that plainly instead.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/technology/ai/index.adoc b/docs/hop-user-manual/modules/ROOT/pages/technology/ai/index.adoc
index 97002fdd24..aa0c5b00aa 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/technology/ai/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/technology/ai/index.adoc
@@ -16,7 +16,7 @@ under the License.
 ////
 :documentationPath: /technology/ai/
 :language: en_US
-:description: Apache Hop AI advisory: named providers, pluggable advisors, and a multi-session workbench. Off by default.
+:description: Apache Hop AI Assistant: named providers, pluggable advisors, and a multi-session workbench. Off by default.
 
 = AI
 
@@ -30,9 +30,9 @@ AI is *disabled until you turn it on*.
 == What ships
 
 * xref:metadata-types/ai-provider.adoc[AI Provider] metadata — named model + credentials
-* Configuration perspective → *Plugins* → *AI Assistant* — master enable, default provider, extra context notes, context files (default `+${PROJECT_HOME}/AGENTS.md+`), optional full XML
+* Configuration perspective → *Plugins* → *AI Assistant* — master enable, default provider, extra context notes, context files (none by default), optional full XML, what happens to a running question when its window closes
 * xref:hop-gui/perspective-ai-advisor.adoc[AI Assistant] workbench — perspective, floating window, and bottom dock
-* Pipeline AI Help and Workflow AI Help — toolbar and context-menu entry points, including review-and-apply `hop_proposals` graph edits, clipboard XML/JSON, and metadata saves
+* The pipeline and workflow assistants — toolbar and context-menu entry points, including review-and-apply `hop_proposals` graph edits, clipboard XML/JSON, and metadata saves
 * Language Model Chat can optionally use a named AI Provider for connection fields; inline keys remain as fallback
 
 == Privacy
@@ -48,10 +48,10 @@ See xref:hop-gui/perspective-ai-advisor.adoc#version-control[Version control] on
   Put them in xref:projects/projects-environments.adoc[environment] configuration files as variables, or better in a keystore read with a xref:metadata-types/variable-resolver/index.adoc[variable resolver].
   See xref:hop-gui/perspective-ai-advisor.adoc#secrets-and-personal-information[Secrets and personal information] and xref:best-practices/index.adoc[Best practices: security].
 * API keys stay on xref:metadata-types/ai-provider.adoc[AI Provider] metadata and are never copied into advisor prompts. Still prefer `+${AI_API_KEY}+` or a resolver expression instead of a pasted key.
-* Prompts redact XML/JSON fields named password, token, api-key and similar. That does not catch literals in SQL, notes or logs.
-* Session *context inclusion* checkboxes control extra payload (check results, plugin catalog, execution log, full XML, selected metadata). Use only what the question needs: xref:hop-gui/perspective-ai-advisor.adoc#context-inclusions[Context inclusions].
-* Extra context notes and listed context files (for example a project `AGENTS.md`) are sent on every turn when present. Keep secrets out of those files.
-* Advisor plugins may also ship `ai-context.md` next to the plugin JAR (Hop's AI plugin does). Those notes are always sent for that advisor.
+* Prompts mask every value Hop stores encrypted (password fields), fields named like secrets (password, passphrase, secret key, token, API key, …) and passwords in URLs. That does not catch literals in SQL, notes or logs.
+* Session *context inclusion* checkboxes control extra payload (transform settings, check results, plugin catalog, execution log, full XML, selected metadata). Use only what the question needs: xref:hop-gui/perspective-ai-advisor.adoc#context-inclusions[Context inclusions].
+* Extra context notes and listed context files are sent on every turn as instructions. No context files are listed by default; only add files you wrote for the assistant, and keep secrets out of them.
+* Assistant (advisor) plugins may also ship `ai-context.md` next to the plugin JAR (Hop's AI plugin does). Those notes are always sent for that assistant.
 * Full pipeline or workflow XML is not sent unless both the session checkbox *and* Configuration → *Allow sending full XML* are on.
 * Delete `plugins/tech/ai` to remove the GUI, metadata type and advisors.
 * Hide individual widgets with xref:hop-gui/disable-ui-elements.adoc[disabledGuiElements.xml].
diff --git a/docs/hop-user-manual/modules/ROOT/partials/config/HopAiConfigOptionPlugin.adoc b/docs/hop-user-manual/modules/ROOT/partials/config/HopAiConfigOptionPlugin.adoc
index 6a329943a4..e0527f47ed 100644
--- a/docs/hop-user-manual/modules/ROOT/partials/config/HopAiConfigOptionPlugin.adoc
+++ b/docs/hop-user-manual/modules/ROOT/partials/config/HopAiConfigOptionPlugin.adoc
@@ -23,34 +23,40 @@ These options are stored under `pass:c[hopAiConfig]` in `hop-config.json`.
 |===
 |Option|Description|Default
 
-|pass:c[Enable AI advisory]
-|pass:c[When disabled, AI Help is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.]
+|pass:c[Enable the AI Assistant]
+|pass:c[When disabled, the AI Assistant is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.]
 |`pass:c[false]`
 
 |pass:c[Default AI provider]
-|pass:c[Named AI Provider metadata used by GUI advisors. Pipelines can still pick a different provider. Accepts variables.]
+|pass:c[Named AI Provider metadata used by the AI Assistant. Pipelines can still pick a different provider. Accepts variables.]
 |
 
 |pass:c[Allow sending full XML]
 |pass:c[Second gate for the session "Include full XML" checkbox. Off by default. Full XML is large and can contain SQL, filenames and notes. Leave this off unless you need it, and only after secrets live in environment variables or a keystore.]
 |`pass:c[false]`
 
+|pass:c[Keep conversations after Hop GUI closes]
+|pass:c[Saves the AI Assistant sessions of each project as plain text (questions, answers, proposals and options, not the context that was sent) in the audit folder, ${HOP_AUDIT_FOLDER}, next to the other things Hop GUI remembers per project. That folder is on your machine and outside the project, so the conversations do not end up in version control. Uncheck to start every Hop GUI session without conversations.]
+|`pass:c[true]`
+
+|pass:c[When a window closes during a question]
+|pass:c[What happens to a question that is still waiting for its answer when you close the AI Assistant window or dock. Finish in the background records the answer in the session, so you find it when you open the session again. Cancel stops waiting, as Stop does, and no answer is recorded. Exiting Hop GUI always stops a question that is still waiting.]
+|`pass:c[Finish in the background]`
+
 |pass:c[Extra context]
 a|pass:c[Standing notes sent with every AI Assistant question. The default reminds the model that metadata names are case-sensitive (`local` is not `Local`), that pipeline and workflow run configurations are different types, and that Hop is not Kettle. Add project conventions here. Accepts variables.]
 
 .Default
-[listing]
-----
-Hop metadata names are case-sensitive.
-Pipeline run configurations and workflow run configurations are different metadata types.
-Do not guess names of existing metadata, transforms, or actions that are not in the prompt context. New objects the user asked to create should use the names they gave.
-Variables use ${NAME}. Prefer variables or a resolver for secrets.
-Hop uses pipelines (.hpl) and workflows (.hwf) with transforms and actions, not Kettle transformations, jobs, steps, or job entries.
-----
+[%hardbreaks]
+`pass:c[Hop metadata names are case-sensitive.]`
+`pass:c[Pipeline run configurations and workflow run configurations are different metadata types.]`
+`pass:c[Do not guess names of existing metadata, transforms, or actions that are not in the prompt context. New objects the user asked to create should use the names they gave.]`
+`pass:c[Variables use ${NAME}. Prefer variables or a resolver for secrets.]`
+`pass:c[Hop uses pipelines (.hpl) and workflows (.hwf) with transforms and actions, not Kettle transformations, jobs, steps, or job entries.]`
 |_(with the description)_
 
 |pass:c[Context files]
-|pass:c[One VFS path per line. Files that exist are appended to the system prompt; missing files are skipped. Default is ${PROJECT_HOME}/AGENTS.md. Variables are resolved. Accepts variables.]
-|`pass:c[${PROJECT_HOME}/AGENTS.md]`
+|pass:c[One VFS path per line. Files that exist are appended to the system prompt as instructions the assistant follows; missing files are skipped. Variables are resolved. Empty by default. Only list files you wrote for the AI Assistant, for example ${PROJECT_HOME}/ai-assistant.md: an AGENTS.md is usually written for coding agents, and a file from someone else's project can steer the assistant. Accepts variables.]
+|
 
 |===
diff --git a/docs/hop-user-manual/modules/ROOT/partials/config/all-plugins.adoc b/docs/hop-user-manual/modules/ROOT/partials/config/all-plugins.adoc
index ef8952e073..72eb0ccfcd 100644
--- a/docs/hop-user-manual/modules/ROOT/partials/config/all-plugins.adoc
+++ b/docs/hop-user-manual/modules/ROOT/partials/config/all-plugins.adoc
@@ -25,35 +25,41 @@ These options are stored under `pass:c[hopAiConfig]` in `hop-config.json`.
 |===
 |Option|Description|Default
 
-|pass:c[Enable AI advisory]
-|pass:c[When disabled, AI Help is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.]
+|pass:c[Enable the AI Assistant]
+|pass:c[When disabled, the AI Assistant is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.]
 |`pass:c[false]`
 
 |pass:c[Default AI provider]
-|pass:c[Named AI Provider metadata used by GUI advisors. Pipelines can still pick a different provider. Accepts variables.]
+|pass:c[Named AI Provider metadata used by the AI Assistant. Pipelines can still pick a different provider. Accepts variables.]
 |
 
 |pass:c[Allow sending full XML]
 |pass:c[Second gate for the session "Include full XML" checkbox. Off by default. Full XML is large and can contain SQL, filenames and notes. Leave this off unless you need it, and only after secrets live in environment variables or a keystore.]
 |`pass:c[false]`
 
+|pass:c[Keep conversations after Hop GUI closes]
+|pass:c[Saves the AI Assistant sessions of each project as plain text (questions, answers, proposals and options, not the context that was sent) in the audit folder, ${HOP_AUDIT_FOLDER}, next to the other things Hop GUI remembers per project. That folder is on your machine and outside the project, so the conversations do not end up in version control. Uncheck to start every Hop GUI session without conversations.]
+|`pass:c[true]`
+
+|pass:c[When a window closes during a question]
+|pass:c[What happens to a question that is still waiting for its answer when you close the AI Assistant window or dock. Finish in the background records the answer in the session, so you find it when you open the session again. Cancel stops waiting, as Stop does, and no answer is recorded. Exiting Hop GUI always stops a question that is still waiting.]
+|`pass:c[Finish in the background]`
+
 |pass:c[Extra context]
 a|pass:c[Standing notes sent with every AI Assistant question. The default reminds the model that metadata names are case-sensitive (`local` is not `Local`), that pipeline and workflow run configurations are different types, and that Hop is not Kettle. Add project conventions here. Accepts variables.]
 
 .Default
-[listing]
-----
-Hop metadata names are case-sensitive.
-Pipeline run configurations and workflow run configurations are different metadata types.
-Do not guess names of existing metadata, transforms, or actions that are not in the prompt context. New objects the user asked to create should use the names they gave.
-Variables use ${NAME}. Prefer variables or a resolver for secrets.
-Hop uses pipelines (.hpl) and workflows (.hwf) with transforms and actions, not Kettle transformations, jobs, steps, or job entries.
-----
+[%hardbreaks]
+`pass:c[Hop metadata names are case-sensitive.]`
+`pass:c[Pipeline run configurations and workflow run configurations are different metadata types.]`
+`pass:c[Do not guess names of existing metadata, transforms, or actions that are not in the prompt context. New objects the user asked to create should use the names they gave.]`
+`pass:c[Variables use ${NAME}. Prefer variables or a resolver for secrets.]`
+`pass:c[Hop uses pipelines (.hpl) and workflows (.hwf) with transforms and actions, not Kettle transformations, jobs, steps, or job entries.]`
 |_(with the description)_
 
 |pass:c[Context files]
-|pass:c[One VFS path per line. Files that exist are appended to the system prompt; missing files are skipped. Default is ${PROJECT_HOME}/AGENTS.md. Variables are resolved. Accepts variables.]
-|`pass:c[${PROJECT_HOME}/AGENTS.md]`
+|pass:c[One VFS path per line. Files that exist are appended to the system prompt as instructions the assistant follows; missing files are skipped. Variables are resolved. Empty by default. Only list files you wrote for the AI Assistant, for example ${PROJECT_HOME}/ai-assistant.md: an AGENTS.md is usually written for coding agents, and a file from someone else's project can steer the assistant. Accepts variables.]
+|
 
 |===
 
diff --git a/plugins/tech/ai/pom.xml b/plugins/tech/ai/pom.xml
index 154413e00c..6157bb9739 100644
--- a/plugins/tech/ai/pom.xml
+++ b/plugins/tech/ai/pom.xml
@@ -70,5 +70,12 @@
             <version>${project.version}</version>
             <scope>provided</scope>
         </dependency>
+        <!-- The evaluation checks proposals against real plugins; Write to log is a common one. -->
+        <dependency>
+            <groupId>org.apache.hop</groupId>
+            <artifactId>hop-transform-writetolog</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/AiAdvisorInclusions.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/AiAdvisorInclusions.java
index f4c7a97c4f..a62d1a70cc 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/AiAdvisorInclusions.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/AiAdvisorInclusions.java
@@ -20,6 +20,7 @@ package org.apache.hop.ai.advisors;
 public final class AiAdvisorInclusions {
 
   public static final String CHECKS = "checks";
+  public static final String SETTINGS = "settings";
   public static final String CATALOG = "catalog";
   public static final String XML = "xml";
   public static final String LOGS = "logs";
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiAdvisor.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiAdvisor.java
index c835efd9a0..0bbcb9ce4e 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiAdvisor.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiAdvisor.java
@@ -28,6 +28,8 @@ import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.advisor.AiProposalValidation;
 import org.apache.hop.ai.advisor.IAiAdvisor;
 import org.apache.hop.ai.advisors.AiAdvisorInclusions;
+import org.apache.hop.ai.engine.AiAdvisorEngine;
+import org.apache.hop.ai.engine.AiProposalNormalizer;
 import org.apache.hop.ai.engine.AiProposalPreview;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.i18n.BaseMessages;
@@ -86,6 +88,12 @@ public class PipelineAiAdvisor implements IAiAdvisor {
   @Override
   public List<AiAdvisorInclusion> listInclusions() {
     return List.of(
+        new AiAdvisorInclusion(
+            AiAdvisorInclusions.SETTINGS,
+            BaseMessages.getString(PKG, "PipelineAiAdvisor.Inclusion.Settings"),
+            true,
+            BaseMessages.getString(PKG, "PipelineAiAdvisor.Inclusion.Settings.Tooltip"),
+            BaseMessages.getString(PKG, "PipelineAiAdvisor.Inclusion.Settings.Summary")),
         new AiAdvisorInclusion(
             AiAdvisorInclusions.CHECKS,
             BaseMessages.getString(PKG, "PipelineAiAdvisor.Inclusion.Checks"),
@@ -136,6 +144,8 @@ public class PipelineAiAdvisor implements IAiAdvisor {
       AiAdvisorRequest request, List<AiProposal> proposals) {
     PipelineMeta pipelineMeta =
         request != null && request.getArtifact() instanceof PipelineMeta meta ? meta : null;
+    // Repair the model's slips first (a workflow hop type, a missing plugin id or location).
+    AiProposalNormalizer.forPipeline(pipelineMeta, proposals);
     return PipelineAiProposalValidator.validate(
         pipelineMeta, proposals, request != null ? request.getMetadataProvider() : null);
   }
@@ -144,7 +154,8 @@ public class PipelineAiAdvisor implements IAiAdvisor {
   public void applyProposals(AiAdvisorRequest request, List<AiProposal> selected)
       throws HopException {
     if (!(request.getArtifact() instanceof PipelineMeta pipelineMeta)) {
-      throw new HopException("No pipeline is bound to this session.");
+      throw new HopException(
+          BaseMessages.getString(AiAdvisorEngine.class, "AiContextBuilder.NotLinked.Pipeline"));
     }
     HopGui hopGui = hopGuiFrom(request);
     PipelineAiProposalApplier.apply(
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiContextBuilder.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiContextBuilder.java
index a3f6129f66..b4e6c7799d 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiContextBuilder.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiContextBuilder.java
@@ -18,24 +18,25 @@
 package org.apache.hop.ai.advisors.pipeline;
 
 import java.util.ArrayList;
-import java.util.Comparator;
 import java.util.List;
 import org.apache.hop.ai.advisor.AiAdvisorPrompt;
 import org.apache.hop.ai.advisor.AiAdvisorRequest;
 import org.apache.hop.ai.advisors.AiAdvisorInclusions;
 import org.apache.hop.ai.config.HopAiConfigSingleton;
+import org.apache.hop.ai.engine.AiAdvisorEngine;
 import org.apache.hop.ai.engine.AiAdvisorMetadataContext;
 import org.apache.hop.ai.engine.AiCheckResultsSerializer;
 import org.apache.hop.ai.engine.AiM2PromptSupport;
+import org.apache.hop.ai.engine.AiNodeSettings;
+import org.apache.hop.ai.engine.AiPluginCatalog;
 import org.apache.hop.ai.engine.AiPromptLoader;
 import org.apache.hop.ai.engine.AiTextUtil;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.exception.HopException;
-import org.apache.hop.core.plugins.IPlugin;
-import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.plugins.TransformPluginType;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
@@ -46,17 +47,19 @@ public final class PipelineAiContextBuilder {
   static final String PROMPT_ROOT = "/org/apache/hop/ai/prompts/pipeline/";
   private static final int MAX_TOPOLOGY_XML_CHARS = 120_000;
   private static final int MAX_FOCUS_XML_CHARS = 40_000;
+  private static final int MAX_SETTINGS_CHARS = 40_000;
   private static final int MAX_LOG_CHARS = 20_000;
-  private static final int MAX_CATALOG_ENTRIES = 180;
 
   private PipelineAiContextBuilder() {}
 
   public static AiAdvisorPrompt buildPrompt(AiAdvisorRequest request) throws HopException {
     if (!(request.getArtifact() instanceof PipelineMeta pipelineMeta)) {
-      throw new HopException("No pipeline is bound to this session.");
+      throw new HopException(
+          BaseMessages.getString(AiAdvisorEngine.class, "AiContextBuilder.NotLinked.Pipeline"));
     }
     if (Utils.isEmpty(request.getUserPrompt())) {
-      throw new HopException("Please enter a question for the AI advisor");
+      throw new HopException(
+          BaseMessages.getString(AiAdvisorEngine.class, "AiContextBuilder.NoQuestion"));
     }
     String scenarioId =
         Utils.isEmpty(request.getScenarioId()) ? "pipeline-general" : request.getScenarioId();
@@ -73,55 +76,51 @@ public final class PipelineAiContextBuilder {
       throws HopException {
     IVariables variables = request.getVariables();
     IHopMetadataProvider metadataProvider = request.getMetadataProvider();
-    boolean includeFull = !request.isFollowUp();
     StringBuilder prompt = new StringBuilder();
-    prompt.append("User question:\n").append(request.getUserPrompt()).append("\n\n");
-    prompt
-        .append("Pipeline structure JSON:\n")
-        .append(serializeStructure(pipelineMeta, request.getFocusNodeName()))
-        .append("\n\n");
-    if (includeFull) {
-      prompt
-          .append("Pipeline summary JSON:\n")
-          .append(serializeSummary(pipelineMeta))
-          .append("\n\n");
-      AiAdvisorMetadataContext.appendTypeKeys(prompt, metadataProvider);
-      AiAdvisorMetadataContext.appendDatabaseCatalog(prompt);
-      if (request.inclusionEnabled(AiAdvisorInclusions.CATALOG)) {
-        prompt
-            .append("Available transform plugins JSON:\n")
-            .append(serializeTransformCatalog())
-            .append("\n\n");
-      }
-      if (request.inclusionEnabled(AiAdvisorInclusions.XML)
-          && HopAiConfigSingleton.getConfig().isAllowSendFullXml()) {
-        prompt
-            .append("Pipeline topology XML:\n")
-            .append(
-                AiTextUtil.redactSecrets(
-                    AiTextUtil.truncate(pipelineMeta.getXml(variables), MAX_TOPOLOGY_XML_CHARS)))
-            .append("\n\n");
-      }
-      if (request.inclusionEnabled(AiAdvisorInclusions.LOGS)) {
-        prompt
-            .append("Execution log excerpt:\n")
-            .append(
-                AiTextUtil.redactSecrets(
-                    AiTextUtil.truncate(request.getLogExcerpt(), MAX_LOG_CHARS)))
-            .append("\n\n");
-      }
+    // Every turn sends what is checked. The history only replays questions and answers, so a
+    // follow-up that skipped this context would lose it, including the log of a new run.
+    AiTextUtil.appendSection(prompt, "pipeline_summary", serializeSummary(pipelineMeta));
+    AiTextUtil.appendSection(
+        prompt,
+        "pipeline_structure",
+        serializeStructure(
+            pipelineMeta,
+            request.getFocusNodeName(),
+            request.inclusionEnabled(AiAdvisorInclusions.SETTINGS)));
+    AiAdvisorMetadataContext.appendTypeKeys(prompt, metadataProvider);
+    AiAdvisorMetadataContext.appendDatabaseCatalog(prompt);
+    if (request.inclusionEnabled(AiAdvisorInclusions.CATALOG)) {
+      AiTextUtil.appendSection(prompt, "plugin_catalog", serializeTransformCatalog());
+    }
+    if (request.inclusionEnabled(AiAdvisorInclusions.XML)
+        && HopAiConfigSingleton.getConfig().isAllowSendFullXml()) {
+      AiTextUtil.appendSection(
+          prompt,
+          "pipeline_xml",
+          AiTextUtil.redactSecrets(
+              AiTextUtil.truncate(pipelineMeta.getXml(variables), MAX_TOPOLOGY_XML_CHARS)));
+    }
+    if (request.inclusionEnabled(AiAdvisorInclusions.LOGS)) {
+      AiTextUtil.appendSection(
+          prompt,
+          "execution_log",
+          AiTextUtil.redactSecrets(AiTextUtil.truncate(request.getLogExcerpt(), MAX_LOG_CHARS)));
     }
     AiAdvisorMetadataContext.appendToPrompt(prompt, request);
     appendFocusTransform(prompt, pipelineMeta, request.getFocusNodeName());
     if (request.inclusionEnabled(AiAdvisorInclusions.CHECKS) && metadataProvider != null) {
       List<ICheckResult> results = new ArrayList<>();
       pipelineMeta.checkTransforms(results, false, null, variables, metadataProvider);
-      prompt
-          .append("Pipeline check results JSON:\n")
-          .append(AiCheckResultsSerializer.serialize(results))
-          .append("\n\n");
+      AiTextUtil.appendSection(
+          prompt, "check_results", AiCheckResultsSerializer.serialize(results));
     }
     AiM2PromptSupport.appendAppliedSummaries(prompt, request.getAppliedChangeSummaries());
+    AiTextUtil.appendSection(prompt, "question", request.getUserPrompt());
+    // Repeated from the instructions: small models follow the last thing they read best, and
+    // otherwise drift to English after a long English context.
+    prompt.append(
+        "Write your answer in the language that the text in the <question> block is written"
+            + " in.\n");
     return prompt.toString();
   }
 
@@ -130,7 +129,7 @@ public final class PipelineAiContextBuilder {
     if (Utils.isEmpty(focusName)) {
       return;
     }
-    prompt.append("Focus transform:\n").append(focusName).append("\n\n");
+    AiTextUtil.appendSection(prompt, "focus_transform", focusName);
     TransformMeta transform = pipelineMeta.findTransform(focusName);
     if (transform == null) {
       return;
@@ -140,19 +139,28 @@ public final class PipelineAiContextBuilder {
       if (Utils.isEmpty(xml)) {
         return;
       }
-      prompt
-          .append("Focus transform XML:\n")
-          .append(AiTextUtil.redactSecrets(AiTextUtil.truncate(xml, MAX_FOCUS_XML_CHARS)))
-          .append("\n\n");
+      AiTextUtil.appendSection(
+          prompt,
+          "focus_transform_xml",
+          AiTextUtil.redactSecrets(AiTextUtil.truncate(xml, MAX_FOCUS_XML_CHARS)));
     } catch (Exception e) {
       // Skip unreadable transform XML rather than failing the whole prompt.
     }
   }
 
   public static String serializeStructure(PipelineMeta pipelineMeta, String focusTransformName) {
+    return serializeStructure(pipelineMeta, focusTransformName, false);
+  }
+
+  /**
+   * @param includeSettings add each transform's settings, until {@link #MAX_SETTINGS_CHARS} is used
+   */
+  public static String serializeStructure(
+      PipelineMeta pipelineMeta, String focusTransformName, boolean includeSettings) {
     StringBuilder json = new StringBuilder();
     json.append("{\"transforms\":[");
     List<TransformMeta> transforms = pipelineMeta.getTransforms();
+    int settingsChars = 0;
     for (int i = 0; i < transforms.size(); i++) {
       if (i > 0) {
         json.append(',');
@@ -162,6 +170,13 @@ public final class PipelineAiContextBuilder {
       json.append(",\"pluginId\":").append(AiTextUtil.jsonString(transform.getTransformPluginId()));
       json.append(",\"copies\":").append(AiTextUtil.jsonString(transform.getCopiesString()));
       json.append(",\"distributes\":").append(transform.isDistributes());
+      if (includeSettings && settingsChars < MAX_SETTINGS_CHARS) {
+        String settings = AiNodeSettings.toJson(transform.getTransform());
+        if (settings != null) {
+          json.append(",\"settings\":").append(settings);
+          settingsChars += settings.length();
+        }
+      }
       json.append('}');
     }
     json.append("],\"hops\":[");
@@ -205,32 +220,6 @@ public final class PipelineAiContextBuilder {
   }
 
   static String serializeTransformCatalog() {
-    StringBuilder json = new StringBuilder();
-    json.append("{\"transforms\":[");
-    PluginRegistry registry = PluginRegistry.getInstance();
-    List<IPlugin> plugins = new ArrayList<>(registry.getPlugins(TransformPluginType.class));
-    plugins.sort(
-        Comparator.comparing(
-                IPlugin::getCategory, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
-            .thenComparing(IPlugin::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
-    int count = 0;
-    for (IPlugin plugin : plugins) {
-      if (count >= MAX_CATALOG_ENTRIES) {
-        break;
-      }
-      if (plugin.getIds() == null || plugin.getIds().length == 0) {
-        continue;
-      }
-      if (count > 0) {
-        json.append(',');
-      }
-      json.append("{\"id\":").append(AiTextUtil.jsonString(plugin.getIds()[0]));
-      json.append(",\"name\":").append(AiTextUtil.jsonString(plugin.getName()));
-      json.append(",\"category\":").append(AiTextUtil.jsonString(plugin.getCategory()));
-      json.append('}');
-      count++;
-    }
-    json.append("]}");
-    return json.toString();
+    return AiPluginCatalog.compact(TransformPluginType.class);
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalApplier.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalApplier.java
index da3d312f33..f63674899a 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalApplier.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalApplier.java
@@ -22,6 +22,7 @@ import java.util.List;
 import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.engine.AiProposalParamSupport;
 import org.apache.hop.ai.engine.AiProposalTypes;
+import org.apache.hop.ai.engine.AiProposalUndo;
 import org.apache.hop.ai.engine.AiProposalXmlSupport;
 import org.apache.hop.ai.engine.AiTransformConfigSupport;
 import org.apache.hop.ai.engine.AiTransformPluginSupport;
@@ -29,16 +30,22 @@ import org.apache.hop.core.NotePadMeta;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.Point;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.w3c.dom.Document;
 
 /** Previews and applies validated AI proposals to an open pipeline. */
 public final class PipelineAiProposalApplier {
 
+  private static final Class<?> PKG = PipelineAiProposalApplier.class;
+
   private PipelineAiProposalApplier() {}
 
   public static void apply(PipelineMeta pipelineMeta, List<AiProposal> proposals)
@@ -62,16 +69,108 @@ public final class PipelineAiProposalApplier {
       IHopMetadataProvider metadataProvider)
       throws HopException {
     if (pipelineMeta == null) {
-      throw new HopException("No pipeline is open");
+      throw new HopException(BaseMessages.getString(PKG, "PipelineAiProposalApplier.NoPipeline"));
     }
     if (proposals == null || proposals.isEmpty()) {
       return;
     }
+    proposals = AiProposalTypes.inApplyOrder(proposals);
     IHopMetadataProvider provider =
         metadataProvider != null ? metadataProvider : pipelineMeta.getMetadataProvider();
-    for (int i = 0; i < proposals.size(); i++) {
-      boolean chainUndo = hopGui != null && i < proposals.size() - 1;
-      applyOne(pipelineMeta, proposals.get(i), hopGui, chainUndo, provider);
+    // All or nothing. The pipeline as it is now is kept to put back when a proposal fails half
+    // way; without it nothing is applied.
+    String before;
+    try {
+      before = pipelineMeta.getXml(Variables.getADefaultVariableSpace());
+    } catch (Exception e) {
+      throw new HopException(
+          BaseMessages.getString(PKG, "PipelineAiProposalApplier.CannotCopy"), e);
+    }
+    // A dry run on a copy finds most failures before the open pipeline is touched. The copy can
+    // fail where the pipeline itself is fine (a plugin that cannot load its XML); the rollback
+    // below still covers that case.
+    PipelineMeta copy = copyForDryRun(before, provider);
+    if (copy != null) {
+      for (AiProposal proposal : proposals) {
+        applyOne(copy, proposal, null, false, provider);
+      }
+    }
+    applyOrRestore(pipelineMeta, before, proposals, hopGui, provider);
+  }
+
+  /**
+   * Apply the proposals to the open pipeline. When one fails, the pipeline is put back as {@code
+   * before} and none of them stays applied.
+   */
+  static void applyOrRestore(
+      PipelineMeta pipelineMeta,
+      String before,
+      List<AiProposal> proposals,
+      HopGui hopGui,
+      IHopMetadataProvider provider)
+      throws HopException {
+    boolean changedBefore = pipelineMeta.hasChanged();
+    int applied = 0;
+    try {
+      for (int i = 0; i < proposals.size(); i++) {
+        boolean chainUndo = hopGui != null && i < proposals.size() - 1;
+        applyOne(pipelineMeta, proposals.get(i), hopGui, chainUndo, provider);
+        applied++;
+      }
+    } catch (Exception e) {
+      // The failing proposal may have changed the pipeline before it failed, so put it back even
+      // when it was the first.
+      HopException failure =
+          new HopException(
+              BaseMessages.getString(PKG, "PipelineAiProposalApplier.RolledBack", applied + 1), e);
+      try {
+        restore(pipelineMeta, before, provider, hopGui, changedBefore);
+      } catch (Exception restoreError) {
+        failure.addSuppressed(restoreError);
+      }
+      throw failure;
+    }
+  }
+
+  /** Put the pipeline back as it was before a batch failed half way. */
+  static void restore(
+      PipelineMeta pipelineMeta,
+      String xml,
+      IHopMetadataProvider provider,
+      HopGui hopGui,
+      boolean changedBefore)
+      throws HopException {
+    Document document = XmlHandler.loadXmlString(xml);
+    pipelineMeta.restoreContentFromXml(
+        XmlHandler.getSubNode(document, PipelineMeta.XML_TAG),
+        pipelineMeta.getFilename(),
+        provider);
+    // Restoring the content clears the changed flag: put back the one it had, so unsaved edits
+    // from before the batch still ask to be saved.
+    if (changedBefore) {
+      pipelineMeta.setChanged();
+    } else {
+      pipelineMeta.clearChanged();
+    }
+    AiProposalUndo.forgetPartialChange(hopGui, pipelineMeta);
+  }
+
+  /** A copy through XML, or null when the pipeline cannot be copied (the dry run is skipped). */
+  public static PipelineMeta copyForDryRun(
+      PipelineMeta pipelineMeta, IHopMetadataProvider provider) {
+    try {
+      return copyForDryRun(pipelineMeta.getXml(Variables.getADefaultVariableSpace()), provider);
+    } catch (Exception e) {
+      return null;
+    }
+  }
+
+  private static PipelineMeta copyForDryRun(String xml, IHopMetadataProvider provider) {
+    try {
+      Document document = XmlHandler.loadXmlString(xml);
+      return new PipelineMeta(XmlHandler.getSubNode(document, PipelineMeta.XML_TAG), provider);
+    } catch (Exception e) {
+      return null;
     }
   }
 
@@ -98,7 +197,9 @@ public final class PipelineAiProposalApplier {
       case CONFIGURE_TRANSFORM -> configureTransform(pipelineMeta, proposal, hopGui, chainUndo);
       case REPLACE_TRANSFORM ->
           replaceTransform(pipelineMeta, proposal, hopGui, chainUndo, metadataProvider);
-      default -> throw new HopException("Unsupported proposal type: " + type);
+      default ->
+          throw new HopException(
+              BaseMessages.getString(PKG, "PipelineAiProposalApplier.UnsupportedType", type));
     }
   }
 
@@ -139,7 +240,9 @@ public final class PipelineAiProposalApplier {
     String transformName = proposal.parameter("transformName");
     TransformMeta transform = pipelineMeta.findTransform(transformName);
     if (transform == null) {
-      throw new HopException("Transform not found: " + transformName);
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalApplier.TransformNotFound", transformName));
     }
     List<PipelineHopMeta> hopsToRemove = new ArrayList<>();
     for (PipelineHopMeta hop : pipelineMeta.getPipelineHops()) {
@@ -206,7 +309,9 @@ public final class PipelineAiProposalApplier {
     TransformMeta to = requireTransform(pipelineMeta, proposal.parameter("toTransform"));
     PipelineHopMeta hop = pipelineMeta.findPipelineHop(from, to);
     if (hop == null) {
-      throw new HopException("Hop not found: " + from.getName() + " -> " + to.getName());
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalApplier.HopNotFound", from.getName(), to.getName()));
     }
     int hopIndex = pipelineMeta.indexOfPipelineHop(hop);
     if (hopGui != null) {
@@ -240,7 +345,8 @@ public final class PipelineAiProposalApplier {
       throws HopException {
     TransformMeta existing = requireTransform(pipelineMeta, proposal.parameter("transformName"));
     if (existing.getTransform() == null) {
-      throw new HopException("Transform has no metadata: " + existing.getName());
+      throw new HopException(
+          BaseMessages.getString(PKG, "PipelineAiProposalApplier.NoMetadata", existing.getName()));
     }
     TransformMeta before = (TransformMeta) existing.clone();
     AiTransformConfigSupport.apply(existing.getTransform(), proposal);
@@ -314,7 +420,8 @@ public final class PipelineAiProposalApplier {
       throws HopException {
     TransformMeta transform = pipelineMeta.findTransform(name);
     if (transform == null) {
-      throw new HopException("Transform not found: " + name);
+      throw new HopException(
+          BaseMessages.getString(PKG, "PipelineAiProposalApplier.TransformNotFound", name));
     }
     return transform;
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java
index b96aa6281a..d0744dbb3c 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java
@@ -17,9 +17,16 @@
 
 package org.apache.hop.ai.advisors.pipeline;
 
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.advisor.AiProposalValidation;
@@ -31,13 +38,17 @@ import org.apache.hop.ai.engine.AiTransformConfigSupport;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.plugins.TransformPluginType;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 
 /** Validates AI pipeline proposals against the open graph before the user applies them. */
 public final class PipelineAiProposalValidator {
 
+  private static final Class<?> PKG = PipelineAiProposalValidator.class;
+
   private PipelineAiProposalValidator() {}
 
   public static List<AiProposalValidation> validate(
@@ -54,8 +65,17 @@ public final class PipelineAiProposalValidator {
       return results;
     }
     Set<String> reservedNames = new HashSet<>();
+    // Hops the batch deletes are deleted first when it is applied, so a hop in the other direction
+    // can take their place: reversing a hop is not a loop.
+    for (AiProposal proposal : proposals) {
+      if (AiProposalTypes.of(proposal) == AiProposalTypes.DELETE_PIPELINE_HOP) {
+        reservedNames.add(
+            deletedHop(proposal.parameter("fromTransform"), proposal.parameter("toTransform")));
+      }
+    }
+    HopGraph hops = HopGraph.of(pipelineMeta, reservedNames);
     for (AiProposal proposal : proposals) {
-      results.add(validateOne(pipelineMeta, proposal, reservedNames, metadataProvider));
+      results.add(validateOne(pipelineMeta, proposal, reservedNames, hops, metadataProvider));
     }
     return results;
   }
@@ -64,31 +84,42 @@ public final class PipelineAiProposalValidator {
       PipelineMeta pipelineMeta,
       AiProposal proposal,
       Set<String> reservedNames,
+      HopGraph hops,
       IHopMetadataProvider metadataProvider) {
     AiProposalTypes type = AiProposalTypes.of(proposal);
     if (type == null) {
-      return blocked(proposal, "Missing or unknown proposal type");
+      return blocked(
+          proposal,
+          Utils.isEmpty(proposal.getType())
+              ? BaseMessages.getString(PKG, "PipelineAiProposalValidator.NoType")
+              : BaseMessages.getString(
+                  PKG, "PipelineAiProposalValidator.UnknownType", proposal.getType()));
     }
     if (!type.isPipelineType()) {
-      return blocked(proposal, "Not a pipeline proposal type: " + type);
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NotOwnType", type));
     }
     if (pipelineMeta == null) {
-      return blocked(proposal, "No pipeline is open");
+      return blocked(proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NoGraph"));
     }
     return switch (type) {
       case ADD_TRANSFORM -> validateAddTransform(pipelineMeta, proposal, reservedNames);
-      case DELETE_TRANSFORM -> validateDeleteTransform(pipelineMeta, proposal);
-      case RENAME_TRANSFORM -> validateRenameTransform(pipelineMeta, proposal, reservedNames);
-      case ADD_PIPELINE_HOP -> validateAddPipelineHop(pipelineMeta, proposal, reservedNames);
+      case DELETE_TRANSFORM ->
+          hops.track(proposal, validateDeleteTransform(pipelineMeta, proposal));
+      case RENAME_TRANSFORM ->
+          hops.track(proposal, validateRenameTransform(pipelineMeta, proposal, reservedNames));
+      case ADD_PIPELINE_HOP -> validateAddPipelineHop(pipelineMeta, proposal, reservedNames, hops);
       case DELETE_PIPELINE_HOP -> validateDeletePipelineHop(pipelineMeta, proposal);
       case SET_TRANSFORM_LOCATION -> validateSetTransformLocation(pipelineMeta, proposal);
       case ADD_PIPELINE_NOTE -> validateAddPipelineNote(proposal);
-      case CONFIGURE_TRANSFORM -> validateConfigureTransform(pipelineMeta, proposal);
+      case CONFIGURE_TRANSFORM -> validateConfigureTransform(pipelineMeta, proposal, reservedNames);
       case CLIPBOARD_TRANSFORMS -> validateClipboardTransforms(proposal);
       case REPLACE_TRANSFORM -> validateReplaceTransform(pipelineMeta, proposal);
       case CLIPBOARD_METADATA, SAVE_METADATA ->
           AiMetadataProposalSupport.validate(proposal, metadataProvider);
-      default -> blocked(proposal, "Unsupported proposal type");
+      default ->
+          blocked(
+              proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.UnsupportedType"));
     };
   }
 
@@ -97,20 +128,26 @@ public final class PipelineAiProposalValidator {
     String pluginId = proposal.parameter("transformPluginId");
     String name = proposal.parameter("name");
     if (Utils.isEmpty(pluginId)) {
-      return blocked(proposal, "transformPluginId is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.PluginIdRequired"));
     }
     if (Utils.isEmpty(name)) {
-      return blocked(proposal, "name is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NameRequired"));
     }
     if (PluginRegistry.getInstance().findPluginWithId(TransformPluginType.class, pluginId)
         == null) {
-      return blocked(proposal, "Unknown transform plugin: " + pluginId);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.UnknownPlugin", pluginId));
     }
     if (pipelineMeta.findTransform(name) != null || reservedNames.contains(name.trim())) {
-      return blocked(proposal, "Transform name already exists: " + name);
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NameExists", name));
     }
     if (!AiProposalParamSupport.parseLocation(proposal).isValid()) {
-      return blocked(proposal, "locationX and locationY must be integers");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.LocationNotIntegers"));
     }
     reservedNames.add(name.trim());
     String xml = AiProposalXmlSupport.xmlParam(proposal);
@@ -124,16 +161,24 @@ public final class PipelineAiProposalValidator {
   }
 
   private static AiProposalValidation validateConfigureTransform(
-      PipelineMeta pipelineMeta, AiProposal proposal) {
+      PipelineMeta pipelineMeta, AiProposal proposal, Set<String> reservedNames) {
     String transformName = proposal.parameter("transformName");
     if (Utils.isEmpty(transformName)) {
-      return blocked(proposal, "transformName is required");
-    }
-    if (pipelineMeta.findTransform(transformName) == null) {
-      return blocked(proposal, "Transform not found: " + transformName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.TransformNameRequired"));
+    }
+    // A transform added earlier in the same list exists by the time this one is applied.
+    if (pipelineMeta.findTransform(transformName) == null
+        && !reservedNames.contains(transformName.trim())) {
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.TransformNotFound", transformName));
     }
     if (!AiTransformConfigSupport.hasConfig(proposal)) {
-      return blocked(proposal, "No configuration parameters");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NoConfiguration"));
     }
     return ok(proposal);
   }
@@ -142,10 +187,15 @@ public final class PipelineAiProposalValidator {
       PipelineMeta pipelineMeta, AiProposal proposal) {
     String transformName = proposal.parameter("transformName");
     if (Utils.isEmpty(transformName)) {
-      return blocked(proposal, "transformName is required");
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.TransformNameRequired"));
     }
     if (pipelineMeta.findTransform(transformName) == null) {
-      return blocked(proposal, "Transform not found: " + transformName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.TransformNotFound", transformName));
     }
     return ok(proposal);
   }
@@ -155,65 +205,102 @@ public final class PipelineAiProposalValidator {
     String transformName = proposal.parameter("transformName");
     String newName = proposal.parameter("newName");
     if (Utils.isEmpty(transformName)) {
-      return blocked(proposal, "transformName is required");
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.TransformNameRequired"));
     }
     if (Utils.isEmpty(newName)) {
-      return blocked(proposal, "newName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NewNameRequired"));
     }
     if (pipelineMeta.findTransform(transformName) == null) {
-      return blocked(proposal, "Transform not found: " + transformName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.TransformNotFound", transformName));
     }
     if (!transformName.trim().equals(newName.trim())
         && (pipelineMeta.findTransform(newName) != null
             || reservedNames.contains(newName.trim()))) {
-      return blocked(proposal, "Transform name already exists: " + newName);
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.NameExists", newName));
     }
     reservedNames.add(newName.trim());
     return ok(proposal);
   }
 
   private static AiProposalValidation validateAddPipelineHop(
-      PipelineMeta pipelineMeta, AiProposal proposal, Set<String> reservedNames) {
+      PipelineMeta pipelineMeta, AiProposal proposal, Set<String> reservedNames, HopGraph hops) {
     String fromName = proposal.parameter("fromTransform");
     String toName = proposal.parameter("toTransform");
     if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
-      return blocked(proposal, "fromTransform and toTransform are required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopEndsRequired"));
     }
     if (!transformExists(pipelineMeta, fromName, reservedNames)) {
-      return blocked(proposal, "From transform not found: " + fromName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.FromTransformNotFound", fromName));
     }
     if (!transformExists(pipelineMeta, toName, reservedNames)) {
-      return blocked(proposal, "To transform not found: " + toName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.ToTransformNotFound", toName));
     }
     TransformMeta from = pipelineMeta.findTransform(fromName);
     TransformMeta to = pipelineMeta.findTransform(toName);
     if (fromName.trim().equals(toName.trim())) {
-      return blocked(proposal, "Hop cannot connect a transform to itself");
-    }
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopToItself"));
+    }
+    // A pipeline cannot loop, through any number of hops: those of the graph that the batch keeps
+    // and those proposed earlier in it.
+    List<String> loop = hops.pathBack(fromName.trim(), toName.trim());
+    if (loop != null) {
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.HopLoop", String.join(" -> ", loop)));
+    }
+    hops.add(fromName.trim(), toName.trim());
     if (from != null && to != null && pipelineMeta.findPipelineHop(from, to) != null) {
-      return warning(proposal, "Hop already exists");
+      return warning(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopExists"));
     }
     String enabled = proposal.parameter("enabled");
     if (!Utils.isEmpty(enabled) && !AiProposalParamSupport.isYesNo(enabled)) {
-      return blocked(proposal, "enabled must be Y or N");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.EnabledYesNo"));
     }
     return ok(proposal);
   }
 
+  private static String deletedHop(String fromName, String toName) {
+    return "deleted-hop:"
+        + (fromName == null ? "" : fromName.trim())
+        + "->"
+        + (toName == null ? "" : toName.trim());
+  }
+
   private static AiProposalValidation validateDeletePipelineHop(
       PipelineMeta pipelineMeta, AiProposal proposal) {
     String fromName = proposal.parameter("fromTransform");
     String toName = proposal.parameter("toTransform");
     if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
-      return blocked(proposal, "fromTransform and toTransform are required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopEndsRequired"));
     }
     TransformMeta from = pipelineMeta.findTransform(fromName);
     TransformMeta to = pipelineMeta.findTransform(toName);
     if (from == null || to == null) {
-      return blocked(proposal, "Hop endpoints not found");
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopEndpointsNotFound"));
     }
     if (pipelineMeta.findPipelineHop(from, to) == null) {
-      return blocked(proposal, "Hop not found");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopNotFound"));
     }
     return ok(proposal);
   }
@@ -222,13 +309,19 @@ public final class PipelineAiProposalValidator {
       PipelineMeta pipelineMeta, AiProposal proposal) {
     String transformName = proposal.parameter("transformName");
     if (Utils.isEmpty(transformName)) {
-      return blocked(proposal, "transformName is required");
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.TransformNameRequired"));
     }
     if (pipelineMeta.findTransform(transformName) == null) {
-      return blocked(proposal, "Transform not found: " + transformName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.TransformNotFound", transformName));
     }
     if (!AiProposalParamSupport.parseLocation(proposal).isValid()) {
-      return blocked(proposal, "locationX and locationY must be integers");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.LocationNotIntegers"));
     }
     return ok(proposal);
   }
@@ -240,20 +333,27 @@ public final class PipelineAiProposalValidator {
       return blocked(proposal, error);
     }
     if (AiProposalXmlSupport.containsSecrets(xml)) {
-      return warning(proposal, "XML contains password-like fields");
+      return warning(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.XmlSecrets"));
     }
-    return warning(proposal, "Copies XML to the clipboard. Paste on the canvas (Ctrl-V).");
+    return warning(
+        proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.ClipboardPaste"));
   }
 
   private static AiProposalValidation validateReplaceTransform(
       PipelineMeta pipelineMeta, AiProposal proposal) {
     String transformName = proposal.parameter("transformName");
     if (Utils.isEmpty(transformName)) {
-      return blocked(proposal, "transformName is required");
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "PipelineAiProposalValidator.TransformNameRequired"));
     }
     TransformMeta existing = pipelineMeta.findTransform(transformName);
     if (existing == null) {
-      return blocked(proposal, "Transform not found: " + transformName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(
+              PKG, "PipelineAiProposalValidator.TransformNotFound", transformName));
     }
     String xml = AiProposalXmlSupport.xmlParam(proposal);
     String error = AiProposalXmlSupport.validatePipelineXml(xml);
@@ -267,26 +367,34 @@ public final class PipelineAiProposalValidator {
           && !existing.getTransformPluginId().equals(ids.get(0))) {
         return blocked(
             proposal,
-            "XML plugin id "
-                + ids.get(0)
-                + " does not match existing transform "
-                + existing.getTransformPluginId());
+            BaseMessages.getString(
+                PKG,
+                "PipelineAiProposalValidator.PluginIdMismatch",
+                ids.get(0),
+                existing.getTransformPluginId()));
       }
     } catch (Exception e) {
-      return blocked(proposal, "Invalid transform XML");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.InvalidXml"));
     }
     if (AiProposalXmlSupport.containsSecrets(xml)) {
-      return warning(proposal, "Replaces transform XML; payload contains password-like fields");
+      return warning(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.ReplaceSecrets"));
     }
-    return warning(proposal, "Replaces the configuration of " + transformName);
+    return warning(
+        proposal,
+        BaseMessages.getString(
+            PKG, "PipelineAiProposalValidator.ReplaceConfiguration", transformName));
   }
 
   private static AiProposalValidation validateAddPipelineNote(AiProposal proposal) {
     if (Utils.isEmpty(proposal.parameter("text"))) {
-      return blocked(proposal, "text is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.TextRequired"));
     }
     if (!AiProposalParamSupport.parseLocation(proposal).isValid()) {
-      return blocked(proposal, "locationX and locationY must be integers");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.LocationNotIntegers"));
     }
     return ok(proposal);
   }
@@ -318,4 +426,91 @@ public final class PipelineAiProposalValidator {
     return pipelineMeta.findTransform(name) != null
         || (reservedNames != null && reservedNames.contains(name.trim()));
   }
+
+  /**
+   * The hops between transform names as they will be once the batch is applied up to the proposal
+   * being checked: the graph's hops without those the batch deletes, with the transforms the batch
+   * renames or deletes so far, and the hops it adds so far.
+   */
+  private static final class HopGraph {
+    private final Map<String, Set<String>> next = new HashMap<>();
+
+    static HopGraph of(PipelineMeta pipelineMeta, Set<String> reservedNames) {
+      HopGraph graph = new HopGraph();
+      if (pipelineMeta == null) {
+        return graph;
+      }
+      for (PipelineHopMeta hop : pipelineMeta.getPipelineHops()) {
+        if (hop.getFromTransform() == null || hop.getToTransform() == null) {
+          continue;
+        }
+        String from = hop.getFromTransform().getName().trim();
+        String to = hop.getToTransform().getName().trim();
+        if (!reservedNames.contains(deletedHop(from, to))) {
+          graph.add(from, to);
+        }
+      }
+      return graph;
+    }
+
+    void add(String from, String to) {
+      next.computeIfAbsent(from, k -> new LinkedHashSet<>()).add(to);
+    }
+
+    /** Follow a rename or delete that passed validation. */
+    AiProposalValidation track(AiProposal proposal, AiProposalValidation validation) {
+      if (validation.isBlocked()) {
+        return validation;
+      }
+      String name = proposal.parameter("transformName").trim();
+      if (AiProposalTypes.of(proposal) == AiProposalTypes.DELETE_TRANSFORM) {
+        rename(name, null);
+      } else {
+        rename(name, proposal.parameter("newName").trim());
+      }
+      return validation;
+    }
+
+    private void rename(String oldName, String newName) {
+      Set<String> out = next.remove(oldName);
+      if (newName != null && out != null) {
+        next.computeIfAbsent(newName, k -> new LinkedHashSet<>()).addAll(out);
+      }
+      for (Set<String> targets : next.values()) {
+        if (targets.remove(oldName) && newName != null) {
+          targets.add(newName);
+        }
+      }
+    }
+
+    /**
+     * The loop a new hop from -> to would close, as from, to, ..., from; or null when there is
+     * none.
+     */
+    List<String> pathBack(String from, String to) {
+      Map<String, String> cameFrom = new LinkedHashMap<>();
+      Deque<String> queue = new ArrayDeque<>();
+      queue.add(to);
+      cameFrom.put(to, null);
+      while (!queue.isEmpty()) {
+        String current = queue.poll();
+        if (current.equals(from)) {
+          List<String> path = new ArrayList<>();
+          for (String at = current; at != null; at = cameFrom.get(at)) {
+            path.add(at);
+          }
+          Collections.reverse(path);
+          path.add(0, from);
+          return path;
+        }
+        for (String target : next.getOrDefault(current, Set.of())) {
+          if (!cameFrom.containsKey(target)) {
+            cameFrom.put(target, current);
+            queue.add(target);
+          }
+        }
+      }
+      return null;
+    }
+  }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiAdvisor.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiAdvisor.java
index a5a4ec0c08..f5121256d0 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiAdvisor.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiAdvisor.java
@@ -28,6 +28,8 @@ import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.advisor.AiProposalValidation;
 import org.apache.hop.ai.advisor.IAiAdvisor;
 import org.apache.hop.ai.advisors.AiAdvisorInclusions;
+import org.apache.hop.ai.engine.AiAdvisorEngine;
+import org.apache.hop.ai.engine.AiProposalNormalizer;
 import org.apache.hop.ai.engine.AiProposalPreview;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.i18n.BaseMessages;
@@ -85,6 +87,12 @@ public class WorkflowAiAdvisor implements IAiAdvisor {
   @Override
   public List<AiAdvisorInclusion> listInclusions() {
     return List.of(
+        new AiAdvisorInclusion(
+            AiAdvisorInclusions.SETTINGS,
+            BaseMessages.getString(PKG, "WorkflowAiAdvisor.Inclusion.Settings"),
+            true,
+            BaseMessages.getString(PKG, "WorkflowAiAdvisor.Inclusion.Settings.Tooltip"),
+            BaseMessages.getString(PKG, "WorkflowAiAdvisor.Inclusion.Settings.Summary")),
         new AiAdvisorInclusion(
             AiAdvisorInclusions.CHECKS,
             BaseMessages.getString(PKG, "WorkflowAiAdvisor.Inclusion.Checks"),
@@ -135,6 +143,8 @@ public class WorkflowAiAdvisor implements IAiAdvisor {
       AiAdvisorRequest request, List<AiProposal> proposals) {
     WorkflowMeta workflowMeta =
         request != null && request.getArtifact() instanceof WorkflowMeta meta ? meta : null;
+    // Repair the model's slips first (a pipeline hop type, a missing plugin id or location).
+    AiProposalNormalizer.forWorkflow(workflowMeta, proposals);
     return WorkflowAiProposalValidator.validate(
         workflowMeta, proposals, request != null ? request.getMetadataProvider() : null);
   }
@@ -143,7 +153,8 @@ public class WorkflowAiAdvisor implements IAiAdvisor {
   public void applyProposals(AiAdvisorRequest request, List<AiProposal> selected)
       throws HopException {
     if (!(request.getArtifact() instanceof WorkflowMeta workflowMeta)) {
-      throw new HopException("No workflow is bound to this session.");
+      throw new HopException(
+          BaseMessages.getString(AiAdvisorEngine.class, "AiContextBuilder.NotLinked.Workflow"));
     }
     HopGui hopGui = hopGuiFrom(request);
     WorkflowAiProposalApplier.apply(
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiContextBuilder.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiContextBuilder.java
index 862bc425a6..d0cffcb113 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiContextBuilder.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiContextBuilder.java
@@ -18,24 +18,25 @@
 package org.apache.hop.ai.advisors.workflow;
 
 import java.util.ArrayList;
-import java.util.Comparator;
 import java.util.List;
 import org.apache.hop.ai.advisor.AiAdvisorPrompt;
 import org.apache.hop.ai.advisor.AiAdvisorRequest;
 import org.apache.hop.ai.advisors.AiAdvisorInclusions;
 import org.apache.hop.ai.config.HopAiConfigSingleton;
+import org.apache.hop.ai.engine.AiAdvisorEngine;
 import org.apache.hop.ai.engine.AiAdvisorMetadataContext;
 import org.apache.hop.ai.engine.AiCheckResultsSerializer;
 import org.apache.hop.ai.engine.AiM2PromptSupport;
+import org.apache.hop.ai.engine.AiNodeSettings;
+import org.apache.hop.ai.engine.AiPluginCatalog;
 import org.apache.hop.ai.engine.AiPromptLoader;
 import org.apache.hop.ai.engine.AiTextUtil;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.ActionPluginType;
-import org.apache.hop.core.plugins.IPlugin;
-import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.workflow.WorkflowHopMeta;
 import org.apache.hop.workflow.WorkflowMeta;
@@ -46,17 +47,19 @@ public final class WorkflowAiContextBuilder {
   static final String PROMPT_ROOT = "/org/apache/hop/ai/prompts/workflow/";
   private static final int MAX_TOPOLOGY_XML_CHARS = 120_000;
   private static final int MAX_FOCUS_XML_CHARS = 40_000;
+  private static final int MAX_SETTINGS_CHARS = 40_000;
   private static final int MAX_LOG_CHARS = 20_000;
-  private static final int MAX_CATALOG_ENTRIES = 120;
 
   private WorkflowAiContextBuilder() {}
 
   public static AiAdvisorPrompt buildPrompt(AiAdvisorRequest request) throws HopException {
     if (!(request.getArtifact() instanceof WorkflowMeta workflowMeta)) {
-      throw new HopException("No workflow is bound to this session.");
+      throw new HopException(
+          BaseMessages.getString(AiAdvisorEngine.class, "AiContextBuilder.NotLinked.Workflow"));
     }
     if (Utils.isEmpty(request.getUserPrompt())) {
-      throw new HopException("Please enter a question for the AI advisor");
+      throw new HopException(
+          BaseMessages.getString(AiAdvisorEngine.class, "AiContextBuilder.NoQuestion"));
     }
     String scenarioId =
         Utils.isEmpty(request.getScenarioId()) ? "workflow-general" : request.getScenarioId();
@@ -73,55 +76,51 @@ public final class WorkflowAiContextBuilder {
       throws HopException {
     IVariables variables = request.getVariables();
     IHopMetadataProvider metadataProvider = request.getMetadataProvider();
-    boolean includeFull = !request.isFollowUp();
     StringBuilder prompt = new StringBuilder();
-    prompt.append("User question:\n").append(request.getUserPrompt()).append("\n\n");
-    prompt
-        .append("Workflow structure JSON:\n")
-        .append(serializeStructure(workflowMeta, request.getFocusNodeName()))
-        .append("\n\n");
-    if (includeFull) {
-      prompt
-          .append("Workflow summary JSON:\n")
-          .append(serializeSummary(workflowMeta))
-          .append("\n\n");
-      AiAdvisorMetadataContext.appendTypeKeys(prompt, metadataProvider);
-      AiAdvisorMetadataContext.appendDatabaseCatalog(prompt);
-      if (request.inclusionEnabled(AiAdvisorInclusions.CATALOG)) {
-        prompt
-            .append("Available action plugins JSON:\n")
-            .append(serializeActionCatalog())
-            .append("\n\n");
-      }
-      if (request.inclusionEnabled(AiAdvisorInclusions.XML)
-          && HopAiConfigSingleton.getConfig().isAllowSendFullXml()) {
-        prompt
-            .append("Workflow topology XML:\n")
-            .append(
-                AiTextUtil.redactSecrets(
-                    AiTextUtil.truncate(workflowMeta.getXml(variables), MAX_TOPOLOGY_XML_CHARS)))
-            .append("\n\n");
-      }
-      if (request.inclusionEnabled(AiAdvisorInclusions.LOGS)) {
-        prompt
-            .append("Execution log excerpt:\n")
-            .append(
-                AiTextUtil.redactSecrets(
-                    AiTextUtil.truncate(request.getLogExcerpt(), MAX_LOG_CHARS)))
-            .append("\n\n");
-      }
+    // Every turn sends what is checked. The history only replays questions and answers, so a
+    // follow-up that skipped this context would lose it, including the log of a new run.
+    AiTextUtil.appendSection(prompt, "workflow_summary", serializeSummary(workflowMeta));
+    AiTextUtil.appendSection(
+        prompt,
+        "workflow_structure",
+        serializeStructure(
+            workflowMeta,
+            request.getFocusNodeName(),
+            request.inclusionEnabled(AiAdvisorInclusions.SETTINGS)));
+    AiAdvisorMetadataContext.appendTypeKeys(prompt, metadataProvider);
+    AiAdvisorMetadataContext.appendDatabaseCatalog(prompt);
+    if (request.inclusionEnabled(AiAdvisorInclusions.CATALOG)) {
+      AiTextUtil.appendSection(prompt, "plugin_catalog", serializeActionCatalog());
+    }
+    if (request.inclusionEnabled(AiAdvisorInclusions.XML)
+        && HopAiConfigSingleton.getConfig().isAllowSendFullXml()) {
+      AiTextUtil.appendSection(
+          prompt,
+          "workflow_xml",
+          AiTextUtil.redactSecrets(
+              AiTextUtil.truncate(workflowMeta.getXml(variables), MAX_TOPOLOGY_XML_CHARS)));
+    }
+    if (request.inclusionEnabled(AiAdvisorInclusions.LOGS)) {
+      AiTextUtil.appendSection(
+          prompt,
+          "execution_log",
+          AiTextUtil.redactSecrets(AiTextUtil.truncate(request.getLogExcerpt(), MAX_LOG_CHARS)));
     }
     AiAdvisorMetadataContext.appendToPrompt(prompt, request);
     appendFocusAction(prompt, workflowMeta, request.getFocusNodeName());
     if (request.inclusionEnabled(AiAdvisorInclusions.CHECKS) && metadataProvider != null) {
       List<ICheckResult> results = new ArrayList<>();
       workflowMeta.checkActions(results, false, null, variables, metadataProvider);
-      prompt
-          .append("Workflow check results JSON:\n")
-          .append(AiCheckResultsSerializer.serialize(results))
-          .append("\n\n");
+      AiTextUtil.appendSection(
+          prompt, "check_results", AiCheckResultsSerializer.serialize(results));
     }
     AiM2PromptSupport.appendAppliedSummaries(prompt, request.getAppliedChangeSummaries());
+    AiTextUtil.appendSection(prompt, "question", request.getUserPrompt());
+    // Repeated from the instructions: small models follow the last thing they read best, and
+    // otherwise drift to English after a long English context.
+    prompt.append(
+        "Write your answer in the language that the text in the <question> block is written"
+            + " in.\n");
     return prompt.toString();
   }
 
@@ -129,7 +128,7 @@ public final class WorkflowAiContextBuilder {
     if (Utils.isEmpty(focusName)) {
       return;
     }
-    prompt.append("Focus action:\n").append(focusName).append("\n\n");
+    AiTextUtil.appendSection(prompt, "focus_action", focusName);
     ActionMeta action = workflowMeta.findAction(focusName);
     if (action == null) {
       return;
@@ -139,19 +138,28 @@ public final class WorkflowAiContextBuilder {
       if (Utils.isEmpty(xml)) {
         return;
       }
-      prompt
-          .append("Focus action XML:\n")
-          .append(AiTextUtil.redactSecrets(AiTextUtil.truncate(xml, MAX_FOCUS_XML_CHARS)))
-          .append("\n\n");
+      AiTextUtil.appendSection(
+          prompt,
+          "focus_action_xml",
+          AiTextUtil.redactSecrets(AiTextUtil.truncate(xml, MAX_FOCUS_XML_CHARS)));
     } catch (Exception e) {
       // Skip unreadable action XML rather than failing the whole prompt.
     }
   }
 
   public static String serializeStructure(WorkflowMeta workflowMeta, String focusActionName) {
+    return serializeStructure(workflowMeta, focusActionName, false);
+  }
+
+  /**
+   * @param includeSettings add each action's settings, until {@link #MAX_SETTINGS_CHARS} is used
+   */
+  public static String serializeStructure(
+      WorkflowMeta workflowMeta, String focusActionName, boolean includeSettings) {
     StringBuilder json = new StringBuilder();
     json.append("{\"actions\":[");
     List<ActionMeta> actions = workflowMeta.getActions();
+    int settingsChars = 0;
     for (int i = 0; i < actions.size(); i++) {
       if (i > 0) {
         json.append(',');
@@ -163,6 +171,13 @@ public final class WorkflowAiContextBuilder {
               AiTextUtil.jsonString(
                   action.getAction() != null ? action.getAction().getPluginId() : ""));
       json.append(",\"parallel\":").append(action.isLaunchingInParallel());
+      if (includeSettings && settingsChars < MAX_SETTINGS_CHARS) {
+        String settings = AiNodeSettings.toJson(action.getAction());
+        if (settings != null) {
+          json.append(",\"settings\":").append(settings);
+          settingsChars += settings.length();
+        }
+      }
       json.append('}');
     }
     json.append("],\"hops\":[");
@@ -206,32 +221,6 @@ public final class WorkflowAiContextBuilder {
   }
 
   static String serializeActionCatalog() {
-    StringBuilder json = new StringBuilder();
-    json.append("{\"actions\":[");
-    PluginRegistry registry = PluginRegistry.getInstance();
-    List<IPlugin> plugins = new ArrayList<>(registry.getPlugins(ActionPluginType.class));
-    plugins.sort(
-        Comparator.comparing(
-                IPlugin::getCategory, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))
-            .thenComparing(IPlugin::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
-    int count = 0;
-    for (IPlugin plugin : plugins) {
-      if (count >= MAX_CATALOG_ENTRIES) {
-        break;
-      }
-      if (plugin.getIds() == null || plugin.getIds().length == 0) {
-        continue;
-      }
-      if (count > 0) {
-        json.append(',');
-      }
-      json.append("{\"id\":").append(AiTextUtil.jsonString(plugin.getIds()[0]));
-      json.append(",\"name\":").append(AiTextUtil.jsonString(plugin.getName()));
-      json.append(",\"category\":").append(AiTextUtil.jsonString(plugin.getCategory()));
-      json.append('}');
-      count++;
-    }
-    json.append("]}");
-    return json.toString();
+    return AiPluginCatalog.compact(ActionPluginType.class);
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalApplier.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalApplier.java
index 1bdbc3470d..553371ede0 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalApplier.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalApplier.java
@@ -23,6 +23,7 @@ import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.engine.AiActionPluginSupport;
 import org.apache.hop.ai.engine.AiProposalParamSupport;
 import org.apache.hop.ai.engine.AiProposalTypes;
+import org.apache.hop.ai.engine.AiProposalUndo;
 import org.apache.hop.ai.engine.AiProposalXmlSupport;
 import org.apache.hop.ai.engine.AiTransformConfigSupport;
 import org.apache.hop.core.NotePadMeta;
@@ -30,15 +31,20 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.Point;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.variables.Variables;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.workflow.WorkflowHopMeta;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionMeta;
+import org.w3c.dom.Document;
 
 /** Previews and applies validated AI proposals to an open workflow. */
 public final class WorkflowAiProposalApplier {
 
+  private static final Class<?> PKG = WorkflowAiProposalApplier.class;
+
   private WorkflowAiProposalApplier() {}
 
   public static void apply(WorkflowMeta workflowMeta, List<AiProposal> proposals)
@@ -73,17 +79,112 @@ public final class WorkflowAiProposalApplier {
       IVariables variables)
       throws HopException {
     if (workflowMeta == null) {
-      throw new HopException("No workflow is open");
+      throw new HopException(BaseMessages.getString(PKG, "WorkflowAiProposalApplier.NoWorkflow"));
     }
     if (proposals == null || proposals.isEmpty()) {
       return;
     }
+    proposals = AiProposalTypes.inApplyOrder(proposals);
     IHopMetadataProvider provider =
         metadataProvider != null ? metadataProvider : workflowMeta.getMetadataProvider();
     IVariables vars = variables != null ? variables : Variables.getADefaultVariableSpace();
-    for (int i = 0; i < proposals.size(); i++) {
-      boolean chainUndo = hopGui != null && i < proposals.size() - 1;
-      applyOne(workflowMeta, proposals.get(i), hopGui, chainUndo, provider, vars);
+    // All or nothing. The workflow as it is now is kept to put back when a proposal fails half
+    // way; without it nothing is applied.
+    String before;
+    try {
+      before = workflowMeta.getXml(vars);
+    } catch (Exception e) {
+      throw new HopException(
+          BaseMessages.getString(PKG, "WorkflowAiProposalApplier.CannotCopy"), e);
+    }
+    // A dry run on a copy finds most failures before the open workflow is touched. The copy can
+    // fail where the workflow itself is fine (a plugin that cannot load its XML); the rollback
+    // below still covers that case.
+    WorkflowMeta copy = copyForDryRun(before, provider, vars);
+    if (copy != null) {
+      for (AiProposal proposal : proposals) {
+        applyOne(copy, proposal, null, false, provider, vars);
+      }
+    }
+    applyOrRestore(workflowMeta, before, proposals, hopGui, provider, vars);
+  }
+
+  /**
+   * Apply the proposals to the open workflow. When one fails, the workflow is put back as {@code
+   * before} and none of them stays applied.
+   */
+  static void applyOrRestore(
+      WorkflowMeta workflowMeta,
+      String before,
+      List<AiProposal> proposals,
+      HopGui hopGui,
+      IHopMetadataProvider provider,
+      IVariables vars)
+      throws HopException {
+    boolean changedBefore = workflowMeta.hasChanged();
+    int applied = 0;
+    try {
+      for (int i = 0; i < proposals.size(); i++) {
+        boolean chainUndo = hopGui != null && i < proposals.size() - 1;
+        applyOne(workflowMeta, proposals.get(i), hopGui, chainUndo, provider, vars);
+        applied++;
+      }
+    } catch (Exception e) {
+      // The failing proposal may have changed the workflow before it failed, so put it back even
+      // when it was the first.
+      HopException failure =
+          new HopException(
+              BaseMessages.getString(PKG, "WorkflowAiProposalApplier.RolledBack", applied + 1), e);
+      try {
+        restore(workflowMeta, before, provider, hopGui, changedBefore);
+      } catch (Exception restoreError) {
+        failure.addSuppressed(restoreError);
+      }
+      throw failure;
+    }
+  }
+
+  /** Put the workflow back as it was before a batch failed half way. */
+  static void restore(
+      WorkflowMeta workflowMeta,
+      String xml,
+      IHopMetadataProvider provider,
+      HopGui hopGui,
+      boolean changedBefore)
+      throws HopException {
+    Document document = XmlHandler.loadXmlString(xml);
+    workflowMeta.restoreContentFromXml(
+        XmlHandler.getSubNode(document, WorkflowMeta.XML_TAG),
+        workflowMeta.getFilename(),
+        provider);
+    // Restoring the content clears the changed flag: put back the one it had, so unsaved edits
+    // from before the batch still ask to be saved.
+    if (changedBefore) {
+      workflowMeta.setChanged();
+    } else {
+      workflowMeta.clearChanged();
+    }
+    AiProposalUndo.forgetPartialChange(hopGui, workflowMeta);
+  }
+
+  /** A copy through XML, or null when the workflow cannot be copied (the dry run is skipped). */
+  public static WorkflowMeta copyForDryRun(
+      WorkflowMeta workflowMeta, IHopMetadataProvider provider, IVariables variables) {
+    try {
+      return copyForDryRun(workflowMeta.getXml(variables), provider, variables);
+    } catch (Exception e) {
+      return null;
+    }
+  }
+
+  private static WorkflowMeta copyForDryRun(
+      String xml, IHopMetadataProvider provider, IVariables variables) {
+    try {
+      Document document = XmlHandler.loadXmlString(xml);
+      return new WorkflowMeta(
+          XmlHandler.getSubNode(document, WorkflowMeta.XML_TAG), provider, variables);
+    } catch (Exception e) {
+      return null;
     }
   }
 
@@ -110,7 +211,9 @@ public final class WorkflowAiProposalApplier {
       case CONFIGURE_ACTION -> configureAction(workflowMeta, proposal, hopGui, chainUndo);
       case REPLACE_ACTION ->
           replaceAction(workflowMeta, proposal, hopGui, chainUndo, metadataProvider, variables);
-      default -> throw new HopException("Unsupported proposal type: " + type);
+      default ->
+          throw new HopException(
+              BaseMessages.getString(PKG, "WorkflowAiProposalApplier.UnsupportedType", type));
     }
   }
 
@@ -140,7 +243,8 @@ public final class WorkflowAiProposalApplier {
       throws HopException {
     ActionMeta existing = requireAction(workflowMeta, proposal.parameter("actionName"));
     if (existing.getAction() == null) {
-      throw new HopException("Action has no metadata: " + existing.getName());
+      throw new HopException(
+          BaseMessages.getString(PKG, "WorkflowAiProposalApplier.NoMetadata", existing.getName()));
     }
     ActionMeta before = (ActionMeta) existing.clone();
     AiTransformConfigSupport.apply(existing.getAction(), proposal);
@@ -227,7 +331,9 @@ public final class WorkflowAiProposalApplier {
     ActionMeta to = requireAction(workflowMeta, proposal.parameter("toAction"));
     WorkflowHopMeta hop = workflowMeta.findWorkflowHop(from, to);
     if (hop == null) {
-      throw new HopException("Hop not found: " + from.getName() + " -> " + to.getName());
+      throw new HopException(
+          BaseMessages.getString(
+              PKG, "WorkflowAiProposalApplier.HopNotFound", from.getName(), to.getName()));
     }
     int hopIndex = workflowMeta.indexOfWorkflowHop(hop);
     if (hopGui != null) {
@@ -314,7 +420,8 @@ public final class WorkflowAiProposalApplier {
       throws HopException {
     ActionMeta action = workflowMeta.findAction(name);
     if (action == null) {
-      throw new HopException("Action not found: " + name);
+      throw new HopException(
+          BaseMessages.getString(PKG, "WorkflowAiProposalApplier.ActionNotFound", name));
     }
     return action;
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalValidator.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalValidator.java
index 386a03018a..b9912577fb 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalValidator.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/workflow/WorkflowAiProposalValidator.java
@@ -31,6 +31,7 @@ import org.apache.hop.ai.engine.AiTransformConfigSupport;
 import org.apache.hop.core.plugins.ActionPluginType;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionMeta;
@@ -38,6 +39,8 @@ import org.apache.hop.workflow.action.ActionMeta;
 /** Validates AI workflow proposals against the open graph before the user applies them. */
 public final class WorkflowAiProposalValidator {
 
+  private static final Class<?> PKG = WorkflowAiProposalValidator.class;
+
   private WorkflowAiProposalValidator() {}
 
   public static List<AiProposalValidation> validate(
@@ -67,13 +70,19 @@ public final class WorkflowAiProposalValidator {
       IHopMetadataProvider metadataProvider) {
     AiProposalTypes type = AiProposalTypes.of(proposal);
     if (type == null) {
-      return blocked(proposal, "Missing or unknown proposal type");
+      return blocked(
+          proposal,
+          Utils.isEmpty(proposal.getType())
+              ? BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NoType")
+              : BaseMessages.getString(
+                  PKG, "WorkflowAiProposalValidator.UnknownType", proposal.getType()));
     }
     if (!type.isWorkflowType()) {
-      return blocked(proposal, "Not a workflow proposal type: " + type);
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NotOwnType", type));
     }
     if (workflowMeta == null) {
-      return blocked(proposal, "No workflow is open");
+      return blocked(proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NoGraph"));
     }
     return switch (type) {
       case ADD_ACTION -> validateAddAction(workflowMeta, proposal, reservedNames);
@@ -83,12 +92,14 @@ public final class WorkflowAiProposalValidator {
       case DELETE_WORKFLOW_HOP -> validateDeleteWorkflowHop(workflowMeta, proposal);
       case SET_ACTION_LOCATION -> validateSetActionLocation(workflowMeta, proposal);
       case ADD_WORKFLOW_NOTE -> validateAddWorkflowNote(proposal);
-      case CONFIGURE_ACTION -> validateConfigureAction(workflowMeta, proposal);
+      case CONFIGURE_ACTION -> validateConfigureAction(workflowMeta, proposal, reservedNames);
       case CLIPBOARD_ACTIONS -> validateClipboardActions(proposal);
       case REPLACE_ACTION -> validateReplaceAction(workflowMeta, proposal);
       case CLIPBOARD_METADATA, SAVE_METADATA ->
           AiMetadataProposalSupport.validate(proposal, metadataProvider);
-      default -> blocked(proposal, "Unsupported proposal type");
+      default ->
+          blocked(
+              proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.UnsupportedType"));
     };
   }
 
@@ -97,35 +108,46 @@ public final class WorkflowAiProposalValidator {
     String pluginId = proposal.parameter("actionPluginId");
     String name = proposal.parameter("name");
     if (Utils.isEmpty(pluginId)) {
-      return blocked(proposal, "actionPluginId is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.PluginIdRequired"));
     }
     if (Utils.isEmpty(name)) {
-      return blocked(proposal, "name is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NameRequired"));
     }
     if (PluginRegistry.getInstance().findPluginWithId(ActionPluginType.class, pluginId) == null) {
-      return blocked(proposal, "Unknown action plugin: " + pluginId);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.UnknownPlugin", pluginId));
     }
     if (workflowMeta.findAction(name) != null || reservedNames.contains(name.trim())) {
-      return blocked(proposal, "Action name already exists: " + name);
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NameExists", name));
     }
     if (!AiProposalParamSupport.parseLocation(proposal).isValid()) {
-      return blocked(proposal, "locationX and locationY must be integers");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.LocationNotIntegers"));
     }
     reservedNames.add(name.trim());
     return ok(proposal);
   }
 
   private static AiProposalValidation validateConfigureAction(
-      WorkflowMeta workflowMeta, AiProposal proposal) {
+      WorkflowMeta workflowMeta, AiProposal proposal, Set<String> reservedNames) {
     String actionName = proposal.parameter("actionName");
     if (Utils.isEmpty(actionName)) {
-      return blocked(proposal, "actionName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNameRequired"));
     }
-    if (workflowMeta.findAction(actionName) == null) {
-      return blocked(proposal, "Action not found: " + actionName);
+    // An action added earlier in the same list exists by the time this one is applied.
+    if (workflowMeta.findAction(actionName) == null && !reservedNames.contains(actionName.trim())) {
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNotFound", actionName));
     }
     if (!AiTransformConfigSupport.hasConfig(proposal)) {
-      return blocked(proposal, "No configuration parameters");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NoConfiguration"));
     }
     return ok(proposal);
   }
@@ -134,10 +156,13 @@ public final class WorkflowAiProposalValidator {
       WorkflowMeta workflowMeta, AiProposal proposal) {
     String actionName = proposal.parameter("actionName");
     if (Utils.isEmpty(actionName)) {
-      return blocked(proposal, "actionName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNameRequired"));
     }
     if (workflowMeta.findAction(actionName) == null) {
-      return blocked(proposal, "Action not found: " + actionName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNotFound", actionName));
     }
     return ok(proposal);
   }
@@ -147,17 +172,22 @@ public final class WorkflowAiProposalValidator {
     String actionName = proposal.parameter("actionName");
     String newName = proposal.parameter("newName");
     if (Utils.isEmpty(actionName)) {
-      return blocked(proposal, "actionName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNameRequired"));
     }
     if (Utils.isEmpty(newName)) {
-      return blocked(proposal, "newName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NewNameRequired"));
     }
     if (workflowMeta.findAction(actionName) == null) {
-      return blocked(proposal, "Action not found: " + actionName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNotFound", actionName));
     }
     if (!actionName.trim().equals(newName.trim())
         && (workflowMeta.findAction(newName) != null || reservedNames.contains(newName.trim()))) {
-      return blocked(proposal, "Action name already exists: " + newName);
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.NameExists", newName));
     }
     reservedNames.add(newName.trim());
     return ok(proposal);
@@ -168,29 +198,38 @@ public final class WorkflowAiProposalValidator {
     String fromName = proposal.parameter("fromAction");
     String toName = proposal.parameter("toAction");
     if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
-      return blocked(proposal, "fromAction and toAction are required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.HopEndsRequired"));
     }
     if (!actionExists(workflowMeta, fromName, reservedNames)) {
-      return blocked(proposal, "From action not found: " + fromName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.FromActionNotFound", fromName));
     }
     if (!actionExists(workflowMeta, toName, reservedNames)) {
-      return blocked(proposal, "To action not found: " + toName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ToActionNotFound", toName));
     }
     ActionMeta from = workflowMeta.findAction(fromName);
     ActionMeta to = workflowMeta.findAction(toName);
     if (fromName.trim().equals(toName.trim())) {
-      return blocked(proposal, "Hop cannot connect an action to itself");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.HopToItself"));
     }
     if (from != null && to != null && workflowMeta.findWorkflowHop(from, to) != null) {
-      return warning(proposal, "Hop already exists");
+      return warning(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.HopExists"));
     }
     if (!Utils.isEmpty(proposal.parameter("unconditional"))
         && !AiProposalParamSupport.isYesNo(proposal.parameter("unconditional"))) {
-      return blocked(proposal, "unconditional must be Y or N");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.UnconditionalYesNo"));
     }
     if (!Utils.isEmpty(proposal.parameter("evaluation"))
         && !AiProposalParamSupport.isYesNo(proposal.parameter("evaluation"))) {
-      return blocked(proposal, "evaluation must be Y or N");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.EvaluationYesNo"));
     }
     return ok(proposal);
   }
@@ -200,15 +239,19 @@ public final class WorkflowAiProposalValidator {
     String fromName = proposal.parameter("fromAction");
     String toName = proposal.parameter("toAction");
     if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
-      return blocked(proposal, "fromAction and toAction are required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.HopEndsRequired"));
     }
     ActionMeta from = workflowMeta.findAction(fromName);
     ActionMeta to = workflowMeta.findAction(toName);
     if (from == null || to == null) {
-      return blocked(proposal, "Hop endpoints not found");
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.HopEndpointsNotFound"));
     }
     if (workflowMeta.findWorkflowHop(from, to) == null) {
-      return blocked(proposal, "Hop not found");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.HopNotFound"));
     }
     return ok(proposal);
   }
@@ -217,13 +260,17 @@ public final class WorkflowAiProposalValidator {
       WorkflowMeta workflowMeta, AiProposal proposal) {
     String actionName = proposal.parameter("actionName");
     if (Utils.isEmpty(actionName)) {
-      return blocked(proposal, "actionName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNameRequired"));
     }
     if (workflowMeta.findAction(actionName) == null) {
-      return blocked(proposal, "Action not found: " + actionName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNotFound", actionName));
     }
     if (!AiProposalParamSupport.parseLocation(proposal).isValid()) {
-      return blocked(proposal, "locationX and locationY must be integers");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.LocationNotIntegers"));
     }
     return ok(proposal);
   }
@@ -235,20 +282,25 @@ public final class WorkflowAiProposalValidator {
       return blocked(proposal, error);
     }
     if (AiProposalXmlSupport.containsSecrets(xml)) {
-      return warning(proposal, "XML contains password-like fields");
+      return warning(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.XmlSecrets"));
     }
-    return warning(proposal, "Copies XML to the clipboard. Paste on the canvas (Ctrl-V).");
+    return warning(
+        proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ClipboardPaste"));
   }
 
   private static AiProposalValidation validateReplaceAction(
       WorkflowMeta workflowMeta, AiProposal proposal) {
     String actionName = proposal.parameter("actionName");
     if (Utils.isEmpty(actionName)) {
-      return blocked(proposal, "actionName is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNameRequired"));
     }
     ActionMeta existing = workflowMeta.findAction(actionName);
     if (existing == null) {
-      return blocked(proposal, "Action not found: " + actionName);
+      return blocked(
+          proposal,
+          BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ActionNotFound", actionName));
     }
     String xml = AiProposalXmlSupport.xmlParam(proposal);
     String error = AiProposalXmlSupport.validateWorkflowXml(xml);
@@ -261,23 +313,31 @@ public final class WorkflowAiProposalValidator {
       if (!ids.isEmpty() && !Utils.isEmpty(existingId) && !existingId.equals(ids.get(0))) {
         return blocked(
             proposal,
-            "XML plugin id " + ids.get(0) + " does not match existing action " + existingId);
+            BaseMessages.getString(
+                PKG, "WorkflowAiProposalValidator.PluginIdMismatch", ids.get(0), existingId));
       }
     } catch (Exception e) {
-      return blocked(proposal, "Invalid action XML");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.InvalidXml"));
     }
     if (AiProposalXmlSupport.containsSecrets(xml)) {
-      return warning(proposal, "Replaces action XML; payload contains password-like fields");
+      return warning(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.ReplaceSecrets"));
     }
-    return warning(proposal, "Replaces the configuration of " + actionName);
+    return warning(
+        proposal,
+        BaseMessages.getString(
+            PKG, "WorkflowAiProposalValidator.ReplaceConfiguration", actionName));
   }
 
   private static AiProposalValidation validateAddWorkflowNote(AiProposal proposal) {
     if (Utils.isEmpty(proposal.parameter("text"))) {
-      return blocked(proposal, "text is required");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.TextRequired"));
     }
     if (!AiProposalParamSupport.parseLocation(proposal).isValid()) {
-      return blocked(proposal, "locationX and locationY must be integers");
+      return blocked(
+          proposal, BaseMessages.getString(PKG, "WorkflowAiProposalValidator.LocationNotIntegers"));
     }
     return ok(proposal);
   }
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/ui/AiAdvisorSharingSummaryTest.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/AiRequestOnClose.java
similarity index 53%
copy from plugins/tech/ai/src/test/java/org/apache/hop/ai/ui/AiAdvisorSharingSummaryTest.java
copy to plugins/tech/ai/src/main/java/org/apache/hop/ai/config/AiRequestOnClose.java
index b425bb7d51..4d93fd6216 100644
--- a/plugins/tech/ai/src/test/java/org/apache/hop/ai/ui/AiAdvisorSharingSummaryTest.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/AiRequestOnClose.java
@@ -15,27 +15,29 @@
  * limitations under the License.
  */
 
-package org.apache.hop.ai.ui;
+package org.apache.hop.ai.config;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IEnumHasCodeAndDescription;
 
-import java.util.List;
-import org.junit.jupiter.api.Test;
+/**
+ * What happens to a question that is still running when its AI Assistant window is closed. Exiting
+ * Hop GUI always stops it.
+ */
+public enum AiRequestOnClose implements IEnumHasCodeAndDescription {
+  /** The answer is still recorded in the session, and shown when the session is opened again. */
+  FINISH_IN_BACKGROUND,
 
-class AiAdvisorSharingSummaryTest {
+  /** The request is cancelled, as with Stop. */
+  CANCEL;
 
-  @Test
-  void questionOnly() {
-    assertEquals(
-        "Sharing: your question",
-        AiAdvisorSessionPane.formatSharingLine("Sharing: ", List.of("your question")));
+  @Override
+  public String getCode() {
+    return name();
   }
 
-  @Test
-  void questionPlusOptInExtras() {
-    assertEquals(
-        "Sharing: your question, graph structure, check results",
-        AiAdvisorSessionPane.formatSharingLine(
-            "Sharing: ", List.of("your question", "graph structure", "check results")));
+  @Override
+  public String getDescription() {
+    return BaseMessages.getString(AiRequestOnClose.class, "AiRequestOnClose." + name());
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfig.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfig.java
index 8e6f278f74..bb9a7ba8e1 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfig.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfig.java
@@ -27,8 +27,12 @@ public class HopAiConfig {
 
   public static final String HOP_CONFIG_KEY = "hopAiConfig";
 
-  /** Default context-file list: include a project AGENTS.md when it exists. */
-  public static final String DEFAULT_EXTRA_CONTEXT_FILES = "${PROJECT_HOME}/AGENTS.md";
+  /**
+   * Default context-file list: none. A project's AGENTS.md is usually written for coding agents,
+   * and whatever a listed file says goes into the system prompt as instructions, so adding one is
+   * the user's choice.
+   */
+  public static final String DEFAULT_EXTRA_CONTEXT_FILES = "";
 
   /**
    * Hop-wide standing notes sent with every advisor. Short facts models otherwise get wrong
@@ -49,9 +53,18 @@ public class HopAiConfig {
   /** Name of the default {@code AiProvider} metadata object for GUI advisors. */
   private String defaultProviderName = "";
 
-  /** When true, advisors may send full pipeline/workflow XML on the first turn. */
+  /** When true, advisors may send full pipeline/workflow XML when the session asks for it. */
   private boolean allowSendFullXml;
 
+  /**
+   * Keep the conversations of each project when Hop GUI closes, in the audit folder ({@code
+   * HOP_AUDIT_FOLDER}), next to the other things Hop GUI remembers per project.
+   */
+  private boolean keepConversations = true;
+
+  /** What happens to a running question when its window closes. */
+  private AiRequestOnClose requestOnClose = AiRequestOnClose.FINISH_IN_BACKGROUND;
+
   /**
    * Standing notes appended to every advisor system prompt (conventions, case-sensitivity, naming).
    */
@@ -59,12 +72,17 @@ public class HopAiConfig {
 
   /**
    * One VFS path per line. Existing files are appended to the system prompt; missing files are
-   * skipped. Default picks up a project {@code AGENTS.md}.
+   * skipped. Empty by default.
    */
   private String extraContextFiles = DEFAULT_EXTRA_CONTEXT_FILES;
 
   public HopAiConfig() {}
 
+  /** Never null, also for a configuration saved before the option existed. */
+  public AiRequestOnClose getRequestOnClose() {
+    return requestOnClose == null ? AiRequestOnClose.FINISH_IN_BACKGROUND : requestOnClose;
+  }
+
   public HopAiConfig(HopAiConfig other) {
     if (other == null) {
       return;
@@ -72,6 +90,8 @@ public class HopAiConfig {
     this.aiEnabled = other.aiEnabled;
     this.defaultProviderName = other.defaultProviderName;
     this.allowSendFullXml = other.allowSendFullXml;
+    this.keepConversations = other.keepConversations;
+    this.requestOnClose = other.requestOnClose;
     this.extraContext = other.extraContext;
     this.extraContextFiles = other.extraContextFiles;
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigOptionPlugin.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigOptionPlugin.java
index 24ea883d9f..604fd4b14a 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigOptionPlugin.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigOptionPlugin.java
@@ -44,7 +44,7 @@ import picocli.CommandLine;
 @Setter
 @ConfigPlugin(
     id = "HopAiConfigOptionPlugin",
-    description = "Configuration options for Hop AI advisory",
+    description = "Configuration options for the Hop AI Assistant",
     category = ConfigPlugin.CATEGORY_CONFIG,
     classLoaderGroup = "hop-ai",
     configKey = HopAiConfig.HOP_CONFIG_KEY,
@@ -63,7 +63,7 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       label = "i18n::HopAiConfigOptionPlugin.GitWarning.Label",
       toolTip = "i18n::HopAiConfigOptionPlugin.GitWarning.Tooltip",
       groupType = GuiWidgetGroupType.BOXES,
-      group = "AI Advisory")
+      group = "i18n::HopAiConfigOptionPlugin.Group")
   public void gitWarning(Event event) {
     // Notice text only; LINK is the widget type this form can show without a value field.
   }
@@ -77,10 +77,10 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       label = "i18n::HopAiConfigOptionPlugin.AiEnabled.Label",
       toolTip = "i18n::HopAiConfigOptionPlugin.AiEnabled.Tooltip",
       groupType = GuiWidgetGroupType.BOXES,
-      group = "AI Advisory")
+      group = "i18n::HopAiConfigOptionPlugin.Group")
   @CommandLine.Option(
       names = {"--hop-ai-enabled"},
-      description = "Enable AI advisory in Hop GUI",
+      description = "Enable the AI Assistant in Hop GUI",
       negatable = true)
   private Boolean aiEnabled;
 
@@ -93,7 +93,7 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       label = "i18n::HopAiConfigOptionPlugin.DefaultProvider.Label",
       toolTip = "i18n::HopAiConfigOptionPlugin.DefaultProvider.Tooltip",
       groupType = GuiWidgetGroupType.BOXES,
-      group = "AI Advisory")
+      group = "i18n::HopAiConfigOptionPlugin.Group")
   @CommandLine.Option(
       names = {"--hop-ai-default-provider"},
       description = "Default AI provider metadata name")
@@ -108,13 +108,46 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       label = "i18n::HopAiConfigOptionPlugin.AllowFullXml.Label",
       toolTip = "i18n::HopAiConfigOptionPlugin.AllowFullXml.Tooltip",
       groupType = GuiWidgetGroupType.BOXES,
-      group = "AI Advisory")
+      group = "i18n::HopAiConfigOptionPlugin.Group")
   @CommandLine.Option(
       names = {"--hop-ai-allow-full-xml"},
-      description = "Allow advisors to send full pipeline or workflow XML",
+      description = "Allow the AI Assistant to send full pipeline or workflow XML",
       negatable = true)
   private Boolean allowSendFullXml;
 
+  @GuiWidgetElement(
+      id = "0310-ai-keep-conversations",
+      order = "0310",
+      parentId = PARENT,
+      type = GuiElementType.CHECKBOX,
+      variables = false,
+      label = "i18n::HopAiConfigOptionPlugin.KeepConversations.Label",
+      toolTip = "i18n::HopAiConfigOptionPlugin.KeepConversations.Tooltip",
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "i18n::HopAiConfigOptionPlugin.Group")
+  @CommandLine.Option(
+      names = {"--hop-ai-keep-conversations"},
+      description = "Keep AI Assistant conversations when Hop GUI closes",
+      negatable = true)
+  private Boolean keepConversations;
+
+  @GuiWidgetElement(
+      id = "0320-ai-request-on-close",
+      order = "0320",
+      parentId = PARENT,
+      type = GuiElementType.COMBO,
+      variables = false,
+      label = "i18n::HopAiConfigOptionPlugin.RequestOnClose.Label",
+      toolTip = "i18n::HopAiConfigOptionPlugin.RequestOnClose.Tooltip",
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "i18n::HopAiConfigOptionPlugin.Group")
+  @CommandLine.Option(
+      names = {"--hop-ai-request-on-close"},
+      description =
+          "What happens to a running AI Assistant question when its window closes:"
+              + " FINISH_IN_BACKGROUND or CANCEL")
+  private AiRequestOnClose requestOnClose;
+
   @GuiWidgetElement(
       id = "0400-ai-extra-context",
       order = "0400",
@@ -125,7 +158,7 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       label = "i18n::HopAiConfigOptionPlugin.ExtraContext.Label",
       toolTip = "i18n::HopAiConfigOptionPlugin.ExtraContext.Tooltip",
       groupType = GuiWidgetGroupType.BOXES,
-      group = "AI Advisory")
+      group = "i18n::HopAiConfigOptionPlugin.Group")
   private String extraContext;
 
   @GuiWidgetElement(
@@ -138,7 +171,7 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       label = "i18n::HopAiConfigOptionPlugin.ExtraContextFiles.Label",
       toolTip = "i18n::HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip",
       groupType = GuiWidgetGroupType.BOXES,
-      group = "AI Advisory")
+      group = "i18n::HopAiConfigOptionPlugin.Group")
   private String extraContextFiles;
 
   public static HopAiConfigOptionPlugin getInstance() {
@@ -155,6 +188,8 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
     instance.aiEnabled = config.isAiEnabled();
     instance.defaultProviderName = config.getDefaultProviderName();
     instance.allowSendFullXml = config.isAllowSendFullXml();
+    instance.keepConversations = config.isKeepConversations();
+    instance.requestOnClose = config.getRequestOnClose();
     instance.extraContext = config.getExtraContext();
     instance.extraContextFiles = config.getExtraContextFiles();
     return instance;
@@ -172,7 +207,7 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
       boolean changed = false;
       if (aiEnabled != null && config.isAiEnabled() != aiEnabled) {
         config.setAiEnabled(aiEnabled);
-        log.logBasic(aiEnabled ? "Enabled Hop AI advisory" : "Disabled Hop AI advisory");
+        log.logBasic(aiEnabled ? "Enabled the Hop AI Assistant" : "Disabled the Hop AI Assistant");
         changed = true;
       }
       if (defaultProviderName != null
@@ -184,6 +219,14 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
         config.setAllowSendFullXml(allowSendFullXml);
         changed = true;
       }
+      if (keepConversations != null && config.isKeepConversations() != keepConversations) {
+        config.setKeepConversations(keepConversations);
+        changed = true;
+      }
+      if (requestOnClose != null && config.getRequestOnClose() != requestOnClose) {
+        config.setRequestOnClose(requestOnClose);
+        changed = true;
+      }
       if (changed) {
         HopAiConfigSingleton.saveConfig();
       }
@@ -222,6 +265,12 @@ public class HopAiConfigOptionPlugin implements IConfigOptions, IGuiPluginCompos
     if (allowSendFullXml != null) {
       config.setAllowSendFullXml(allowSendFullXml);
     }
+    if (keepConversations != null) {
+      config.setKeepConversations(keepConversations);
+    }
+    if (requestOnClose != null) {
+      config.setRequestOnClose(requestOnClose);
+    }
     if (extraContext != null) {
       config.setExtraContext(extraContext);
     }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigSingleton.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigSingleton.java
index 486aebb256..567e0417e1 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigSingleton.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/config/HopAiConfigSingleton.java
@@ -96,6 +96,8 @@ public class HopAiConfigSingleton {
       raw.put("aiEnabled", config.isAiEnabled());
       raw.put("defaultProviderName", config.getDefaultProviderName());
       raw.put("allowSendFullXml", config.isAllowSendFullXml());
+      raw.put("keepConversations", config.isKeepConversations());
+      raw.put("requestOnClose", config.getRequestOnClose().getCode());
       raw.put("extraContext", config.getExtraContext());
       raw.put("extraContextFiles", config.getExtraContextFiles());
     }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiActionPluginSupport.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiActionPluginSupport.java
index 1e0cdae702..c4e96982ad 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiActionPluginSupport.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiActionPluginSupport.java
@@ -20,12 +20,15 @@ package org.apache.hop.ai.engine;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.ActionPluginType;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.workflow.action.ActionMeta;
 import org.apache.hop.workflow.action.IAction;
 
 /** Loads workflow action plugin metadata with plugin defaults for AI proposal application. */
 public final class AiActionPluginSupport {
 
+  private static final Class<?> PKG = AiActionPluginSupport.class;
+
   private AiActionPluginSupport() {}
 
   public static ActionMeta newActionMeta(String pluginId, String name) throws HopException {
@@ -36,7 +39,8 @@ public final class AiActionPluginSupport {
       actionMeta.setName(name);
       return actionMeta;
     } catch (Exception e) {
-      throw new HopException("Unable to load workflow action plugin: " + pluginId, e);
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiActionPluginSupport.CannotLoad", pluginId), e);
     }
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java
index 89a732afda..91fec28c7a 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java
@@ -20,6 +20,9 @@ package org.apache.hop.ai.engine;
 import dev.langchain4j.data.message.AiMessage;
 import dev.langchain4j.data.message.ChatMessage;
 import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.exception.InvalidRequestException;
+import dev.langchain4j.exception.JsonException;
+import dev.langchain4j.exception.UnsupportedFeatureException;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -27,20 +30,35 @@ import java.util.Map;
 import org.apache.hop.ai.advisor.AiAdvisorPrompt;
 import org.apache.hop.ai.advisor.AiAdvisorRequest;
 import org.apache.hop.ai.advisor.AiAdvisorResponse;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.ai.advisor.AiProposalValidation;
 import org.apache.hop.ai.advisor.IAiAdvisor;
+import org.apache.hop.ai.advisors.pipeline.PipelineAiAdvisor;
+import org.apache.hop.ai.advisors.pipeline.PipelineAiProposalApplier;
+import org.apache.hop.ai.advisors.workflow.WorkflowAiAdvisor;
+import org.apache.hop.ai.advisors.workflow.WorkflowAiProposalApplier;
 import org.apache.hop.ai.config.HopAiConfig;
 import org.apache.hop.ai.config.HopAiConfigSingleton;
 import org.apache.hop.ai.metadata.AiProvider;
 import org.apache.hop.ai.session.AiAdvisorSession;
 import org.apache.hop.ai.session.AiAdvisorTurn;
+import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.workflow.WorkflowMeta;
 
 /** Runs one advisory turn: advisor prompt → {@link AiChatFactory} → parsed response. */
 public final class AiAdvisorEngine {
 
+  private static final Class<?> PKG = AiAdvisorEngine.class;
+
   private AiAdvisorEngine() {}
 
   public static AiAdvisorResponse advise(
@@ -53,8 +71,10 @@ public final class AiAdvisorEngine {
   }
 
   /**
-   * @param logExcerpt execution log captured on the UI thread before this method runs in the
-   *     background. When non-null it is used as-is so SWT log widgets are not touched here.
+   * Prepare and send in one go, for callers without a UI thread (tests, the evaluation).
+   *
+   * @param logExcerpt execution log captured on the UI thread, or null to read it from the
+   *     session's log supplier
    */
   public static AiAdvisorResponse advise(
       AiAdvisorSession session,
@@ -63,28 +83,80 @@ public final class AiAdvisorEngine {
       IHopMetadataProvider metadataProvider,
       String logExcerpt)
       throws HopException {
+    Prepared prepared = prepare(session, advisor, variables, metadataProvider, logExcerpt);
+    AiAdvisorResponse response = execute(session, advisor, variables, prepared);
+    session.removePendingAppliedSummaries(prepared.request().getAppliedChangeSummaries());
+    return response;
+  }
+
+  /** What {@link #prepare} built: everything the model call needs, taken from the open file. */
+  public record Prepared(
+      AiProvider provider,
+      AiAdvisorPrompt prompt,
+      List<ChatMessage> history,
+      AiAdvisorRequest request) {
+
+    /** Rough size of what is sent: instructions, history and the question with its context. */
+    public int estimatedTokens() {
+      int tokens =
+          estimateTokens(prompt.getSystemPrompt()) + estimateTokens(prompt.getUserPrompt());
+      for (ChatMessage message : history) {
+        tokens += estimateTokens(messageText(message));
+      }
+      return tokens;
+    }
+  }
+
+  /**
+   * Build the question on the UI thread. It reads the open pipeline or workflow (XML, check
+   * results, settings), which the user may be editing; doing that from a background thread can fail
+   * half way or send a mix of old and new.
+   *
+   * @param logExcerpt execution log captured on the UI thread. When non-null it is used as-is so
+   *     SWT log widgets are not touched here.
+   */
+  public static Prepared prepare(
+      AiAdvisorSession session,
+      IAiAdvisor advisor,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider,
+      String logExcerpt)
+      throws HopException {
     HopAiConfig config = HopAiConfigSingleton.getConfig();
     if (!config.isAiEnabled()) {
-      throw new HopException(
-          "AI advisory is disabled. Enable it under Configuration → Plugins → AI Assistant.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiAdvisorEngine.Disabled"));
     }
     if (advisor == null) {
-      throw new HopException("Select an AI advisor for this session.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiAdvisorEngine.NoAdvisor"));
     }
     AiProvider provider = loadProvider(session, metadataProvider);
     AiAdvisorRequest request = toRequest(session, variables, metadataProvider, logExcerpt);
-
-    if (session.isCancelled()) {
-      throw new HopException("AI request was cancelled");
-    }
     AiAdvisorPrompt prompt = advisor.buildPrompt(request);
     AiAdvisorExtraContext.apply(prompt, advisor, variables);
+    // The answer is checked in the background against a copy, not the file the user may edit.
+    request.setArtifact(snapshot(request.getArtifact(), metadataProvider, variables));
     List<ChatMessage> history = historyFrom(session);
-    AiChatResult chat =
-        AiChatFactory.generateResult(
-            provider, variables, prompt.getSystemPrompt(), prompt.getUserPrompt(), history);
+    checkPromptFits(
+        provider.getName(),
+        AiProviderSettings.contextBudget(provider, variables),
+        AiProviderSettings.maxOutputTokens(provider, variables),
+        prompt.getSystemPrompt(),
+        prompt.getUserPrompt(),
+        history);
+    return new Prepared(provider, prompt, history, request);
+  }
+
+  /** Send a prepared question and read the answer. Runs in the background. */
+  public static AiAdvisorResponse execute(
+      AiAdvisorSession session, IAiAdvisor advisor, IVariables variables, Prepared prepared)
+      throws HopException {
+    if (session.isCancelled()) {
+      throw new HopException(BaseMessages.getString(PKG, "AiAdvisorEngine.Cancelled"));
+    }
+    AiAdvisorPrompt prompt = prepared.prompt();
+    AiChatResult chat = ask(session, advisor, prepared, variables);
     if (session.isCancelled() || Thread.currentThread().isInterrupted()) {
-      throw new HopException("AI request was cancelled");
+      throw new HopException(BaseMessages.getString(PKG, "AiAdvisorEngine.Cancelled"));
     }
     AiAdvisorResponse parsed = advisor.parseResponse(chat.getText());
     if (parsed == null) {
@@ -94,9 +166,282 @@ public final class AiAdvisorEngine {
     parsed.setInputTokenCount(chat.getInputTokenCount());
     parsed.setOutputTokenCount(chat.getOutputTokenCount());
     parsed.setDurationMs(chat.getDurationMs());
+    // Parameters written as "key: value" text instead of in the block.
+    AiProposalTextRecovery.fill(parsed, chat.getText());
+    if (parsed.getProposalParseError() == null
+        && (parsed.getProposals() == null || parsed.getProposals().isEmpty())
+        && mentionsProposals(chat.getText())) {
+      // The answer talks about proposals or names proposal types, but holds none: small models
+      // sometimes describe the change and leave the block out or empty.
+      parsed.setProposalParseError(BaseMessages.getString(PKG, "AiAdvisorEngine.ProposalsMissing"));
+    }
+    if (parsed.getProposalParseError() == null && usesHopProposalSchema(advisor)) {
+      // Proposals outside the schema (an unknown type, a made-up risk level, no parameters).
+      parsed.setProposalParseError(AiProposalSchema.check(parsed.getProposals()));
+    }
+    if (parsed.getProposalParseError() == null) {
+      // Proposals that would be blocked in the review: tell the model why, once.
+      String blocked = blockedReasons(advisor, prepared.request(), parsed.getProposals());
+      if (blocked != null) {
+        parsed.setProposalParseError(blocked);
+      }
+    }
+    if (parsed.getProposalParseError() != null && !session.isCancelled()) {
+      List<AiProposal> before =
+          parsed.getProposals() == null ? List.of() : new ArrayList<>(parsed.getProposals());
+      repairProposals(
+          prepared.provider(), variables, advisor, prompt, prepared.history(), chat, parsed);
+      if (!withdrawn(parsed)
+          && !before.isEmpty()
+          && blockedCount(advisor, prepared.request(), before)
+              < blockedCount(advisor, prepared.request(), parsed.getProposals())) {
+        // Keep whichever set the review can apply more of.
+        parsed.setProposals(new ArrayList<>(before));
+        parsed.setProposalBlockPresent(true);
+      }
+      if (parsed.getProposalParseError() != null
+          && parsed.getProposals() != null
+          && !parsed.getProposals().isEmpty()) {
+        // The review shows what is still wrong per proposal; no need to repeat it in the turn.
+        parsed.setProposalParseError(null);
+      }
+    }
     return parsed;
   }
 
+  /**
+   * Send the question. With Structured answers on the provider, the answer is held to {@link
+   * AiProposalSchema} where the provider type allows it, and turned back into the usual text. When
+   * the provider refuses the schema, the question is asked again without it.
+   */
+  static AiChatResult ask(
+      AiAdvisorSession session, IAiAdvisor advisor, Prepared prepared, IVariables variables)
+      throws HopException {
+    AiProvider provider = prepared.provider();
+    AiAdvisorPrompt prompt = prepared.prompt();
+    if (provider.isStructuredAnswers() && usesHopProposalSchema(advisor)) {
+      try {
+        AiChatResult structured =
+            AiChatFactory.generateStructured(
+                provider,
+                variables,
+                structuredSystemPrompt(prompt.getSystemPrompt()),
+                prompt.getUserPrompt(),
+                prepared.history(),
+                AiProposalSchema.schema());
+        if (structured != null) {
+          return new AiChatResult(
+              AiProposalSchema.toAnswerText(structured.getText()),
+              structured.getInputTokenCount(),
+              structured.getOutputTokenCount(),
+              structured.getDurationMs());
+        }
+      } catch (HopException e) {
+        // Only when the provider refused the schema is asking without it any use. A timeout, a
+        // rejected key or a rate limit would fail again, after the same wait and cost.
+        if (session.isCancelled() || Thread.currentThread().isInterrupted() || !schemaRefused(e)) {
+          throw e;
+        }
+        LogChannel.GENERAL.logBasic(
+            BaseMessages.getString(
+                PKG, "AiAdvisorEngine.StructuredFailed", provider.getName(), e.getMessage()));
+      }
+    }
+    return AiChatFactory.generateResult(
+        provider, variables, prompt.getSystemPrompt(), prompt.getUserPrompt(), prepared.history());
+  }
+
+  /**
+   * Whether a failed structured request failed on the request itself: the provider or model does
+   * not take the schema (HTTP 400, an unsupported feature), or the answer was not the JSON asked
+   * for.
+   */
+  static boolean schemaRefused(Throwable error) {
+    for (Throwable cause = error; cause != null; cause = cause.getCause()) {
+      if (cause instanceof InvalidRequestException
+          || cause instanceof UnsupportedFeatureException
+          || cause instanceof JsonException) {
+        return true;
+      }
+      if (cause.getCause() == cause) {
+        break;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Whether the advisor's proposals are the Hop pipeline and workflow types of {@link
+   * AiProposalSchema}. Advisors of other plugins have types of their own (CREATE_HUB, …), which the
+   * schema would reject.
+   */
+  static boolean usesHopProposalSchema(IAiAdvisor advisor) {
+    return advisor instanceof PipelineAiAdvisor || advisor instanceof WorkflowAiAdvisor;
+  }
+
+  /** The instructions with the answer format of {@link AiProposalSchema} added. */
+  static String structuredSystemPrompt(String systemPrompt) throws HopException {
+    return systemPrompt
+        + "\n\n"
+        + AiPromptLoader.load(AiM2PromptSupport.PROMPT_ROOT, "structured-answer.txt");
+  }
+
+  /**
+   * A copy of the open pipeline or workflow for the background checks. When it cannot be copied (a
+   * plugin is missing) the checks read the original: they only read, and a failure there only skips
+   * the repair. Artifacts of other advisors are passed on as they are.
+   */
+  static Object snapshot(Object artifact, IHopMetadataProvider provider, IVariables variables) {
+    Object copy = null;
+    if (artifact instanceof PipelineMeta pipelineMeta) {
+      copy = PipelineAiProposalApplier.copyForDryRun(pipelineMeta, provider);
+    } else if (artifact instanceof WorkflowMeta workflowMeta) {
+      copy =
+          WorkflowAiProposalApplier.copyForDryRun(
+              workflowMeta,
+              provider,
+              variables != null ? variables : Variables.getADefaultVariableSpace());
+    }
+    return copy != null ? copy : artifact;
+  }
+
+  static int blockedCount(
+      IAiAdvisor advisor, AiAdvisorRequest request, List<AiProposal> proposals) {
+    if (request == null
+        || request.getArtifact() == null
+        || proposals == null
+        || proposals.isEmpty()) {
+      return Integer.MAX_VALUE;
+    }
+    try {
+      int blocked = 0;
+      for (AiProposalValidation validation : advisor.validateProposals(request, proposals)) {
+        if (validation != null && validation.isBlocked()) {
+          blocked++;
+        }
+      }
+      // Nothing to apply is worse than anything to apply.
+      return blocked == proposals.size() ? Integer.MAX_VALUE - 1 : blocked;
+    } catch (RuntimeException e) {
+      return Integer.MAX_VALUE;
+    }
+  }
+
+  /**
+   * Why proposals would be blocked in the review, one line each, or null when none would be. Only
+   * reads the open file; a failure to check is not a reason to bother the model.
+   */
+  static String blockedReasons(
+      IAiAdvisor advisor, AiAdvisorRequest request, List<AiProposal> proposals) {
+    if (request == null
+        || request.getArtifact() == null
+        || proposals == null
+        || proposals.isEmpty()) {
+      return null;
+    }
+    try {
+      List<AiProposalValidation> validations = advisor.validateProposals(request, proposals);
+      StringBuilder reasons = new StringBuilder();
+      for (int i = 0; i < validations.size() && i < proposals.size(); i++) {
+        AiProposalValidation validation = validations.get(i);
+        if (validation != null && validation.isBlocked()) {
+          reasons
+              .append("\n- proposal ")
+              .append(i + 1)
+              .append(" (")
+              .append(proposals.get(i).getType())
+              .append("): ")
+              .append(validation.getReason());
+        }
+      }
+      return reasons.isEmpty()
+          ? null
+          : BaseMessages.getString(PKG, "AiAdvisorEngine.ProposalsBlocked") + reasons;
+    } catch (RuntimeException e) {
+      return null;
+    }
+  }
+
+  /**
+   * Ask once for a corrected {@code hop_proposals} block when the first one could not be read.
+   * Small models get the JSON wrong more often than the advice. The advice text of the first answer
+   * is kept; only the proposals come from the repair. When the repair fails as well, the first
+   * error stays on the response so the user sees why there is nothing to review.
+   */
+  static void repairProposals(
+      AiProvider provider,
+      IVariables variables,
+      IAiAdvisor advisor,
+      AiAdvisorPrompt prompt,
+      List<ChatMessage> history,
+      AiChatResult first,
+      AiAdvisorResponse parsed) {
+    List<ChatMessage> conversation = new ArrayList<>(history);
+    conversation.add(new UserMessage(prompt.getUserPrompt()));
+    conversation.add(new AiMessage(first.getText()));
+    String instruction =
+        parsed.getProposalParseError()
+            + " Reply with only the corrected ```hop_proposals block: a JSON object with a"
+            + " \"proposals\" array, as described in the proposal schema. Every proposal needs"
+            + " its \"parameters\" object, and names must be the exact names of existing"
+            + " transforms or actions, or of ones added in the same block. No other text. If you"
+            + " did not mean to propose a change, reply with {\"proposals\": []} in that block.";
+    try {
+      AiChatResult repair =
+          AiChatFactory.generateResult(
+              provider, variables, prompt.getSystemPrompt(), instruction, conversation, true);
+      AiAdvisorResponse repaired = advisor.parseResponse(fenced(repair.getText()));
+      if (repaired != null
+          && repaired.isProposalBlockPresent()
+          && repaired.getProposalParseError() == null) {
+        // Either the proposals, or an empty list: the model confirms there is no change.
+        AiProposalTextRecovery.fill(repaired, repair.getText());
+        parsed.setProposals(
+            repaired.getProposals() != null ? repaired.getProposals() : new ArrayList<>());
+        parsed.setProposalParseError(null);
+        parsed.setProposalBlockPresent(!parsed.getProposals().isEmpty());
+      }
+      parsed.setInputTokenCount(sum(parsed.getInputTokenCount(), repair.getInputTokenCount()));
+      parsed.setOutputTokenCount(sum(parsed.getOutputTokenCount(), repair.getOutputTokenCount()));
+      parsed.setDurationMs(sum(parsed.getDurationMs(), repair.getDurationMs()));
+    } catch (HopException e) {
+      // Keep the first answer and its parse error.
+    }
+  }
+
+  /**
+   * Whether the repair answered with an empty list: the model confirms it meant no change, so the
+   * proposals of the first answer are withdrawn, not kept as the better set.
+   */
+  static boolean withdrawn(AiAdvisorResponse repaired) {
+    return repaired.getProposalParseError() == null
+        && (repaired.getProposals() == null || repaired.getProposals().isEmpty());
+  }
+
+  /** In JSON-only mode the reply is the bare object; give it the fence the parser looks for. */
+  static String fenced(String text) {
+    if (text == null) {
+      return "";
+    }
+    String trimmed = text.trim();
+    if (trimmed.startsWith("{") && !trimmed.contains("```")) {
+      return "```hop_proposals\n" + trimmed + "\n```";
+    }
+    return text;
+  }
+
+  private static Integer sum(Integer a, Integer b) {
+    return a == null ? b : b == null ? a : Integer.valueOf(a + b);
+  }
+
+  private static Long sum(Long a, Long b) {
+    return a == null ? b : b == null ? a : Long.valueOf(a + b);
+  }
+
+  /**
+   * The applied-change summaries are copied, not taken: they stay on the session until the answer
+   * to this question is recorded, so a question that fails or is cancelled sends them again.
+   */
   static AiAdvisorRequest toRequest(
       AiAdvisorSession session,
       IVariables variables,
@@ -128,7 +473,7 @@ public final class AiAdvisorEngine {
     request.setAttributes(copyAttributes(session.getAttributes()));
     request.setInclusionSelections(copyInclusionSelections(session.getInclusionSelections()));
     request.setFollowUp(hasSuccessfulPriorTurn(session));
-    request.setAppliedChangeSummaries(session.consumePendingAppliedSummaries());
+    request.setAppliedChangeSummaries(List.copyOf(session.getPendingAppliedSummaries()));
     if (logExcerpt != null) {
       request.setLogExcerpt(logExcerpt);
     } else if (session.getLogSupplier() != null) {
@@ -161,19 +506,192 @@ public final class AiAdvisorEngine {
       name = HopAiConfigSingleton.getConfig().getDefaultProviderName();
     }
     if (Utils.isEmpty(name)) {
-      throw new HopException(
-          "No AI provider is selected. Create one under Metadata → AI Provider.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiAdvisorEngine.NoProvider"));
+    }
+    AiProvider provider;
+    try {
+      provider = metadataProvider.getSerializer(AiProvider.class).load(name);
+    } catch (HopException e) {
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiAdvisorEngine.ProviderNotLoaded", name), e);
     }
-    AiProvider provider = metadataProvider.getSerializer(AiProvider.class).load(name);
     if (provider == null) {
-      throw new HopException("AI provider '" + name + "' was not found.");
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiAdvisorEngine.ProviderNotFound", name));
     }
     return provider;
   }
 
+  private static final java.util.regex.Pattern PROPOSAL_MENTION =
+      java.util.regex.Pattern.compile(
+          "\\b(ADD_TRANSFORM|ADD_ACTION|ADD_PIPELINE_HOP|ADD_WORKFLOW_HOP|CONFIGURE_TRANSFORM"
+              + "|CONFIGURE_ACTION|SAVE_METADATA|DELETE_TRANSFORM|DELETE_ACTION|RENAME_TRANSFORM"
+              + "|RENAME_ACTION|REPLACE_TRANSFORM|REPLACE_ACTION|hop_proposals"
+              // Parameters written out as a list instead of a block.
+              + "|transformPluginId|actionPluginId|fromTransform|toTransform|fromAction|toAction)\\b");
+
+  static boolean mentionsProposals(String text) {
+    return text != null && PROPOSAL_MENTION.matcher(text).find();
+  }
+
+  /**
+   * An earlier answer as the model should remember it: its prose and a clean, valid block of the
+   * proposals as they were read. Not the original text: a small model copies its own earlier
+   * answers, so a broken block there gets repeated in every follow-up.
+   */
+  static String answerForHistory(AiAdvisorTurn turn) {
+    String advice = Const.NVL(turn.getAssistantAdvice(), "");
+    if (turn.getProposals() == null || turn.getProposals().isEmpty()) {
+      return advice;
+    }
+    List<Map<String, Object>> proposals = new ArrayList<>();
+    for (AiProposal proposal : turn.getProposals()) {
+      Map<String, Object> entry = new LinkedHashMap<>();
+      entry.put("id", Const.NVL(proposal.getId(), Integer.toString(proposals.size() + 1)));
+      entry.put("description", Const.NVL(proposal.getDescription(), ""));
+      entry.put("riskLevel", Const.NVL(proposal.getRiskLevel(), "LOW"));
+      entry.put("type", proposal.getType());
+      entry.put("parameters", proposal.getParameters());
+      proposals.add(entry);
+    }
+    try {
+      return advice
+          + "\n\n```hop_proposals\n"
+          + HopJson.newMapper().writeValueAsString(Map.of("proposals", proposals))
+          + "\n```";
+    } catch (Exception e) {
+      return advice;
+    }
+  }
+
+  /** Roughly four characters per token for English text and code; JSON packs a little tighter. */
+  static final int CHARS_PER_TOKEN = 4;
+
+  /** Kept free for the answer when the provider sets no output limit. */
+  static final int DEFAULT_ANSWER_RESERVE = 2_048;
+
+  static int estimateTokens(String text) {
+    return text == null ? 0 : (text.length() + CHARS_PER_TOKEN - 1) / CHARS_PER_TOKEN;
+  }
+
+  /**
+   * Stop before sending a prompt that clearly does not fit the provider's context window. Ollama
+   * would drop the start of it without a word, which loses the instructions and the context, and
+   * hosted providers reject it. Only clear overflows are stopped: the estimate is rough.
+   *
+   * @param contextSize the window in tokens; see {@link AiProviderSettings#contextBudget}
+   */
+  static void checkPromptFits(
+      String providerName,
+      int contextSize,
+      Integer maxOutputTokens,
+      String systemPrompt,
+      String userPrompt,
+      List<ChatMessage> history)
+      throws HopException {
+    int tokens = estimateTokens(systemPrompt) + estimateTokens(userPrompt);
+    for (ChatMessage message : history) {
+      if (message instanceof UserMessage user && user.hasSingleText()) {
+        tokens += estimateTokens(user.singleText());
+      } else if (message instanceof AiMessage ai) {
+        tokens += estimateTokens(ai.text());
+      }
+    }
+    int reserve =
+        maxOutputTokens != null
+            ? maxOutputTokens
+            : Math.min(DEFAULT_ANSWER_RESERVE, contextSize / 4);
+    if (tokens + reserve > contextSize) {
+      throw new AiUserException(
+          BaseMessages.getString(
+              PKG,
+              "AiAdvisorEngine.PromptTooLarge",
+              Integer.toString(tokens),
+              providerName,
+              Integer.toString(contextSize),
+              Integer.toString(reserve)));
+    }
+  }
+
+  /**
+   * Exactly what the next question would send, for the user to read before sending it: the
+   * instructions, how much conversation history goes along, and the question with its context.
+   *
+   * @param question the text in the question field; a placeholder is used when it is empty
+   */
+  public static String preview(
+      AiAdvisorSession session,
+      IAiAdvisor advisor,
+      IVariables variables,
+      IHopMetadataProvider metadataProvider,
+      String logExcerpt,
+      String question)
+      throws HopException {
+    if (advisor == null) {
+      throw new HopException(BaseMessages.getString(PKG, "AiAdvisorEngine.NoAdvisor"));
+    }
+    AiAdvisorRequest request = toRequest(session, variables, metadataProvider, logExcerpt);
+    request.setUserPrompt(Utils.isEmpty(question) ? "(your question)" : question.trim());
+    request.setFollowUp(hasAnswer(session.getTurns(), session.getTurns().size()));
+    AiAdvisorPrompt prompt = advisor.buildPrompt(request);
+    AiAdvisorExtraContext.apply(prompt, advisor, variables);
+    if (usesHopProposalSchema(advisor) && usesStructuredAnswers(session, metadataProvider)) {
+      prompt.setSystemPrompt(structuredSystemPrompt(prompt.getSystemPrompt()));
+    }
+    List<ChatMessage> history = historyFrom(session.getTurns(), session.getTurns().size());
+    int historyTokens = 0;
+    for (ChatMessage message : history) {
+      historyTokens += estimateTokens(messageText(message));
+    }
+    int total =
+        estimateTokens(prompt.getSystemPrompt())
+            + estimateTokens(prompt.getUserPrompt())
+            + historyTokens;
+    return "About "
+        + total
+        + " tokens in total.\n\n"
+        + "=== Instructions (system message, about "
+        + estimateTokens(prompt.getSystemPrompt())
+        + " tokens) ===\n"
+        + prompt.getSystemPrompt()
+        + "\n\n=== Conversation history: "
+        + history.size() / 2
+        + " earlier question(s) and answer(s), about "
+        + historyTokens
+        + " tokens ===\n\n"
+        + "=== Your question with its context (about "
+        + estimateTokens(prompt.getUserPrompt())
+        + " tokens) ===\n"
+        + prompt.getUserPrompt();
+  }
+
+  /** For the preview: whether the session's provider asks for structured answers. */
+  private static boolean usesStructuredAnswers(
+      AiAdvisorSession session, IHopMetadataProvider metadataProvider) {
+    try {
+      return loadProvider(session, metadataProvider).isStructuredAnswers();
+    } catch (HopException e) {
+      return false;
+    }
+  }
+
+  private static String messageText(ChatMessage message) {
+    if (message instanceof UserMessage user && user.hasSingleText()) {
+      return user.singleText();
+    }
+    if (message instanceof AiMessage ai) {
+      return ai.text();
+    }
+    return "";
+  }
+
   static boolean hasSuccessfulPriorTurn(AiAdvisorSession session) {
-    List<AiAdvisorTurn> turns = session.getTurns();
-    for (int i = 0; i < turns.size() - 1; i++) {
+    return hasAnswer(session.getTurns(), session.getTurns().size() - 1);
+  }
+
+  /** Whether one of the first {@code count} turns has an answer. */
+  private static boolean hasAnswer(List<AiAdvisorTurn> turns, int count) {
+    for (int i = 0; i < count && i < turns.size(); i++) {
       if (!Utils.isEmpty(turns.get(i).getAssistantAdvice())) {
         return true;
       }
@@ -182,17 +700,21 @@ public final class AiAdvisorEngine {
   }
 
   static List<ChatMessage> historyFrom(AiAdvisorSession session) {
-    List<AiAdvisorTurn> turns = session.getTurns();
-    if (turns.size() <= 1) {
+    return historyFrom(session.getTurns(), session.getTurns().size() - 1);
+  }
+
+  /** The last answered turns before turn {@code end}, as chat messages. */
+  static List<ChatMessage> historyFrom(List<AiAdvisorTurn> turns, int end) {
+    if (end <= 0) {
       return List.of();
     }
-    int from = Math.max(0, turns.size() - 1 - AiAdvisorSession.MAX_HISTORY_TURNS);
+    int from = Math.max(0, end - AiAdvisorSession.MAX_HISTORY_TURNS);
     List<ChatMessage> history = new ArrayList<>();
-    for (int i = from; i < turns.size() - 1; i++) {
+    for (int i = from; i < end; i++) {
       AiAdvisorTurn turn = turns.get(i);
       if (!Utils.isEmpty(turn.getUserPrompt()) && !Utils.isEmpty(turn.getAssistantAdvice())) {
         history.add(new UserMessage(turn.getUserPrompt()));
-        history.add(new AiMessage(turn.getAssistantAdvice()));
+        history.add(new AiMessage(answerForHistory(turn)));
       }
     }
     return history;
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorMetadataContext.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorMetadataContext.java
index 838f7b3e57..69ffb41618 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorMetadataContext.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorMetadataContext.java
@@ -63,10 +63,10 @@ public final class AiAdvisorMetadataContext {
         || request.getMetadataSelections().isEmpty()) {
       return;
     }
-    prompt
-        .append("Selected metadata JSON:\n")
-        .append(serialize(request.getMetadataProvider(), request.getMetadataSelections()))
-        .append("\n\n");
+    AiTextUtil.appendSection(
+        prompt,
+        "selected_metadata",
+        serialize(request.getMetadataProvider(), request.getMetadataSelections()));
   }
 
   @SuppressWarnings("unchecked")
@@ -167,10 +167,7 @@ public final class AiAdvisorMetadataContext {
     if (prompt == null || metadataProvider == null) {
       return;
     }
-    prompt
-        .append("Available metadata types JSON:\n")
-        .append(serializeTypeKeys(metadataProvider))
-        .append("\n\n");
+    AiTextUtil.appendSection(prompt, "metadata_types", serializeTypeKeys(metadataProvider));
   }
 
   /**
@@ -207,16 +204,8 @@ public final class AiAdvisorMetadataContext {
     if (prompt == null) {
       return;
     }
-    prompt
-        .append("Available database plugins JSON:\n")
-        .append(serializeDatabaseCatalog())
-        .append("\n\n");
-    prompt.append(
-        "SAVE_METADATA for a relational connection: typeKey rdbms, json shape "
-            + "{\"name\":\"my-db\",\"rdbms\":{\"POSTGRESQL\":{\"pluginId\":\"POSTGRESQL\","
-            + "\"accessType\":0,\"hostname\":\"localhost\",\"port\":\"5432\","
-            + "\"databaseName\":\"db\",\"username\":\"user\",\"password\":\"${DB_PASSWORD}\"}}}."
-            + " The rdbms key must be the catalog id (POSTGRESQL, MYSQL, …), not a flat object.\n\n");
+    // The rdbms JSON shape that SAVE_METADATA needs is in the proposal rules of the system prompt.
+    AiTextUtil.appendSection(prompt, "database_plugins", serializeDatabaseCatalog());
   }
 
   public static List<TypeCatalog> listTypes(IHopMetadataProvider metadataProvider) {
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java
index c4903ef21d..625cadabd0 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatFactory.java
@@ -20,6 +20,12 @@ package org.apache.hop.ai.engine;
 import dev.langchain4j.data.message.ChatMessage;
 import dev.langchain4j.data.message.SystemMessage;
 import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.model.chat.Capability;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.chat.request.ChatRequest;
+import dev.langchain4j.model.chat.request.ResponseFormat;
+import dev.langchain4j.model.chat.request.ResponseFormatType;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
 import dev.langchain4j.model.chat.response.ChatResponse;
 import dev.langchain4j.model.output.TokenUsage;
 import java.util.ArrayList;
@@ -28,9 +34,11 @@ import java.util.concurrent.TimeUnit;
 import org.apache.hop.ai.metadata.AiModelRole;
 import org.apache.hop.ai.metadata.AiProvider;
 import org.apache.hop.ai.provider.IAiProvider;
+import org.apache.hop.ai.providers.OpenAiProvider;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.transforms.languagemodelchat.LanguageModelChatMeta;
 import org.apache.hop.pipeline.transforms.languagemodelchat.internals.LanguageModelFacade;
@@ -42,16 +50,21 @@ public final class AiChatFactory {
       "You are a health-check endpoint. Reply with exactly OK and nothing else.";
   private static final String HEALTH_USER = "health";
 
+  private static final Class<?> PKG = AiChatFactory.class;
+
   private AiChatFactory() {}
 
+  static String missingTypeMessage(AiProvider provider) {
+    return BaseMessages.getString(PKG, "AiChatFactory.MissingType", provider.getName());
+  }
+
   public static LanguageModelChatMeta toLanguageModelChatMeta(
       AiProvider provider, IVariables variables) throws HopException {
     if (provider == null) {
-      throw new HopException("AI provider metadata is missing");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiChatFactory.NoProvider"));
     }
-    IAiProvider backend = provider.getProvider();
-    if (backend == null) {
-      throw new HopException("AI provider type is not set on '" + provider.getName() + "'");
+    if (!provider.hasProviderType()) {
+      throw new AiUserException(missingTypeMessage(provider));
     }
     LanguageModelChatMeta meta = new LanguageModelChatMeta();
     meta.setDefault();
@@ -71,19 +84,20 @@ public final class AiChatFactory {
       IHopMetadataProvider metadataProvider)
       throws HopException {
     if (source == null) {
-      throw new HopException("Language Model Chat metadata is missing");
+      throw new HopException(BaseMessages.getString(PKG, "AiChatFactory.NoChatMeta"));
     }
     if (providerName == null || providerName.isBlank()) {
       return source;
     }
     if (metadataProvider == null) {
       throw new HopException(
-          "No metadata provider is available to load AI Provider '" + providerName + "'");
+          BaseMessages.getString(PKG, "AiChatFactory.NoMetadataProvider", providerName));
     }
     String name = resolve(variables, providerName);
     AiProvider provider = metadataProvider.getSerializer(AiProvider.class).load(name);
     if (provider == null) {
-      throw new HopException("AI provider '" + name + "' was not found.");
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiChatFactory.ProviderNotFound", name));
     }
     LanguageModelChatMeta copy = (LanguageModelChatMeta) source.clone();
     applyConnection(copy, provider, variables, false);
@@ -101,10 +115,10 @@ public final class AiChatFactory {
       IVariables variables,
       boolean useProviderDefaults)
       throws HopException {
-    IAiProvider backend = provider.getProvider();
-    if (backend == null) {
-      throw new HopException("AI provider type is not set on '" + provider.getName() + "'");
+    if (!provider.hasProviderType()) {
+      throw new AiUserException(missingTypeMessage(provider));
     }
+    IAiProvider backend = provider.getProvider();
     meta.setModelType(backend.getHopModelType());
 
     String baseUrl = resolve(variables, provider.getBaseUrl());
@@ -119,6 +133,8 @@ public final class AiChatFactory {
     boolean hasTemperature = !Utils.isEmpty(provider.getTemperature());
     double temperature = parseTemperature(resolve(variables, provider.getTemperature()));
     Integer timeout = parseTimeout(resolve(variables, provider.getTimeoutSeconds()));
+    Integer contextSize = AiProviderSettings.contextSize(provider, variables);
+    Integer maxOutputTokens = AiProviderSettings.maxOutputTokens(provider, variables);
     boolean applyTemperature = useProviderDefaults || hasTemperature;
 
     String hopType = backend.getHopModelType();
@@ -138,6 +154,9 @@ public final class AiChatFactory {
       if (timeout != null) {
         meta.setAnthropicTimeout(timeout);
       }
+      if (maxOutputTokens != null) {
+        meta.setAnthropicMaxTokens(maxOutputTokens);
+      }
     } else if ("OLLAMA".equals(hopType)) {
       if (!Utils.isEmpty(baseUrl)) {
         meta.setOllamaImageEndpoint(baseUrl);
@@ -151,6 +170,12 @@ public final class AiChatFactory {
       if (timeout != null) {
         meta.setOllamaTimeout(timeout);
       }
+      if (contextSize != null) {
+        meta.setOllamaNumCtx(contextSize);
+      }
+      if (maxOutputTokens != null) {
+        meta.setOllamaNumPredict(maxOutputTokens);
+      }
     } else if ("MISTRAL".equals(hopType)) {
       if (!Utils.isEmpty(baseUrl)) {
         meta.setMistralBaseUrl(baseUrl);
@@ -167,6 +192,9 @@ public final class AiChatFactory {
       if (timeout != null) {
         meta.setMistralTimeout(timeout);
       }
+      if (maxOutputTokens != null) {
+        meta.setMistralMaxTokens(maxOutputTokens);
+      }
     } else if ("HUGGING_FACE".equals(hopType)) {
       if (!Utils.isEmpty(apiKey)) {
         meta.setHuggingFaceAccessToken(apiKey);
@@ -183,6 +211,9 @@ public final class AiChatFactory {
       if (timeout != null) {
         meta.setHuggingFaceTimeout(timeout);
       }
+      if (maxOutputTokens != null) {
+        meta.setHuggingFaceMaxNewTokens(maxOutputTokens);
+      }
     } else {
       if (!Utils.isEmpty(baseUrl)) {
         meta.setOpenAiBaseUrl(baseUrl);
@@ -199,6 +230,9 @@ public final class AiChatFactory {
       if (timeout != null) {
         meta.setOpenAiTimeout(timeout);
       }
+      if (maxOutputTokens != null) {
+        meta.setOpenAiMaxTokens(maxOutputTokens);
+      }
     }
   }
 
@@ -220,8 +254,33 @@ public final class AiChatFactory {
       String userPrompt,
       List<ChatMessage> conversationHistory)
       throws HopException {
+    return generateResult(
+        provider, variables, systemPrompt, userPrompt, conversationHistory, false);
+  }
+
+  /**
+   * @param jsonOnly ask the provider to answer with valid JSON only, where it supports that (Ollama
+   *     and OpenAI). Small models write broken JSON in free text far more often.
+   */
+  public static AiChatResult generateResult(
+      AiProvider provider,
+      IVariables variables,
+      String systemPrompt,
+      String userPrompt,
+      List<ChatMessage> conversationHistory,
+      boolean jsonOnly)
+      throws HopException {
     validate(provider);
     LanguageModelChatMeta meta = toLanguageModelChatMeta(provider, variables);
+    if (jsonOnly) {
+      if ("OLLAMA".equals(provider.getHopModelType())) {
+        meta.setOllamaFormat("json");
+      } else if ("MISTRAL".equals(provider.getHopModelType())) {
+        meta.setMistralResponseFormat("json_object");
+      } else if (provider.getProvider() instanceof OpenAiProvider) {
+        meta.setOpenAiResponseFormat("json_object");
+      }
+    }
     LanguageModelFacade facade = new LanguageModelFacade(variables, meta);
     List<ChatMessage> messages = new ArrayList<>();
     messages.add(new SystemMessage(systemPrompt));
@@ -242,10 +301,68 @@ public final class AiChatFactory {
           positive(usage != null ? usage.outputTokenCount() : null),
           durationMs);
     } catch (Exception e) {
-      throw new HopException("AI request failed: " + e.getMessage(), e);
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiChatFactory.RequestFailed", e.getMessage()), e);
     } catch (Error e) {
       // ServiceConfigurationError (langchain4j SPI) is an Error, not an Exception.
-      throw new HopException("AI request failed: " + e.getMessage(), e);
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiChatFactory.RequestFailed", e.getMessage()), e);
+    }
+  }
+
+  /**
+   * Ask with the answer held to a JSON schema. The model is built by {@link AiChatModelFactory},
+   * which knows which provider types accept a schema.
+   *
+   * @return the answer, or null when this provider type cannot hold a model to a schema; the caller
+   *     then asks without one
+   */
+  public static AiChatResult generateStructured(
+      AiProvider provider,
+      IVariables variables,
+      String systemPrompt,
+      String userPrompt,
+      List<ChatMessage> conversationHistory,
+      JsonSchema schema)
+      throws HopException {
+    validate(provider);
+    ChatModel model;
+    try {
+      model = AiChatModelFactory.createChatModel(provider, "", variables);
+    } catch (HopException e) {
+      // A provider type the factory cannot drive, such as Hugging Face.
+      return null;
+    }
+    if (!model.supportedCapabilities().contains(Capability.RESPONSE_FORMAT_JSON_SCHEMA)) {
+      return null;
+    }
+    List<ChatMessage> messages = new ArrayList<>();
+    messages.add(new SystemMessage(systemPrompt));
+    if (conversationHistory != null) {
+      messages.addAll(conversationHistory);
+    }
+    messages.add(new UserMessage(userPrompt));
+    ChatRequest request =
+        ChatRequest.builder()
+            .messages(messages)
+            .responseFormat(
+                ResponseFormat.builder().type(ResponseFormatType.JSON).jsonSchema(schema).build())
+            .build();
+    long started = System.nanoTime();
+    try {
+      ChatResponse response = model.chat(request);
+      long durationMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
+      String text =
+          response != null && response.aiMessage() != null ? response.aiMessage().text() : "";
+      TokenUsage usage = response != null ? response.tokenUsage() : null;
+      return new AiChatResult(
+          text == null ? "" : text,
+          positive(usage != null ? usage.inputTokenCount() : null),
+          positive(usage != null ? usage.outputTokenCount() : null),
+          durationMs);
+    } catch (Exception | Error e) {
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiChatFactory.RequestFailed", e.getMessage()), e);
     }
   }
 
@@ -259,34 +376,31 @@ public final class AiChatFactory {
   public static String healthCheck(AiProvider provider, IVariables variables) throws HopException {
     String response = generate(provider, variables, HEALTH_SYSTEM, HEALTH_USER, null);
     if (Utils.isEmpty(response)) {
-      throw new HopException("The AI provider returned an empty response.");
+      throw new HopException(BaseMessages.getString(PKG, "AiChatFactory.EmptyResponse"));
     }
-    String model =
-        Utils.isEmpty(provider.getModelName())
-            ? provider.getProvider().getDefaultModelName()
-            : provider.getModelName();
-    return "Connected to "
-        + provider.getPluginName()
-        + " (model: "
-        + model
-        + "). Response: "
-        + response.trim();
+    // The model that answered: a CHAT row in Models per role replaces Model name.
+    String model = resolve(variables, provider.resolveModelName(AiModelRole.CHAT));
+    if (Utils.isEmpty(model)) {
+      model = provider.getProvider().getDefaultModelName();
+    }
+    return BaseMessages.getString(
+        PKG, "AiChatFactory.Connected", provider.getPluginName(), model, response.trim());
   }
 
   public static void validate(AiProvider provider) throws HopException {
     if (provider == null) {
-      throw new HopException("AI provider metadata is missing");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiChatFactory.NoProvider"));
     }
     if (!AiLanguageModelAvailability.isAvailable()) {
-      throw new HopException(
-          "The Hop Language Model Chat plugin is not installed. Add hop-transform-languagemodelchat to your Hop assembly.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiChatFactory.NoChatPlugin"));
     }
-    IAiProvider backend = provider.getProvider();
-    if (backend == null) {
-      throw new HopException("Please select an AI provider type.");
+    if (!provider.hasProviderType()) {
+      throw new AiUserException(missingTypeMessage(provider));
     }
+    IAiProvider backend = provider.getProvider();
     if (backend.requiresApiKey() && Utils.isEmpty(provider.getApiKey())) {
-      throw new HopException("Please configure an API key for the AI provider.");
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiChatFactory.NoApiKey", provider.getName()));
     }
   }
 
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java
index 1eaae0fb19..3ffdcb1fdb 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiChatModelFactory.java
@@ -16,8 +16,10 @@
  */
 package org.apache.hop.ai.engine;
 
+import dev.langchain4j.model.anthropic.AnthropicChatModel;
 import dev.langchain4j.model.chat.Capability;
 import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.model.mistralai.MistralAiChatModel;
 import dev.langchain4j.model.ollama.OllamaChatModel;
 import dev.langchain4j.model.openai.OpenAiChatModel;
 import org.apache.hop.ai.metadata.AiModelRole;
@@ -26,6 +28,7 @@ import org.apache.hop.ai.providers.OpenAiProvider;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 
 /**
@@ -38,6 +41,8 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
  */
 public final class AiChatModelFactory {
 
+  private static final Class<?> PKG = AiChatModelFactory.class;
+
   private AiChatModelFactory() {}
 
   /**
@@ -69,21 +74,18 @@ public final class AiChatModelFactory {
       model = settings.backend().getDefaultModelName();
     }
     if (Utils.isEmpty(model)) {
-      throw new HopException(
-          "No chat model is configured. Set one on this transform, or on AI provider '"
-              + provider.getName()
-              + "'.");
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiChatModelFactory.NoModel", provider.getName()));
     }
 
     return switch (settings.type()) {
       case "OLLAMA" -> ollamaModel(settings, model);
       case "OPEN_AI" -> openAiModel(settings, model);
+      case "ANTHROPIC" -> anthropicModel(settings, model);
+      case "MISTRAL" -> mistralModel(settings, model);
       default ->
           throw new HopException(
-              "Provider type '"
-                  + settings.type()
-                  + "' cannot be driven directly yet. Use an Ollama or OpenAI compatible"
-                  + " provider.");
+              BaseMessages.getString(PKG, "AiChatModelFactory.UnsupportedType", settings.type()));
     };
   }
 
@@ -97,9 +99,9 @@ public final class AiChatModelFactory {
 
   /*
    * supportedCapabilities() is not a probe of the provider: langchain4j reports back only what the
-   * builder was given. Ollama and OpenAI itself declare JSON schema support. Other OpenAI
-   * compatible endpoints (Gemini, Grok, custom servers) differ in which schema keywords they accept,
-   * so they declare nothing and get the fields in the prompt instead.
+   * builder was given. Ollama, OpenAI itself, Anthropic and Mistral declare JSON schema support.
+   * Other OpenAI compatible endpoints (Gemini, Grok, custom servers) differ in which schema
+   * keywords they accept, so they declare nothing and get the fields in the prompt instead.
    */
   private static ChatModel ollamaModel(AiProviderSettings settings, String model) {
     OllamaChatModel.OllamaChatModelBuilder builder =
@@ -113,6 +115,56 @@ public final class AiChatModelFactory {
     if (settings.temperature() != null) {
       builder.temperature(settings.temperature());
     }
+    if (settings.contextSize() != null) {
+      builder.numCtx(settings.contextSize());
+    }
+    if (settings.maxOutputTokens() != null) {
+      builder.numPredict(settings.maxOutputTokens());
+    }
+    return builder.build();
+  }
+
+  /** Anthropic holds the model to a schema with its structured outputs. It requires a limit. */
+  private static ChatModel anthropicModel(AiProviderSettings settings, String model) {
+    AnthropicChatModel.AnthropicChatModelBuilder builder =
+        AnthropicChatModel.builder()
+            .modelName(model)
+            .apiKey(settings.apiKey())
+            .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA)
+            .maxTokens(
+                settings.maxOutputTokens() != null
+                    ? settings.maxOutputTokens()
+                    : AiProviderSettings.DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS);
+    if (!Utils.isEmpty(settings.baseUrl())) {
+      builder.baseUrl(settings.baseUrl());
+    }
+    if (settings.timeout() != null) {
+      builder.timeout(settings.timeout());
+    }
+    if (settings.temperature() != null) {
+      builder.temperature(settings.temperature());
+    }
+    return builder.build();
+  }
+
+  private static ChatModel mistralModel(AiProviderSettings settings, String model) {
+    MistralAiChatModel.MistralAiChatModelBuilder builder =
+        MistralAiChatModel.builder()
+            .modelName(model)
+            .apiKey(settings.apiKey())
+            .supportedCapabilities(Capability.RESPONSE_FORMAT_JSON_SCHEMA);
+    if (!Utils.isEmpty(settings.baseUrl())) {
+      builder.baseUrl(settings.baseUrl());
+    }
+    if (settings.timeout() != null) {
+      builder.timeout(settings.timeout());
+    }
+    if (settings.temperature() != null) {
+      builder.temperature(settings.temperature());
+    }
+    if (settings.maxOutputTokens() != null) {
+      builder.maxTokens(settings.maxOutputTokens());
+    }
     return builder.build();
   }
 
@@ -135,6 +187,9 @@ public final class AiChatModelFactory {
     if (settings.temperature() != null) {
       builder.temperature(settings.temperature());
     }
+    if (settings.maxOutputTokens() != null) {
+      builder.maxTokens(settings.maxOutputTokens());
+    }
     return builder.build();
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiM2PromptSupport.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiM2PromptSupport.java
index d7baed4931..6d7231e8ba 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiM2PromptSupport.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiM2PromptSupport.java
@@ -18,6 +18,7 @@
 package org.apache.hop.ai.engine;
 
 import java.util.List;
+import java.util.TreeSet;
 import org.apache.hop.ai.advisor.AiAdvisorRequest;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.Utils;
@@ -34,19 +35,33 @@ public final class AiM2PromptSupport {
   public static String buildSupplement() throws HopException {
     return AiPromptLoader.load(PROMPT_ROOT, "preamble-m2.txt")
         + "\n\n"
-        + AiPromptLoader.load(PROMPT_ROOT, "hop-proposals-schema.txt");
+        + AiPromptLoader.load(PROMPT_ROOT, "hop-proposals-schema.txt")
+        + "\n"
+        + savableMetadata();
+  }
+
+  /**
+   * Which metadata a proposal can save, from the list the review enforces, so the model does not
+   * propose a run configuration or a server only to see it blocked, or offers something else.
+   */
+  static String savableMetadata() throws HopException {
+    return AiPromptLoader.load(PROMPT_ROOT, "savable-metadata.txt")
+        .replace(
+            "{types}",
+            String.join(", ", new TreeSet<>(AiMetadataProposalSupport.SAVABLE_TYPE_KEYS)));
   }
 
   public static void appendAppliedSummaries(StringBuilder prompt, List<String> summaries) {
     if (summaries == null || summaries.isEmpty()) {
       return;
     }
-    prompt.append("User applied these graph changes since the previous turn:\n");
+    StringBuilder applied = new StringBuilder();
     for (String summary : summaries) {
       if (!Utils.isEmpty(summary)) {
-        prompt.append("- ").append(summary).append('\n');
+        applied.append("- ").append(summary).append('\n');
       }
     }
-    prompt.append('\n');
+    // The user applied these proposals to the graph since the previous question.
+    AiTextUtil.appendSection(prompt, "applied_changes", applied.toString());
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/IAiAdvisorWorkbenchHost.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataBackup.java
similarity index 56%
copy from plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/IAiAdvisorWorkbenchHost.java
copy to plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataBackup.java
index 46c971476c..052174fd48 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/IAiAdvisorWorkbenchHost.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataBackup.java
@@ -14,29 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.apache.hop.ai.engine;
 
-package org.apache.hop.ai.ui;
-
-import org.apache.hop.core.variables.IVariables;
-import org.apache.hop.metadata.api.IHopMetadataProvider;
-import org.apache.hop.ui.hopgui.HopGui;
-import org.eclipse.swt.widgets.Display;
-import org.eclipse.swt.widgets.Shell;
-
-/** Services {@link AiAdvisorWorkbench} needs from a perspective, dialog, or dock tab. */
-public interface IAiAdvisorWorkbenchHost {
-
-  HopGui getHopGui();
-
-  Shell getShell();
-
-  Display getDisplay();
-
-  IVariables getVariables();
-
-  IHopMetadataProvider getMetadataProvider();
-
-  void activate();
+/**
+ * What a {@code SAVE_METADATA} proposal replaced, so the change can be undone.
+ *
+ * @param typeKey the metadata type key
+ * @param name the object name
+ * @param previousJson the object as it was before the save, or null when the save created it
+ * @param savedJson the object as the save wrote it, to tell whether it was changed since; null when
+ *     not known (a backup kept by an earlier version)
+ */
+public record AiMetadataBackup(String typeKey, String name, String previousJson, String savedJson) {
 
-  void asyncExec(Runnable runnable);
+  public boolean created() {
+    return previousJson == null;
+  }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataProposalSupport.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataProposalSupport.java
index 0642c57850..b3145eed80 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataProposalSupport.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiMetadataProposalSupport.java
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.core.JsonParser;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -37,16 +38,42 @@ import org.apache.hop.core.json.HopJson;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.IHopMetadata;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.api.IHopMetadataSerializer;
 import org.apache.hop.metadata.serializer.json.JsonMetadataParser;
+import org.apache.hop.metadata.util.HopMetadataUtil;
 
 /** Validates and saves CLIPBOARD_METADATA / SAVE_METADATA hop_proposals. */
 public final class AiMetadataProposalSupport {
 
+  private static final Class<?> PKG = AiMetadataProposalSupport.class;
+
   public static final int MAX_JSON_CHARS = 100_000;
 
+  /**
+   * The metadata types a proposal may save: connections and data definitions a user typically asks
+   * the assistant to create. Everything else, such as run configurations, servers, AI providers,
+   * variable resolvers, logging, web services, VFS and Git connections, can redirect where prompts,
+   * secrets, files, logs or execution go. The user creates or changes those in the Metadata
+   * perspective. Matched on the type's own key, so a legacy key in a proposal does not get around
+   * it.
+   */
+  static final Set<String> SAVABLE_TYPE_KEYS =
+      Set.of(
+          "rdbms",
+          "cassandra-connection",
+          "mongodb-connection",
+          "neo4j-connection",
+          "neo4j-graph-model",
+          "dataset",
+          "unit-test",
+          "file-definition",
+          "schema-definition",
+          "partition");
+
   private AiMetadataProposalSupport() {}
 
   public static AiProposalValidation validate(
@@ -60,19 +87,64 @@ public final class AiMetadataProposalSupport {
       return result;
     }
     if (AiProposalXmlSupport.containsSecrets(jsonParam(proposal))) {
-      result.setWarning("JSON contains password-like fields");
+      result.setWarning(BaseMessages.getString(PKG, "AiMetadataProposalSupport.Warning.Secrets"));
     } else if (AiProposalTypes.of(proposal) == AiProposalTypes.CLIPBOARD_METADATA) {
-      result.setWarning("Copies JSON to the clipboard");
+      result.setWarning(BaseMessages.getString(PKG, "AiMetadataProposalSupport.Warning.Clipboard"));
     } else {
-      String name = Const.NVL(proposal.parameter("name"), "");
-      String typeKey = Const.NVL(proposal.parameter("typeKey"), "");
-      result.setWarning(
-          "Saves metadata object " + (name.isEmpty() ? typeKey : name + " (" + typeKey + ")"));
+      String name = Const.NVL(targetName(proposal, metadataProvider), "");
+      String typeKey = Const.NVL(firstParameter(proposal, "typeKey", "metadataType"), "");
+      if (exists(proposal, metadataProvider)) {
+        result.setWarning(
+            BaseMessages.getString(
+                PKG, "AiMetadataProposalSupport.Warning.Overwrite", typeKey, name));
+        result.setOptIn(true);
+      } else if (name.isEmpty()) {
+        result.setWarning(
+            BaseMessages.getString(PKG, "AiMetadataProposalSupport.Warning.Save", typeKey));
+      } else {
+        result.setWarning(
+            BaseMessages.getString(
+                PKG, "AiMetadataProposalSupport.Warning.SaveNamed", name, typeKey));
+      }
     }
     return result;
   }
 
-  public static void save(AiProposal proposal, IHopMetadataProvider metadataProvider)
+  /** The name the object will be saved under, or null when it cannot be determined. */
+  public static String targetName(AiProposal proposal, IHopMetadataProvider metadataProvider) {
+    String name = firstParameter(proposal, "name");
+    if (!Utils.isEmpty(name)) {
+      return name;
+    }
+    try {
+      String typeKey = firstParameter(proposal, "typeKey", "metadataType");
+      Class<IHopMetadata> metadataClass = metadataProvider.getMetadataClassForKey(typeKey);
+      return parseObject(metadataClass, metadataProvider, jsonParam(proposal)).getName();
+    } catch (Exception e) {
+      return null;
+    }
+  }
+
+  /** Whether a SAVE_METADATA proposal would replace an existing object. */
+  public static boolean exists(AiProposal proposal, IHopMetadataProvider metadataProvider) {
+    if (AiProposalTypes.of(proposal) != AiProposalTypes.SAVE_METADATA || metadataProvider == null) {
+      return false;
+    }
+    String name = targetName(proposal, metadataProvider);
+    if (Utils.isEmpty(name)) {
+      return false;
+    }
+    try {
+      Class<IHopMetadata> metadataClass =
+          metadataProvider.getMetadataClassForKey(
+              firstParameter(proposal, "typeKey", "metadataType"));
+      return metadataProvider.getSerializer(metadataClass).exists(name);
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  public static AiMetadataBackup save(AiProposal proposal, IHopMetadataProvider metadataProvider)
       throws Exception {
     String error = validateError(proposal, metadataProvider);
     if (error != null) {
@@ -89,22 +161,124 @@ public final class AiMetadataProposalSupport {
       throw new HopException("Metadata name is required");
     }
     IHopMetadataSerializer<IHopMetadata> serializer = metadataProvider.getSerializer(metadataClass);
+    String previousJson = null;
+    if (serializer.exists(object.getName())) {
+      previousJson = json(metadataClass, metadataProvider, serializer.load(object.getName()));
+    }
     serializer.save(object);
+    String savedJson = json(metadataClass, metadataProvider, serializer.load(object.getName()));
+    return new AiMetadataBackup(typeKey, object.getName(), previousJson, savedJson);
   }
 
-  public static int saveAll(List<AiProposal> selected, IHopMetadataProvider provider)
+  private static String json(
+      Class<IHopMetadata> metadataClass, IHopMetadataProvider provider, IHopMetadata object)
       throws Exception {
-    int saved = 0;
-    if (selected == null || provider == null) {
-      return 0;
+    return object == null
+        ? null
+        : new JsonMetadataParser<>(metadataClass, provider).getJsonObject(object).toJSONString();
+  }
+
+  /**
+   * The objects an undo would overwrite or delete although they were changed after the assistant
+   * saved them, or deleted since. Their names, as type and name, for the user to confirm.
+   */
+  public static List<String> changedSinceSave(
+      List<AiMetadataBackup> backups, IHopMetadataProvider metadataProvider) {
+    List<String> changed = new ArrayList<>();
+    for (AiMetadataBackup backup : backups) {
+      try {
+        Class<IHopMetadata> metadataClass =
+            metadataProvider.getMetadataClassForKey(backup.typeKey());
+        IHopMetadataSerializer<IHopMetadata> serializer =
+            metadataProvider.getSerializer(metadataClass);
+        String current =
+            serializer.exists(backup.name())
+                ? json(metadataClass, metadataProvider, serializer.load(backup.name()))
+                : null;
+        if (backup.savedJson() == null || !backup.savedJson().equals(current)) {
+          changed.add(backup.typeKey() + " " + backup.name());
+        }
+      } catch (Exception e) {
+        changed.add(backup.typeKey() + " " + backup.name());
+      }
+    }
+    return changed;
+  }
+
+  /**
+   * Check every SAVE_METADATA proposal before anything is applied, so a bad one does not leave the
+   * graph changed and the metadata half saved.
+   */
+  public static void checkAll(List<AiProposal> selected, IHopMetadataProvider provider)
+      throws HopException {
+    if (selected == null) {
+      return;
     }
     for (AiProposal proposal : selected) {
       if (AiProposalTypes.of(proposal) == AiProposalTypes.SAVE_METADATA) {
-        save(proposal, provider);
-        saved++;
+        String error = validateError(proposal, provider);
+        if (error != null) {
+          throw new HopException(error);
+        }
+      }
+    }
+  }
+
+  /** Restore what the saves replaced and delete what they created, newest first. */
+  public static void revert(List<AiMetadataBackup> backups, IHopMetadataProvider metadataProvider)
+      throws Exception {
+    for (int i = backups.size() - 1; i >= 0; i--) {
+      AiMetadataBackup backup = backups.get(i);
+      Class<IHopMetadata> metadataClass = metadataProvider.getMetadataClassForKey(backup.typeKey());
+      IHopMetadataSerializer<IHopMetadata> serializer =
+          metadataProvider.getSerializer(metadataClass);
+      if (backup.created()) {
+        if (serializer.exists(backup.name())) {
+          serializer.delete(backup.name());
+        }
+      } else {
+        serializer.save(parseObject(metadataClass, metadataProvider, backup.previousJson()));
+      }
+    }
+  }
+
+  /**
+   * Save every SAVE_METADATA proposal, or none: when one save fails, the ones before it are undone.
+   *
+   * @return what each save replaced, in save order, for {@link #revert(List, IHopMetadataProvider)}
+   */
+  public static List<AiMetadataBackup> saveAll(
+      List<AiProposal> selected, IHopMetadataProvider provider) throws Exception {
+    List<AiMetadataBackup> backups = new ArrayList<>();
+    if (selected == null || provider == null) {
+      return backups;
+    }
+    try {
+      for (AiProposal proposal : selected) {
+        if (AiProposalTypes.of(proposal) == AiProposalTypes.SAVE_METADATA) {
+          backups.add(save(proposal, provider));
+        }
+      }
+    } catch (Exception e) {
+      HopException failure =
+          new HopException(BaseMessages.getString(PKG, "AiMetadataProposalSupport.SaveFailed"), e);
+      try {
+        revert(backups, provider);
+      } catch (Exception revertError) {
+        failure.addSuppressed(revertError);
       }
+      throw failure;
     }
-    return saved;
+    return backups;
+  }
+
+  /** The allowed type keys; tests add their own test type. */
+  static Set<String> savableTypeKeys = SAVABLE_TYPE_KEYS;
+
+  /** Whether a proposal may save objects of this type; see {@link #SAVABLE_TYPE_KEYS}. */
+  static boolean isSavable(Class<? extends IHopMetadata> metadataClass) {
+    HopMetadata annotation = HopMetadataUtil.getHopMetadataAnnotation(metadataClass);
+    return annotation != null && savableTypeKeys.contains(annotation.key());
   }
 
   static String validateError(AiProposal proposal, IHopMetadataProvider metadataProvider) {
@@ -133,6 +307,10 @@ public final class AiMetadataProposalSupport {
     }
     try {
       Class<IHopMetadata> metadataClass = metadataProvider.getMetadataClassForKey(typeKey);
+      if (AiProposalTypes.of(proposal) == AiProposalTypes.SAVE_METADATA
+          && !isSavable(metadataClass)) {
+        return BaseMessages.getString(PKG, "AiMetadataProposalSupport.NotSavable", typeKey);
+      }
       parseObject(metadataClass, metadataProvider, json);
     } catch (Exception e) {
       return "Invalid metadata JSON: "
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiModelCatalog.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiModelCatalog.java
index 73148594cd..eb164f8f9b 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiModelCatalog.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiModelCatalog.java
@@ -42,6 +42,7 @@ import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.json.HopJson;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 
 /**
  * Live model-id lists for {@link AiProvider}. Uses langchain4j {@code ModelCatalog} where it exists
@@ -52,18 +53,19 @@ public final class AiModelCatalog {
 
   public static final int MAX_MODELS = 500;
 
+  private static final Class<?> PKG = AiModelCatalog.class;
+
   private AiModelCatalog() {}
 
   public static List<String> listModelNames(AiProvider provider, IVariables variables)
       throws HopException {
-    if (provider == null || provider.getProvider() == null) {
-      throw new HopException("Please select an AI provider type.");
+    if (provider == null || !provider.hasProviderType()) {
+      throw new AiUserException(BaseMessages.getString(PKG, "AiModelCatalog.NoType"));
     }
     IAiProvider backend = provider.getProvider();
     String hopType = Const.NVL(backend.getHopModelType(), "OPEN_AI");
     if ("HUGGING_FACE".equals(hopType)) {
-      throw new HopException(
-          "Hugging Face does not expose a model catalog. Enter a router model id or a dedicated endpoint URL.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiModelCatalog.HuggingFace"));
     }
     String baseUrl = resolve(variables, provider.getBaseUrl());
     if (Utils.isEmpty(baseUrl)) {
@@ -71,7 +73,7 @@ public final class AiModelCatalog {
     }
     String apiKey = resolve(variables, provider.getApiKey());
     if (backend.requiresApiKey() && Utils.isEmpty(apiKey)) {
-      throw new HopException("Set an API key (a variable is fine) before listing models.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiModelCatalog.NoApiKey"));
     }
     Duration timeout = timeoutOf(provider.getTimeoutSeconds());
     try {
@@ -95,15 +97,17 @@ public final class AiModelCatalog {
             default -> listOpenAiFamily(baseUrl, apiKey, timeout);
           };
       if (names.isEmpty()) {
-        throw new HopException("The provider returned no models.");
+        throw new AiUserException(BaseMessages.getString(PKG, "AiModelCatalog.NoModels"));
       }
       return names;
     } catch (HopException e) {
       throw e;
     } catch (Exception e) {
       throw new HopException(
-          "Could not list models: "
-              + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()),
+          BaseMessages.getString(
+              PKG,
+              "AiModelCatalog.ListFailed",
+              e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()),
           e);
     }
   }
@@ -162,7 +166,7 @@ public final class AiModelCatalog {
   static List<String> listOpenAiHttp(String baseUrl, String apiKey, Duration timeout)
       throws Exception {
     if (Utils.isEmpty(baseUrl)) {
-      throw new HopException("Base URL is required to list models for this provider.");
+      throw new AiUserException(BaseMessages.getString(PKG, "AiModelCatalog.NoBaseUrl"));
     }
     String root = stripTrailingSlash(baseUrl);
     String body = httpGet(root + "/models", apiKey, timeout);
@@ -215,7 +219,8 @@ public final class AiModelCatalog {
         client.send(builder.build(), HttpResponse.BodyHandlers.ofString());
     int status = response.statusCode();
     if (status < 200 || status >= 300) {
-      throw new HopException("HTTP " + status + " listing models from " + url);
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiModelCatalog.HttpStatus", Integer.toString(status), url));
     }
     return Const.NVL(response.body(), "");
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiNodeSettings.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiNodeSettings.java
new file mode 100644
index 0000000000..f4a9f2dec6
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiNodeSettings.java
@@ -0,0 +1,147 @@
+/*
+ * 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.hop.ai.engine;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.util.ReflectionUtil;
+
+/**
+ * The settings of a transform or action, as compact JSON for a prompt.
+ *
+ * <p>Reads the {@code @HopMetadataProperty} fields: SQL, file names, connection names, field lists
+ * and so on, which is what a model needs to explain what a pipeline or workflow does. Password
+ * fields are skipped. Empty values, long texts and long lists are left out or shortened so one
+ * transform cannot take over the prompt.
+ */
+public final class AiNodeSettings {
+
+  static final int MAX_DEPTH = 3;
+  static final int MAX_LIST_ITEMS = 25;
+  static final int MAX_TEXT_CHARS = 400;
+  static final int MAX_NODE_CHARS = 3_000;
+
+  private static final ObjectMapper MAPPER = new ObjectMapper();
+
+  private AiNodeSettings() {}
+
+  /**
+   * @param node the transform or action meta
+   * @return a JSON object, {@code {"truncated":true}} when the settings are too large, or null when
+   *     the plugin exposes no settings this way
+   */
+  public static String toJson(Object node) {
+    if (node == null) {
+      return null;
+    }
+    Object settings = read(node, 0);
+    if (!(settings instanceof Map<?, ?> map) || map.isEmpty()) {
+      return null;
+    }
+    try {
+      String json = MAPPER.writeValueAsString(settings);
+      if (json.length() > MAX_NODE_CHARS) {
+        return "{\"truncated\":true}";
+      }
+      return AiTextUtil.redactSecrets(json);
+    } catch (JsonProcessingException e) {
+      return null;
+    }
+  }
+
+  static Object read(Object value, int depth) {
+    if (value == null) {
+      return null;
+    }
+    if (value instanceof String text) {
+      if (text.isEmpty()) {
+        return null;
+      }
+      return text.length() > MAX_TEXT_CHARS ? text.substring(0, MAX_TEXT_CHARS) + "…" : text;
+    }
+    if (value instanceof Number || value instanceof Boolean) {
+      return value;
+    }
+    if (value instanceof Enum<?> e) {
+      return e.name();
+    }
+    if (depth >= MAX_DEPTH) {
+      return null;
+    }
+    if (value instanceof Collection<?> collection) {
+      return readList(collection, depth);
+    }
+    if (value.getClass().isArray() && value instanceof Object[] array) {
+      return readList(List.of(array), depth);
+    }
+    return readObject(value, depth);
+  }
+
+  private static List<Object> readList(Collection<?> collection, int depth) {
+    List<Object> list = new ArrayList<>();
+    int count = 0;
+    for (Object item : collection) {
+      if (count == MAX_LIST_ITEMS) {
+        list.add("… " + (collection.size() - MAX_LIST_ITEMS) + " more");
+        break;
+      }
+      Object read = read(item, depth + 1);
+      if (read != null) {
+        list.add(read);
+      }
+      count++;
+    }
+    return list.isEmpty() ? null : list;
+  }
+
+  private static Map<String, Object> readObject(Object object, int depth) {
+    Map<String, Object> map = new LinkedHashMap<>();
+    for (Field field : ReflectionUtil.findAllFields(object.getClass())) {
+      HopMetadataProperty property = field.getAnnotation(HopMetadataProperty.class);
+      if (property == null || property.password()) {
+        continue;
+      }
+      Object fieldValue;
+      try {
+        fieldValue =
+            ReflectionUtil.getFieldValue(object, field.getName(), isBoolean(field.getType()));
+      } catch (Exception e) {
+        continue;
+      }
+      Object read = read(fieldValue, depth + 1);
+      // An option that is off is kept: its default may be on.
+      if (read == null) {
+        continue;
+      }
+      String key = Utils.isEmpty(property.key()) ? field.getName() : property.key();
+      map.put(key, read);
+    }
+    return map.isEmpty() ? null : map;
+  }
+
+  private static boolean isBoolean(Class<?> type) {
+    return type == boolean.class || type == Boolean.class;
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiPluginCatalog.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiPluginCatalog.java
new file mode 100644
index 0000000000..d0566d8b31
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiPluginCatalog.java
@@ -0,0 +1,80 @@
+/*
+ * 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.hop.ai.engine;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.IPluginType;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * The installed transform or action plugins, as compact text for a prompt.
+ *
+ * <p>One line per category with {@code id (name)} pairs. Every plugin is listed, so a proposal can
+ * use any installed plugin id, at roughly half the tokens of one JSON object per plugin.
+ */
+public final class AiPluginCatalog {
+
+  /** A safety net for very large plugin sets; Hop's own plugins take far less. */
+  static final int MAX_CHARS = 40_000;
+
+  private AiPluginCatalog() {}
+
+  public static String compact(Class<? extends IPluginType> pluginType) {
+    return compact(PluginRegistry.getInstance().getPlugins(pluginType));
+  }
+
+  static String compact(List<IPlugin> plugins) {
+    Map<String, List<IPlugin>> byCategory = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+    for (IPlugin plugin : plugins) {
+      if (plugin.getIds() == null || plugin.getIds().length == 0) {
+        continue;
+      }
+      String category = Utils.isEmpty(plugin.getCategory()) ? "Other" : plugin.getCategory();
+      byCategory.computeIfAbsent(category, key -> new ArrayList<>()).add(plugin);
+    }
+    StringBuilder text = new StringBuilder();
+    for (Map.Entry<String, List<IPlugin>> entry : byCategory.entrySet()) {
+      List<IPlugin> inCategory = entry.getValue();
+      inCategory.sort(
+          Comparator.comparing(
+              IPlugin::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
+      StringBuilder line = new StringBuilder(entry.getKey()).append(": ");
+      for (int i = 0; i < inCategory.size(); i++) {
+        IPlugin plugin = inCategory.get(i);
+        if (i > 0) {
+          line.append("; ");
+        }
+        line.append(plugin.getIds()[0]);
+        if (!Utils.isEmpty(plugin.getName()) && !plugin.getName().equals(plugin.getIds()[0])) {
+          line.append(" (").append(plugin.getName()).append(')');
+        }
+      }
+      if (text.length() + line.length() > MAX_CHARS) {
+        text.append("... (more plugins are installed than fit here)\n");
+        break;
+      }
+      text.append(line).append('\n');
+    }
+    return text.toString();
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalNormalizer.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalNormalizer.java
new file mode 100644
index 0000000000..5eea57ea7e
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalNormalizer.java
@@ -0,0 +1,515 @@
+/*
+ * 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.hop.ai.engine;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.plugins.ActionPluginType;
+import org.apache.hop.core.plugins.IPlugin;
+import org.apache.hop.core.plugins.IPluginType;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.workflow.action.ActionMeta;
+
+/**
+ * Repairs the mistakes models make in proposals before they are checked, so the user does not have
+ * to: a workflow type in a pipeline, {@code fromAction} where {@code fromTransform} is meant, a
+ * missing plugin id that the name gives away, or a missing location. Only fixes with one clear
+ * meaning are made; anything else is left for the validator to report.
+ */
+public final class AiProposalNormalizer {
+
+  private static final int STEP_X = 160;
+  private static final int DEFAULT_X = 100;
+  private static final int DEFAULT_Y = 100;
+
+  /** Everything that differs between pipelines and workflows. */
+  private record Kind(
+      Map<String, String> typeFixes,
+      Map<String, String> keyFixes,
+      String addType,
+      String hopType,
+      String pluginIdKey,
+      String fromKey,
+      String toKey,
+      String nodeKey,
+      Set<String> nodeTypes,
+      Class<? extends IPluginType> pluginType,
+      Function<String, Point> locationOf) {}
+
+  private AiProposalNormalizer() {}
+
+  public static void forPipeline(PipelineMeta pipelineMeta, List<AiProposal> proposals) {
+    Map<String, String> types = new HashMap<>();
+    types.put("ADD_ACTION", "ADD_TRANSFORM");
+    types.put("DELETE_ACTION", "DELETE_TRANSFORM");
+    types.put("RENAME_ACTION", "RENAME_TRANSFORM");
+    types.put("ADD_WORKFLOW_HOP", "ADD_PIPELINE_HOP");
+    types.put("DELETE_WORKFLOW_HOP", "DELETE_PIPELINE_HOP");
+    types.put("SET_ACTION_LOCATION", "SET_TRANSFORM_LOCATION");
+    types.put("ADD_WORKFLOW_NOTE", "ADD_PIPELINE_NOTE");
+    types.put("CONFIGURE_ACTION", "CONFIGURE_TRANSFORM");
+    types.put("CLIPBOARD_ACTIONS", "CLIPBOARD_TRANSFORMS");
+    types.put("REPLACE_ACTION", "REPLACE_TRANSFORM");
+    types.put("ADD_HOP", "ADD_PIPELINE_HOP");
+    types.put("DELETE_HOP", "DELETE_PIPELINE_HOP");
+    Map<String, String> keys = new HashMap<>();
+    keys.put("actionPluginId", "transformPluginId");
+    keys.put("actionName", "transformName");
+    keys.put("fromAction", "fromTransform");
+    keys.put("toAction", "toTransform");
+    commonKeys(keys, "transformPluginId", "fromTransform", "toTransform");
+    normalize(
+        proposals,
+        new Kind(
+            types,
+            keys,
+            "ADD_TRANSFORM",
+            "ADD_PIPELINE_HOP",
+            "transformPluginId",
+            "fromTransform",
+            "toTransform",
+            "transformName",
+            Set.of(
+                "DELETE_TRANSFORM",
+                "RENAME_TRANSFORM",
+                "CONFIGURE_TRANSFORM",
+                "SET_TRANSFORM_LOCATION",
+                "REPLACE_TRANSFORM"),
+            TransformPluginType.class,
+            name -> {
+              TransformMeta transform =
+                  pipelineMeta == null ? null : pipelineMeta.findTransform(name);
+              return transform == null ? null : transform.getLocation();
+            }),
+        pipelineMeta == null
+            ? List.of()
+            : pipelineMeta.getTransforms().stream().map(TransformMeta::getLocation).toList(),
+        pipelineMeta == null
+            ? List.of()
+            : pipelineMeta.getTransforms().stream().map(TransformMeta::getName).toList());
+  }
+
+  public static void forWorkflow(WorkflowMeta workflowMeta, List<AiProposal> proposals) {
+    Map<String, String> types = new HashMap<>();
+    types.put("ADD_TRANSFORM", "ADD_ACTION");
+    types.put("DELETE_TRANSFORM", "DELETE_ACTION");
+    types.put("RENAME_TRANSFORM", "RENAME_ACTION");
+    types.put("ADD_PIPELINE_HOP", "ADD_WORKFLOW_HOP");
+    types.put("DELETE_PIPELINE_HOP", "DELETE_WORKFLOW_HOP");
+    types.put("SET_TRANSFORM_LOCATION", "SET_ACTION_LOCATION");
+    types.put("ADD_PIPELINE_NOTE", "ADD_WORKFLOW_NOTE");
+    types.put("CONFIGURE_TRANSFORM", "CONFIGURE_ACTION");
+    types.put("CLIPBOARD_TRANSFORMS", "CLIPBOARD_ACTIONS");
+    types.put("REPLACE_TRANSFORM", "REPLACE_ACTION");
+    types.put("ADD_HOP", "ADD_WORKFLOW_HOP");
+    types.put("DELETE_HOP", "DELETE_WORKFLOW_HOP");
+    Map<String, String> keys = new HashMap<>();
+    keys.put("transformPluginId", "actionPluginId");
+    keys.put("transformName", "actionName");
+    keys.put("fromTransform", "fromAction");
+    keys.put("toTransform", "toAction");
+    commonKeys(keys, "actionPluginId", "fromAction", "toAction");
+    normalize(
+        proposals,
+        new Kind(
+            types,
+            keys,
+            "ADD_ACTION",
+            "ADD_WORKFLOW_HOP",
+            "actionPluginId",
+            "fromAction",
+            "toAction",
+            "actionName",
+            Set.of(
+                "DELETE_ACTION",
+                "RENAME_ACTION",
+                "CONFIGURE_ACTION",
+                "SET_ACTION_LOCATION",
+                "REPLACE_ACTION"),
+            ActionPluginType.class,
+            name -> {
+              ActionMeta action = workflowMeta == null ? null : workflowMeta.findAction(name);
+              return action == null ? null : action.getLocation();
+            }),
+        workflowMeta == null
+            ? List.of()
+            : workflowMeta.getActions().stream().map(ActionMeta::getLocation).toList(),
+        workflowMeta == null
+            ? List.of()
+            : workflowMeta.getActions().stream().map(ActionMeta::getName).toList());
+  }
+
+  /** Short names models use for a plugin id; only read on proposals that add a node. */
+  private static final Set<String> PLUGIN_ALIASES =
+      Set.of("pluginId", "plugin", "pluginType", "transformType", "actionType");
+
+  /**
+   * Short names for the ends of a hop; only read on hop proposals. A transform or action can have a
+   * setting with one of these names, such as the target of the dbt action.
+   */
+  private static final Set<String> HOP_END_ALIASES = Set.of("from", "source", "to", "target");
+
+  private static void commonKeys(
+      Map<String, String> keys, String pluginKey, String from, String to) {
+    for (String alias : PLUGIN_ALIASES) {
+      keys.put(alias, pluginKey);
+    }
+    keys.put("from", from);
+    keys.put("source", from);
+    keys.put("to", to);
+    keys.put("target", to);
+  }
+
+  private static void normalize(
+      List<AiProposal> proposals, Kind kind, List<Point> existing, List<String> existingNames) {
+    if (proposals == null) {
+      return;
+    }
+    int rightMost = DEFAULT_X - STEP_X;
+    int top = DEFAULT_Y;
+    for (Point point : existing) {
+      if (point != null && point.x > rightMost) {
+        rightMost = point.x;
+        top = point.y;
+      }
+    }
+    // First the types and parameter names of every proposal, so the hops are readable when the
+    // transforms or actions they connect are placed.
+    for (AiProposal proposal : proposals) {
+      if (proposal == null || Utils.isEmpty(proposal.getType())) {
+        continue;
+      }
+      String type = proposal.getType().trim().toUpperCase(Locale.ROOT);
+      proposal.setType(kind.typeFixes().getOrDefault(type, type));
+      Map<String, String> parameters = proposal.getParameters();
+      String fixedType = proposal.getType();
+      boolean hop =
+          kind.hopType().equals(fixedType)
+              || kind.hopType().replace("ADD_", "DELETE_").equals(fixedType);
+      boolean add = kind.addType().equals(fixedType);
+      for (Map.Entry<String, String> fix : kind.keyFixes().entrySet()) {
+        if ((HOP_END_ALIASES.contains(fix.getKey()) && !hop)
+            || (PLUGIN_ALIASES.contains(fix.getKey()) && !add)) {
+          continue;
+        }
+        if (parameters.containsKey(fix.getKey()) && Utils.isEmpty(parameters.get(fix.getValue()))) {
+          parameters.put(fix.getValue(), parameters.remove(fix.getKey()));
+        }
+      }
+    }
+    for (AiProposal proposal : proposals) {
+      if (proposal != null && kind.nodeTypes().contains(proposal.getType())) {
+        nameTheNode(proposal, kind.nodeKey(), existingNames);
+      }
+    }
+    Map<String, Point> placed = new HashMap<>();
+    for (AiProposal proposal : proposals) {
+      if (proposal == null || !kind.addType().equals(proposal.getType())) {
+        continue;
+      }
+      Map<String, String> parameters = proposal.getParameters();
+      IPlugin plugin = findPlugin(kind.pluginType(), parameters.get(kind.pluginIdKey()));
+      if (plugin == null) {
+        plugin = guessPlugin(kind.pluginType(), parameters.get("name"), proposal.getDescription());
+        if (plugin != null) {
+          parameters.put(kind.pluginIdKey(), plugin.getIds()[0]);
+          proposal.setDescription(
+              Const.NVL(proposal.getDescription(), "")
+                  + " (plugin "
+                  + plugin.getIds()[0]
+                  + ", found from the name)");
+        }
+      }
+      if (Utils.isEmpty(parameters.get("name")) && plugin != null) {
+        parameters.put("name", plugin.getName());
+      }
+      if (AiProposalParamSupport.parseLocation(proposal).isValid()) {
+        continue;
+      }
+      // Place it right of what it is connected from, else right of everything.
+      Point from = upstreamLocation(proposals, kind, parameters.get("name"), placed);
+      Point location;
+      if (from != null) {
+        location = new Point(from.x + STEP_X, from.y);
+      } else {
+        rightMost += STEP_X;
+        location = new Point(rightMost, top);
+      }
+      rightMost = Math.max(rightMost, location.x);
+      parameters.put("locationX", Integer.toString(location.x));
+      parameters.put("locationY", Integer.toString(location.y));
+      if (!Utils.isEmpty(parameters.get("name"))) {
+        placed.put(parameters.get("name"), location);
+      }
+    }
+    resolveHopEnds(proposals, kind, existingNames);
+  }
+
+  /**
+   * A proposal on an existing transform or action that names it in another way than with {@code
+   * transformName} / {@code actionName}: as {@code name}, as a parameter key without a value (
+   * <code>{"Dummy": ""}</code>), or with the copy number a log line adds ({@code concat.0}). Only
+   * when the node exists.
+   */
+  static void nameTheNode(AiProposal proposal, String nodeKey, List<String> existingNames) {
+    Map<String, String> parameters = proposal.getParameters();
+    String current = parameters.get(nodeKey);
+    if (!Utils.isEmpty(current)) {
+      String existing = existingName(current, existingNames);
+      if (existing != null) {
+        parameters.put(nodeKey, existing);
+      }
+      return;
+    }
+    String byName = existingName(parameters.get("name"), existingNames);
+    if (byName != null) {
+      parameters.remove("name");
+      parameters.put(nodeKey, byName);
+      return;
+    }
+    for (Map.Entry<String, String> parameter : new ArrayList<>(parameters.entrySet())) {
+      String asKey = existingName(parameter.getKey(), existingNames);
+      if (asKey != null && Utils.isEmpty(parameter.getValue())) {
+        parameters.remove(parameter.getKey());
+        parameters.put(nodeKey, asKey);
+        return;
+      }
+    }
+  }
+
+  /**
+   * A hop written as <code>{"concat.0": "", "Dummy": ""}</code>: two parameters without a value
+   * whose keys are nodes are its ends, in that order. Small models write a {"name", "value"} pair
+   * this way.
+   */
+  private static void endsWrittenAsKeys(AiProposal proposal, Kind kind, List<String> names) {
+    Map<String, String> parameters = proposal.getParameters();
+    if (!Utils.isEmpty(parameters.get(kind.fromKey()))
+        || !Utils.isEmpty(parameters.get(kind.toKey()))) {
+      return;
+    }
+    List<String> keys = new ArrayList<>();
+    List<String> ends = new ArrayList<>();
+    for (Map.Entry<String, String> parameter : parameters.entrySet()) {
+      String node = existingName(parameter.getKey(), names);
+      if (node != null && Utils.isEmpty(parameter.getValue())) {
+        keys.add(parameter.getKey());
+        ends.add(node);
+      }
+    }
+    if (ends.size() != 2) {
+      return;
+    }
+    keys.forEach(parameters::remove);
+    parameters.put(kind.fromKey(), ends.get(0));
+    parameters.put(kind.toKey(), ends.get(1));
+  }
+
+  /**
+   * The existing node a name means: the same name, the name without the copy number of a log line
+   * ({@code concat.0}), or the one name that only differs in case and spaces. Null otherwise.
+   */
+  static String existingName(String name, List<String> existingNames) {
+    if (Utils.isEmpty(name)) {
+      return null;
+    }
+    if (existingNames.contains(name)) {
+      return name;
+    }
+    String withoutCopy = name.replaceFirst("\\.\\d+$", "");
+    if (!withoutCopy.equals(name) && existingNames.contains(withoutCopy)) {
+      return withoutCopy;
+    }
+    List<String> similar =
+        existingNames.stream().filter(existing -> simple(existing).equals(simple(name))).toList();
+    return similar.size() == 1 ? similar.get(0) : null;
+  }
+
+  /**
+   * Hops that name a node by its plugin id, or by a slightly different name, point at the node that
+   * is clearly meant: one with that exact name, else the one added here with that plugin id, else
+   * the one whose name only differs in case and spaces. Only when exactly one fits.
+   */
+  private static void resolveHopEnds(
+      List<AiProposal> proposals, Kind kind, List<String> existingNames) {
+    List<String> names = new ArrayList<>(existingNames);
+    Map<String, List<String>> addedByPluginId = new HashMap<>();
+    for (AiProposal proposal : proposals) {
+      if (proposal != null && kind.addType().equals(proposal.getType())) {
+        String name = proposal.parameter("name");
+        if (!Utils.isEmpty(name)) {
+          names.add(name);
+          String pluginId = proposal.parameter(kind.pluginIdKey());
+          if (!Utils.isEmpty(pluginId)) {
+            addedByPluginId.computeIfAbsent(simple(pluginId), key -> new ArrayList<>()).add(name);
+          }
+        }
+      }
+    }
+    // Hops to delete name their ends the same way as hops to add.
+    String deleteHopType = kind.hopType().replace("ADD_", "DELETE_");
+    for (AiProposal proposal : proposals) {
+      if (proposal == null
+          || !(kind.hopType().equals(proposal.getType())
+              || deleteHopType.equals(proposal.getType()))) {
+        continue;
+      }
+      endsWrittenAsKeys(proposal, kind, names);
+      for (String key : List.of(kind.fromKey(), kind.toKey())) {
+        String end = proposal.parameter(key);
+        if (Utils.isEmpty(end) || names.contains(end)) {
+          continue;
+        }
+        String withoutCopy = end.replaceFirst("\\.\\d+$", "");
+        if (!withoutCopy.equals(end) && names.contains(withoutCopy)) {
+          proposal.getParameters().put(key, withoutCopy);
+          continue;
+        }
+        String resolved = null;
+        List<String> byPlugin = addedByPluginId.get(simple(end));
+        if (byPlugin != null && byPlugin.size() == 1) {
+          resolved = byPlugin.get(0);
+        } else {
+          List<String> similar =
+              names.stream().filter(name -> simple(name).equals(simple(end))).toList();
+          if (similar.size() == 1) {
+            resolved = similar.get(0);
+          } else {
+            // An invented name such as "dummy-new": the one node whose name starts like it.
+            String word = firstWord(end);
+            List<String> starting =
+                word.length() < 4
+                    ? List.of()
+                    : names.stream().filter(name -> simple(name).startsWith(word)).toList();
+            if (starting.size() == 1) {
+              resolved = starting.get(0);
+            }
+          }
+        }
+        if (resolved != null) {
+          proposal.getParameters().put(key, resolved);
+        }
+      }
+    }
+  }
+
+  private static Point upstreamLocation(
+      List<AiProposal> proposals, Kind kind, String name, Map<String, Point> placed) {
+    if (Utils.isEmpty(name)) {
+      return null;
+    }
+    for (AiProposal hop : proposals) {
+      if (hop == null || !kind.hopType().equals(hop.getType())) {
+        continue;
+      }
+      if (name.equals(hop.parameter(kind.toKey()))) {
+        String from = hop.parameter(kind.fromKey());
+        if (from == null) {
+          continue;
+        }
+        Point point = placed.get(from);
+        return point != null ? point : kind.locationOf().apply(from);
+      }
+    }
+    return null;
+  }
+
+  static IPlugin findPlugin(Class<? extends IPluginType> type, String id) {
+    if (Utils.isEmpty(id)) {
+      return null;
+    }
+    PluginRegistry registry = PluginRegistry.getInstance();
+    IPlugin plugin = registry.findPluginWithId(type, id.trim());
+    if (plugin != null) {
+      return plugin;
+    }
+    String wanted = simple(id);
+    for (IPlugin candidate : registry.getPlugins(type)) {
+      if (candidate.getIds() != null
+          && candidate.getIds().length > 0
+          && (simple(candidate.getIds()[0]).equals(wanted)
+              || simple(candidate.getName()).equals(wanted))) {
+        return candidate;
+      }
+    }
+    return null;
+  }
+
+  /**
+   * The plugin a name or description points at: the longest plugin id or name found in it, so
+   * "Write to log" finds WriteToLog rather than a shorter plugin whose name happens to be inside.
+   */
+  static IPlugin guessPlugin(Class<? extends IPluginType> type, String... texts) {
+    IPlugin best = null;
+    int bestLength = 0;
+    for (String text : texts) {
+      if (Utils.isEmpty(text)) {
+        continue;
+      }
+      String haystack = simple(text);
+      for (IPlugin plugin : PluginRegistry.getInstance().getPlugins(type)) {
+        if (plugin.getIds() == null || plugin.getIds().length == 0) {
+          continue;
+        }
+        for (String needle : List.of(simple(plugin.getIds()[0]), simple(nameWithoutNote(plugin)))) {
+          if (needle.length() >= 4 && needle.length() > bestLength && haystack.contains(needle)) {
+            best = plugin;
+            bestLength = needle.length();
+          }
+        }
+      }
+      if (best != null) {
+        return best;
+      }
+    }
+    return best;
+  }
+
+  /** "Dummy (do nothing)" is called Dummy. */
+  private static String nameWithoutNote(IPlugin plugin) {
+    String name = Const.NVL(plugin.getName(), "");
+    int bracket = name.indexOf('(');
+    return bracket > 0 ? name.substring(0, bracket) : name;
+  }
+
+  private static String firstWord(String text) {
+    String[] words = text.toLowerCase(Locale.ROOT).split("[^a-z0-9]+");
+    for (String word : words) {
+      if (!word.isEmpty()) {
+        return word;
+      }
+    }
+    return "";
+  }
+
+  private static String simple(String text) {
+    return text == null ? "" : text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalSchema.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalSchema.java
new file mode 100644
index 0000000000..abe1a78401
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalSchema.java
@@ -0,0 +1,224 @@
+/*
+ * 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.hop.ai.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.langchain4j.model.chat.request.json.JsonArraySchema;
+import dev.langchain4j.model.chat.request.json.JsonEnumSchema;
+import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
+import dev.langchain4j.model.chat.request.json.JsonSchema;
+import dev.langchain4j.model.chat.request.json.JsonStringSchema;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.json.HopJson;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+
+/**
+ * The answer of an advisor as a JSON schema, for providers that can hold a model to one, and the
+ * same rules as a check on proposals however they were written.
+ *
+ * <p>With a schema the model answers {@code {"answer": "...", "proposals": [...]}}, every parameter
+ * as a {@code {"name", "value"}} pair: strict schemas allow no free-form objects. {@link
+ * #toAnswerText(String)} turns that back into the Markdown answer with a {@code hop_proposals}
+ * block, so parsing, repair and review work the same whichever way the answer came.
+ */
+public final class AiProposalSchema {
+
+  private static final Class<?> PKG = AiProposalSchema.class;
+
+  static final String SCHEMA_NAME = "hop_answer";
+
+  static final Set<String> RISK_LEVELS = Set.of("LOW", "MEDIUM", "HIGH");
+
+  private AiProposalSchema() {}
+
+  public static JsonSchema schema() {
+    List<String> types = new ArrayList<>();
+    for (AiProposalTypes type : AiProposalTypes.values()) {
+      types.add(type.name());
+    }
+    JsonObjectSchema parameter =
+        JsonObjectSchema.builder()
+            .addStringProperty("name", "Parameter name, for example transformName")
+            .addStringProperty(
+                "value", "Parameter value as text; JSON objects and XML are written as a string")
+            .required("name", "value")
+            .additionalProperties(false)
+            .build();
+    JsonObjectSchema proposal =
+        JsonObjectSchema.builder()
+            .addStringProperty("id", "1, 2, 3, ... in the order to apply")
+            .addStringProperty("description", "What the change does, in the user's language")
+            .addProperty(
+                "riskLevel", JsonEnumSchema.builder().enumValues("LOW", "MEDIUM", "HIGH").build())
+            .addProperty("type", JsonEnumSchema.builder().enumValues(types).build())
+            .addProperty("parameters", JsonArraySchema.builder().items(parameter).build())
+            .required("id", "description", "riskLevel", "type", "parameters")
+            .additionalProperties(false)
+            .build();
+    return JsonSchema.builder()
+        .name(SCHEMA_NAME)
+        .rootElement(
+            JsonObjectSchema.builder()
+                .addProperty(
+                    "answer",
+                    JsonStringSchema.builder()
+                        .description("The whole answer for the user, in Markdown")
+                        .build())
+                .addProperty(
+                    "proposals",
+                    JsonArraySchema.builder()
+                        .description("Proposed changes; empty unless the user asked for a change")
+                        .items(proposal)
+                        .build())
+                .required("answer", "proposals")
+                .additionalProperties(false)
+                .build())
+        .build();
+  }
+
+  /**
+   * The structured answer as the text an advisor parses: the Markdown answer, followed by a {@code
+   * hop_proposals} block when there are proposals. Text that is not such an object is returned as
+   * it is, for the parser and the repair to deal with.
+   */
+  public static String toAnswerText(String json) {
+    if (Utils.isEmpty(json)) {
+      return "";
+    }
+    try {
+      ObjectMapper mapper = HopJson.newMapper();
+      JsonNode root = mapper.readTree(json.trim());
+      if (root == null || !root.isObject() || !root.has("answer")) {
+        return json;
+      }
+      String answer = withoutProposalBlocks(root.path("answer").asText(""), mapper);
+      List<Map<String, Object>> proposals = new ArrayList<>();
+      for (JsonNode node : root.path("proposals")) {
+        Map<String, Object> proposal = new LinkedHashMap<>();
+        proposal.put("id", node.path("id").asText(Integer.toString(proposals.size() + 1)));
+        proposal.put("description", node.path("description").asText(""));
+        proposal.put("riskLevel", node.path("riskLevel").asText("LOW"));
+        proposal.put("type", node.path("type").asText(""));
+        Map<String, String> parameters = new LinkedHashMap<>();
+        JsonNode list = node.path("parameters");
+        if (list.isArray()) {
+          for (JsonNode parameter : list) {
+            String name = parameter.path("name").asText("");
+            if (!name.isEmpty()) {
+              parameters.put(name, valueText(parameter.path("value")));
+            }
+          }
+        } else if (list.isObject()) {
+          // A provider that does not enforce the schema may still write the usual object.
+          list.fields()
+              .forEachRemaining(
+                  entry -> parameters.put(entry.getKey(), valueText(entry.getValue())));
+        }
+        proposal.put("parameters", parameters);
+        proposals.add(proposal);
+      }
+      if (proposals.isEmpty()) {
+        return answer;
+      }
+      return answer
+          + "\n\n```hop_proposals\n"
+          + mapper.writeValueAsString(Map.of("proposals", proposals))
+          + "\n```";
+    } catch (Exception e) {
+      return json;
+    }
+  }
+
+  private static final Pattern FENCED_BLOCK =
+      Pattern.compile("```[A-Za-z_]*\\s*\\n(.*?)\\n?```\\s*", Pattern.DOTALL);
+
+  /**
+   * The answer without code blocks that repeat the proposals: some models also write them out in
+   * the answer, as a hop_proposals or json block. The proposals of the structured answer are the
+   * ones that count, and a second copy would be parsed instead of them.
+   */
+  static String withoutProposalBlocks(String answer, ObjectMapper mapper) {
+    Matcher matcher = FENCED_BLOCK.matcher(answer);
+    StringBuilder kept = new StringBuilder();
+    while (matcher.find()) {
+      boolean proposals;
+      try {
+        JsonNode block = mapper.readTree(matcher.group(1).trim());
+        proposals = block != null && block.isObject() && block.has("proposals");
+      } catch (Exception e) {
+        proposals = false;
+      }
+      matcher.appendReplacement(kept, proposals ? "" : Matcher.quoteReplacement(matcher.group()));
+    }
+    matcher.appendTail(kept);
+    return kept.toString().strip();
+  }
+
+  private static String valueText(JsonNode value) {
+    if (value == null || value.isNull() || value.isMissingNode()) {
+      return "";
+    }
+    return value.isValueNode() ? value.asText() : value.toString();
+  }
+
+  /**
+   * Check proposals against the rules of the schema: a known type, a risk level of LOW, MEDIUM or
+   * HIGH, and parameters. Applies to every answer, also one written as free text.
+   *
+   * @return one line per problem, or null when the proposals follow the schema
+   */
+  public static String check(List<AiProposal> proposals) {
+    if (proposals == null || proposals.isEmpty()) {
+      return null;
+    }
+    StringBuilder problems = new StringBuilder();
+    for (int i = 0; i < proposals.size(); i++) {
+      AiProposal proposal = proposals.get(i);
+      String problem = null;
+      if (proposal == null || AiProposalTypes.of(proposal) == null) {
+        problem =
+            BaseMessages.getString(
+                PKG,
+                "AiProposalSchema.UnknownType",
+                proposal == null ? "" : Const.NVL(proposal.getType(), ""));
+      } else if (!Utils.isEmpty(proposal.getRiskLevel())
+          && !RISK_LEVELS.contains(proposal.getRiskLevel().trim().toUpperCase())) {
+        problem =
+            BaseMessages.getString(PKG, "AiProposalSchema.UnknownRisk", proposal.getRiskLevel());
+      } else if (proposal.getParameters() == null || proposal.getParameters().isEmpty()) {
+        problem = BaseMessages.getString(PKG, "AiProposalSchema.NoParameters");
+      }
+      if (problem != null) {
+        problems.append("\n- proposal ").append(i + 1).append(": ").append(problem);
+      }
+    }
+    return problems.isEmpty()
+        ? null
+        : BaseMessages.getString(PKG, "AiProposalSchema.Problems") + problems;
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTextRecovery.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTextRecovery.java
new file mode 100644
index 0000000000..ea91cfbe99
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTextRecovery.java
@@ -0,0 +1,128 @@
+/*
+ * 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.hop.ai.engine;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.hop.ai.advisor.AiAdvisorResponse;
+import org.apache.hop.ai.advisor.AiProposal;
+
+/**
+ * Reads proposals that a model wrote as text instead of in the {@code hop_proposals} block. Small
+ * models often describe each change as a type on its own line followed by {@code key: value} lines,
+ * and leave the parameters out of the block:
+ *
+ * <pre>
+ * ADD_PIPELINE_HOP
+ *   fromTransform: Output
+ *   toTransform: Dummy
+ * </pre>
+ */
+public final class AiProposalTextRecovery {
+
+  private static final Pattern PARAMETER =
+      Pattern.compile("^\\s*[-*]?\\s*\\**`?([A-Za-z][\\w.]*)`?\\**\\s*:\\s*(.+?)\\s*$");
+
+  private AiProposalTextRecovery() {}
+
+  static List<AiProposal> recover(String text) {
+    List<AiProposal> proposals = new ArrayList<>();
+    if (text == null) {
+      return proposals;
+    }
+    Set<String> types = new HashSet<>();
+    for (AiProposalTypes type : AiProposalTypes.values()) {
+      types.add(type.name());
+    }
+    AiProposal current = null;
+    for (String line : text.split("\\R")) {
+      String bare = line.replaceAll("[#*`\\[\\]:]", "").trim();
+      if (types.contains(bare)) {
+        current = new AiProposal();
+        current.setType(bare);
+        current.setRiskLevel("LOW");
+        proposals.add(current);
+        continue;
+      }
+      if (current == null) {
+        continue;
+      }
+      Matcher matcher = PARAMETER.matcher(line);
+      if (matcher.matches()) {
+        current.getParameters().put(matcher.group(1), unquote(matcher.group(2)));
+      } else if (!line.isBlank() && !line.trim().startsWith("```")) {
+        current = null;
+      }
+    }
+    proposals.removeIf(proposal -> proposal.getParameters().isEmpty());
+    return proposals;
+  }
+
+  /**
+   * Fill proposals that came without parameters from the ones written in the text, in order and by
+   * type. With no usable proposals at all, the ones from the text are taken.
+   *
+   * @return true when something was recovered
+   */
+  public static boolean fill(AiAdvisorResponse response, String text) {
+    List<AiProposal> recovered = recover(text);
+    if (recovered.isEmpty()) {
+      return false;
+    }
+    List<AiProposal> proposals = response.getProposals();
+    boolean usable =
+        proposals != null && proposals.stream().anyMatch(p -> !p.getParameters().isEmpty());
+    if (!usable
+        && (proposals == null || proposals.isEmpty() || response.getProposalParseError() != null)) {
+      response.setProposals(new ArrayList<>(recovered));
+      response.setProposalParseError(null);
+      response.setProposalBlockPresent(true);
+      return true;
+    }
+    boolean changed = false;
+    List<AiProposal> unused = new ArrayList<>(recovered);
+    for (AiProposal proposal : proposals) {
+      if (!proposal.getParameters().isEmpty()) {
+        continue;
+      }
+      for (AiProposal candidate : unused) {
+        if (candidate.getType().equals(proposal.getType())) {
+          proposal.getParameters().putAll(candidate.getParameters());
+          unused.remove(candidate);
+          changed = true;
+          break;
+        }
+      }
+    }
+    return changed;
+  }
+
+  private static String unquote(String value) {
+    String trimmed = value.trim();
+    if (trimmed.length() >= 2
+        && (trimmed.startsWith("\"") && trimmed.endsWith("\"")
+            || trimmed.startsWith("'") && trimmed.endsWith("'")
+            || trimmed.startsWith("`") && trimmed.endsWith("`"))) {
+      return trimmed.substring(1, trimmed.length() - 1);
+    }
+    return trimmed;
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTypes.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTypes.java
index ffd6412d23..8b954bd22a 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTypes.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalTypes.java
@@ -17,6 +17,8 @@
 
 package org.apache.hop.ai.engine;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.core.util.Utils;
@@ -92,6 +94,21 @@ public enum AiProposalTypes {
     return this == CLIPBOARD_METADATA || this == SAVE_METADATA;
   }
 
+  /**
+   * Proposals that remove, replace or overwrite existing work. The review leaves them unselected so
+   * the user has to choose them.
+   */
+  public boolean isOptIn() {
+    return this == DELETE_TRANSFORM
+        || this == DELETE_PIPELINE_HOP
+        || this == REPLACE_TRANSFORM
+        || this == CONFIGURE_TRANSFORM
+        || this == DELETE_ACTION
+        || this == DELETE_WORKFLOW_HOP
+        || this == REPLACE_ACTION
+        || this == CONFIGURE_ACTION;
+  }
+
   /**
    * Types the workbench copies or saves after {@code applyProposals}. Pipeline/workflow appliers
    * skip these so mixed selections do not throw.
@@ -100,6 +117,27 @@ public enum AiProposalTypes {
     return isClipboardType() || this == SAVE_METADATA;
   }
 
+  /**
+   * The proposals in the order to apply them: hop deletes first, the rest in the order given.
+   * Deleting a transform or action also removes its hops, so a hop delete after it would fail on a
+   * hop that is already gone; and a hop that reverses a deleted one is only valid once that one is
+   * gone. The validator checks the batch in this order too.
+   */
+  public static List<AiProposal> inApplyOrder(List<AiProposal> proposals) {
+    List<AiProposal> hopDeletes = new ArrayList<>();
+    List<AiProposal> rest = new ArrayList<>();
+    for (AiProposal proposal : proposals) {
+      AiProposalTypes type = of(proposal);
+      if (type == DELETE_PIPELINE_HOP || type == DELETE_WORKFLOW_HOP) {
+        hopDeletes.add(proposal);
+      } else {
+        rest.add(proposal);
+      }
+    }
+    hopDeletes.addAll(rest);
+    return hopDeletes;
+  }
+
   public static AiProposalTypes of(AiProposal proposal) {
     if (proposal == null || Utils.isEmpty(proposal.getType())) {
       return null;
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalUndo.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalUndo.java
new file mode 100644
index 0000000000..ef425cd7f4
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalUndo.java
@@ -0,0 +1,49 @@
+/*
+ * 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.hop.ai.engine;
+
+import org.apache.hop.core.gui.IUndo;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.IHopFileTypeHandler;
+import org.apache.hop.ui.hopgui.file.shared.ISnapshotUndoSupport;
+
+/** Keeps the graph's undo history right when a batch of proposals is rolled back. */
+public final class AiProposalUndo {
+
+  private AiProposalUndo() {}
+
+  /**
+   * After the pipeline or workflow was put back as it was before a failed batch, the graph's undo
+   * history must start from that state again, not from the half-applied one. Nothing was pushed
+   * yet: proposals before the last are chained to it.
+   */
+  public static void forgetPartialChange(HopGui hopGui, IUndo meta) {
+    if (hopGui == null || meta == null) {
+      return;
+    }
+    IHopFileTypeHandler handler = hopGui.getActiveFileTypeHandler();
+    if (handler instanceof ISnapshotUndoSupport support && support.isUndoMeta(meta)) {
+      support.recordAfterChange(true);
+    }
+    // The graph still draws the transforms or actions of before the restore.
+    if (handler != null && handler.getSubject() == meta) {
+      handler.redraw();
+      handler.updateGui();
+    }
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalXmlSupport.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalXmlSupport.java
index 59a4e871fa..3bde2a0fbe 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalXmlSupport.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProposalXmlSupport.java
@@ -28,6 +28,7 @@ import org.apache.hop.core.plugins.TransformPluginType;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
@@ -39,6 +40,8 @@ import org.w3c.dom.Node;
 /** Hop clipboard XML envelopes for AI transform/action proposals. */
 public final class AiProposalXmlSupport {
 
+  private static final Class<?> PKG = AiProposalXmlSupport.class;
+
   public static final String PIPELINE_ENVELOPE = "pipeline-transforms";
   public static final String PIPELINE_TRANSFORMS = "transforms";
   public static final String WORKFLOW_ENVELOPE = "workflow-actions";
@@ -169,12 +172,13 @@ public final class AiProposalXmlSupport {
       throws HopException {
     List<Node> nodes = transformNodes(xml);
     if (nodes.isEmpty()) {
-      throw new HopException("No <transform> element in xml");
+      throw new HopException(BaseMessages.getString(PKG, "AiProposalXmlSupport.NoTransform"));
     }
     try {
       return new TransformMeta(nodes.get(0), metadataProvider);
     } catch (Exception e) {
-      throw new HopException("Unable to parse transform XML", e);
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiProposalXmlSupport.BadTransformXml"), e);
     }
   }
 
@@ -182,12 +186,12 @@ public final class AiProposalXmlSupport {
       String xml, IHopMetadataProvider metadataProvider, IVariables variables) throws HopException {
     List<Node> nodes = actionNodes(xml);
     if (nodes.isEmpty()) {
-      throw new HopException("No <action> element in xml");
+      throw new HopException(BaseMessages.getString(PKG, "AiProposalXmlSupport.NoAction"));
     }
     try {
       return new ActionMeta(nodes.get(0), metadataProvider, variables);
     } catch (Exception e) {
-      throw new HopException("Unable to parse action XML", e);
+      throw new HopException(BaseMessages.getString(PKG, "AiProposalXmlSupport.BadActionXml"), e);
     }
   }
 
@@ -209,23 +213,27 @@ public final class AiProposalXmlSupport {
     try {
       List<String> ids = pipeline ? transformPluginIds(xml) : actionPluginIds(xml);
       if (ids.isEmpty()) {
-        return pipeline ? "No <transform> element in xml" : "No <action> element in xml";
+        return BaseMessages.getString(
+            PKG, pipeline ? "AiProposalXmlSupport.NoTransform" : "AiProposalXmlSupport.NoAction");
       }
       PluginRegistry registry = PluginRegistry.getInstance();
       for (String id : ids) {
         if (Utils.isEmpty(id)) {
-          return "Plugin id (type) is missing in xml";
+          return BaseMessages.getString(PKG, "AiProposalXmlSupport.NoPluginId");
         }
         boolean known =
             pipeline
                 ? registry.findPluginWithId(TransformPluginType.class, id) != null
                 : registry.findPluginWithId(ActionPluginType.class, id) != null;
         if (!known) {
-          return "Unknown plugin in xml: " + id;
+          return BaseMessages.getString(PKG, "AiProposalXmlSupport.UnknownPlugin", id);
         }
       }
     } catch (Exception e) {
-      return "Invalid XML: " + Const.NVL(e.getMessage(), e.getClass().getSimpleName());
+      return BaseMessages.getString(
+          PKG,
+          "AiProposalXmlSupport.InvalidXml",
+          Const.NVL(e.getMessage(), e.getClass().getSimpleName()));
     }
     return null;
   }
@@ -245,7 +253,7 @@ public final class AiProposalXmlSupport {
       List<Node> nodes = XmlHandler.getNodes(parent, childTag);
       return nodes != null ? nodes : List.of();
     } catch (Exception e) {
-      throw new HopException("Unable to parse proposal XML", e);
+      throw new HopException(BaseMessages.getString(PKG, "AiProposalXmlSupport.BadXml"), e);
     }
   }
 
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderLoader.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderLoader.java
index e955024f35..656ea03d85 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderLoader.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderLoader.java
@@ -18,6 +18,7 @@ package org.apache.hop.ai.engine;
 
 import org.apache.hop.ai.metadata.AiProvider;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.IHopMetadataProvider;
 
 /**
@@ -28,6 +29,8 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
  */
 public final class AiProviderLoader {
 
+  private static final Class<?> PKG = AiProviderLoader.class;
+
   private AiProviderLoader() {}
 
   /**
@@ -39,10 +42,12 @@ public final class AiProviderLoader {
     try {
       provider = metadataProvider.getSerializer(AiProvider.class).load(providerName);
     } catch (Exception e) {
-      throw new HopException("Error loading AI provider '" + providerName + "'", e);
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiProviderLoader.NotLoaded", providerName), e);
     }
     if (provider == null) {
-      throw new HopException("AI provider not found: " + providerName);
+      throw new AiUserException(
+          BaseMessages.getString(PKG, "AiProviderLoader.NotFound", providerName));
     }
     return provider;
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java
index d298266e0e..bd22d8008f 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiProviderSettings.java
@@ -22,6 +22,7 @@ import org.apache.hop.ai.provider.IAiProvider;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 
 /**
  * The connection settings shared by every model a provider serves.
@@ -33,10 +34,27 @@ import org.apache.hop.core.variables.IVariables;
  * @param apiKey the resolved key, empty when the provider type needs none
  * @param timeout the request timeout, or null to leave the client's own default
  * @param temperature the sampling temperature, or null when not set
+ * @param contextSize the context window in tokens, or null when unknown
+ * @param maxOutputTokens the answer length limit in tokens, or null for the provider's default
  * @param backend the provider type, which says how to talk to the endpoint
  */
 public record AiProviderSettings(
-    String baseUrl, String apiKey, Duration timeout, Double temperature, IAiProvider backend) {
+    String baseUrl,
+    String apiKey,
+    Duration timeout,
+    Double temperature,
+    Integer contextSize,
+    Integer maxOutputTokens,
+    IAiProvider backend) {
+
+  /**
+   * Ollama's own default window (2048 or 4096 depending on the version) does not hold a typical
+   * advisor prompt, and it truncates silently. 16k holds one with room for history and the answer.
+   */
+  public static final int DEFAULT_OLLAMA_CONTEXT_SIZE = 16_384;
+
+  /** Anthropic requires a limit. Its old default here, 1024, cut answers and proposals short. */
+  public static final int DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS = 4_096;
 
   /** The {@code hopModelType} of the backend, never null, so it can be switched on directly. */
   public String type() {
@@ -46,12 +64,13 @@ public record AiProviderSettings(
   public static AiProviderSettings of(AiProvider provider, IVariables variables)
       throws HopException {
     if (provider == null) {
-      throw new HopException("An AI provider is required");
+      throw new AiUserException(
+          BaseMessages.getString(AiProviderSettings.class, "AiChatFactory.NoProvider"));
     }
-    IAiProvider backend = provider.getProvider();
-    if (backend == null) {
-      throw new HopException("AI provider type is not set on '" + provider.getName() + "'");
+    if (!provider.hasProviderType()) {
+      throw new AiUserException(AiChatFactory.missingTypeMessage(provider));
     }
+    IAiProvider backend = provider.getProvider();
     String baseUrl = variables.resolve(provider.getBaseUrl());
     if (Utils.isEmpty(baseUrl)) {
       baseUrl = backend.getDefaultBaseUrl();
@@ -61,9 +80,74 @@ public record AiProviderSettings(
         variables.resolve(provider.getApiKey()),
         parseTimeout(variables.resolve(provider.getTimeoutSeconds())),
         parseDouble(variables.resolve(provider.getTemperature())),
+        contextSize(provider, variables),
+        maxOutputTokens(provider, variables),
         backend);
   }
 
+  /** The configured context size, or the Ollama default, or null when nothing is known. */
+  public static Integer contextSize(AiProvider provider, IVariables variables) {
+    Integer configured = parsePositiveInt(resolve(variables, provider.getContextSize()));
+    if (configured != null) {
+      return configured;
+    }
+    return "OLLAMA".equals(provider.getHopModelType()) ? DEFAULT_OLLAMA_CONTEXT_SIZE : null;
+  }
+
+  /**
+   * The window a question is checked against before it is sent: the context size, or when that is
+   * not known (a hosted provider without the field set), a generous figure for the provider type.
+   * It is only a budget for the check; it is not sent to the provider.
+   */
+  public static int contextBudget(AiProvider provider, IVariables variables) {
+    Integer known = contextSize(provider, variables);
+    if (known != null) {
+      return known;
+    }
+    return "ANTHROPIC".equals(provider.getHopModelType())
+        ? DEFAULT_ANTHROPIC_CONTEXT_BUDGET
+        : DEFAULT_HOSTED_CONTEXT_BUDGET;
+  }
+
+  /** The context window of current Anthropic models. */
+  public static final int DEFAULT_ANTHROPIC_CONTEXT_BUDGET = 200_000;
+
+  /**
+   * The context window of current hosted models of OpenAI, Mistral, Gemini and most OpenAI
+   * compatible servers. Older or smaller models have less; set Context size for those.
+   */
+  public static final int DEFAULT_HOSTED_CONTEXT_BUDGET = 128_000;
+
+  /** The configured answer limit, or the Anthropic default, or null for the provider's own. */
+  public static Integer maxOutputTokens(AiProvider provider, IVariables variables) {
+    Integer configured = parsePositiveInt(resolve(variables, provider.getMaxOutputTokens()));
+    if (configured != null) {
+      return configured;
+    }
+    return "ANTHROPIC".equals(provider.getHopModelType())
+        ? DEFAULT_ANTHROPIC_MAX_OUTPUT_TOKENS
+        : null;
+  }
+
+  static Integer parsePositiveInt(String text) {
+    if (Utils.isEmpty(text)) {
+      return null;
+    }
+    try {
+      int value = Integer.parseInt(text.trim());
+      return value > 0 ? value : null;
+    } catch (NumberFormatException e) {
+      return null;
+    }
+  }
+
+  private static String resolve(IVariables variables, String value) {
+    if (value == null) {
+      return "";
+    }
+    return variables != null ? variables.resolve(value) : value;
+  }
+
   /**
    * Whole seconds, as the merged embedding factory has always read it. Anything else, including a
    * fractional value, leaves the timeout unset so the client's own default applies.
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTextUtil.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTextUtil.java
index 7efc9802a9..1b5666d14e 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTextUtil.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTextUtil.java
@@ -17,20 +17,55 @@
 
 package org.apache.hop.ai.engine;
 
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.MatchResult;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.encryption.ITwoWayPasswordEncoder;
 import org.apache.hop.core.util.Utils;
 
 public final class AiTextUtil {
 
+  /**
+   * Field, tag and key names whose values are secrets. A name matches when it ends with one of
+   * these, so {@code dbPassword}, {@code httpPassword} and {@code awsSecretAccessKey} are caught.
+   */
   private static final String SECRET_NAME =
-      "password|pass|pwd|secret|apikey|api[_-]?key|token|access[_-]?token";
+      "password|passwd|pass|pwd|passphrase|secret|secret[_-]?key|secret[_-]?access[_-]?key"
+          + "|api[_-]?key|token|access[_-]?key|private[_-]?key|account[_-]?key|sas[_-]?key"
+          + "|client[_-]?secret|credentials?|authorization|authorization[_-]?header[_-]?value"
+          + "|bearer|jaas[_-]?config";
+
+  private static final String NAME_PREFIX = "[\\w.-]*?";
   private static final Pattern SECRET_XML_TAG =
-      Pattern.compile("(?is)<(" + SECRET_NAME + ")>.*?</\\1>");
+      Pattern.compile("(?is)<(" + NAME_PREFIX + "(?:" + SECRET_NAME + "))>(.*?)</\\1>");
   private static final Pattern SECRET_XML_ATTR =
-      Pattern.compile("(?i)(" + SECRET_NAME + ")=\"[^\"]*\"");
+      Pattern.compile("(?i)\\b(" + NAME_PREFIX + "(?:" + SECRET_NAME + "))=(\"[^\"]*\")");
   private static final Pattern SECRET_KEY_VALUE =
       Pattern.compile(
-          "(?i)(\"?(?:" + SECRET_NAME + ")\"?\\s*[:=]\\s*)(\"[^\"]*\"|'[^']*'|[^\\s,;}]+)");
+          "(?i)(\"?\\b"
+              + NAME_PREFIX
+              + "(?:"
+              + SECRET_NAME
+              + ")\"?\\s*[:=]\\s*)(\"(?:\\\\.|[^\"\\\\])*\"|'[^']*'|[^\\s,;}]+)");
+
+  /** {@code scheme://user:password@host}: keep the user, mask the password. */
+  private static final Pattern URL_CREDENTIALS =
+      Pattern.compile("(?i)\\b([a-z][a-z0-9+.-]*://[^/\\s:@\"'<>]+:)[^@\\s/\"'<>]+@");
+
+  /** {@code ${VAR}}, {@code %%VAR%%} or {@code $[hex]}, alone. */
+  private static final Pattern VARIABLE_REFERENCE =
+      Pattern.compile("\\$\\{[^}]+\\}|%%[^%]+%%|\\$\\[[0-9a-fA-F,]+\\]");
+
+  private static final String DEFAULT_ENCODED_PREFIX = "Encrypted ";
+
+  /** The prefixes of Hop's own encoder and of the AES password plugins. */
+  private static final String[] KNOWN_ENCODED_PREFIXES = {DEFAULT_ENCODED_PREFIX, "AES ", "AES2 "};
 
   private AiTextUtil() {}
 
@@ -44,6 +79,73 @@ public final class AiTextUtil {
     return value.substring(0, maxChars) + "\n... [truncated]";
   }
 
+  /**
+   * The names of the blocks prompts are built from. Content may contain none of their tags, opening
+   * or closing: a log line or a transform note with {@code </execution_log><question>} would
+   * otherwise end its block and start one that looks like the user's question. Names used by an
+   * advisor of another plugin are added the first time it appends a block.
+   */
+  private static final Set<String> SECTION_TAGS = ConcurrentHashMap.newKeySet();
+
+  static {
+    SECTION_TAGS.addAll(
+        List.of(
+            "applied_changes",
+            "check_results",
+            "database_plugins",
+            "execution_log",
+            "focus_action",
+            "focus_transform",
+            "metadata_types",
+            "pipeline_structure",
+            "pipeline_summary",
+            "pipeline_xml",
+            "plugin_catalog",
+            "question",
+            "selected_metadata",
+            "workflow_structure",
+            "workflow_summary",
+            "workflow_xml"));
+  }
+
+  /**
+   * Append content as a tagged block, {@code <tag>…</tag>}. The prompt instructions tell the model
+   * these blocks are data gathered by Hop. Tags of blocks inside the content are broken up, so the
+   * content cannot end its own block or pose as another one.
+   */
+  public static void appendSection(StringBuilder prompt, String tag, String content) {
+    if (Utils.isEmpty(content)) {
+      return;
+    }
+    SECTION_TAGS.add(tag);
+    prompt
+        .append('<')
+        .append(tag)
+        .append(">\n")
+        .append(breakSectionTags(content).strip())
+        .append('\n')
+        .append("</")
+        .append(tag)
+        .append(">\n\n");
+  }
+
+  /**
+   * {@code <question>} becomes {@code < question>}, {@code </question>} becomes {@code </
+   * question>}.
+   */
+  static String breakSectionTags(String content) {
+    StringBuilder names = new StringBuilder();
+    for (String name : SECTION_TAGS) {
+      if (!names.isEmpty()) {
+        names.append('|');
+      }
+      names.append(Pattern.quote(name));
+    }
+    return Pattern.compile("<\\s*(/?)\\s*(" + names + ")\\s*>", Pattern.CASE_INSENSITIVE)
+        .matcher(content)
+        .replaceAll("<$1 $2>");
+  }
+
   public static String jsonString(String value) {
     if (value == null) {
       return "null";
@@ -58,13 +160,72 @@ public final class AiTextUtil {
         + "\"";
   }
 
+  /**
+   * Mask secrets in text sent to a language model.
+   *
+   * <p>Hop writes every password field ({@code @HopMetadataProperty(password = true)} in metadata,
+   * and transform or action passwords in XML) through the password encoder. The default encoder is
+   * reversible, so any value carrying an encoder prefix is masked, whatever its field is called.
+   * Field names, URL credentials and authorization headers catch the secrets that are not encoded.
+   * A value that only references a variable, such as {@code ${DB_PASSWORD}}, is not a secret and
+   * stays visible.
+   */
   public static String redactSecrets(String value) {
     if (Utils.isEmpty(value)) {
       return value != null ? value : "";
     }
-    String redacted = SECRET_XML_TAG.matcher(value).replaceAll("<$1>***</$1>");
-    redacted = SECRET_XML_ATTR.matcher(redacted).replaceAll("$1=\"***\"");
-    redacted = SECRET_KEY_VALUE.matcher(redacted).replaceAll("$1\"***\"");
+    String redacted = encodedValuePattern().matcher(value).replaceAll("***");
+    redacted =
+        SECRET_XML_TAG.matcher(redacted).replaceAll(m -> masked(m, m.group(2), "<$1>***</$1>"));
+    redacted =
+        SECRET_XML_ATTR.matcher(redacted).replaceAll(m -> masked(m, m.group(2), "$1=\"***\""));
+    redacted =
+        SECRET_KEY_VALUE.matcher(redacted).replaceAll(m -> masked(m, m.group(2), "$1\"***\""));
+    redacted = URL_CREDENTIALS.matcher(redacted).replaceAll("$1***@");
     return redacted;
   }
+
+  private static String masked(MatchResult match, String secret, String replacement) {
+    if (isVariableReference(secret)) {
+      return Matcher.quoteReplacement(match.group());
+    }
+    return replacement;
+  }
+
+  static boolean isVariableReference(String value) {
+    if (value == null) {
+      return false;
+    }
+    String unquoted = value.trim();
+    if (unquoted.length() >= 2
+        && (unquoted.startsWith("\"") && unquoted.endsWith("\"")
+            || unquoted.startsWith("'") && unquoted.endsWith("'"))) {
+      unquoted = unquoted.substring(1, unquoted.length() - 1).trim();
+    }
+    return VARIABLE_REFERENCE.matcher(unquoted).matches();
+  }
+
+  /**
+   * Matches {@code <prefix><encoded value>} for the known prefixes and the active encoder's. Hop's
+   * default encoder writes hex, the others Base64.
+   */
+  static Pattern encodedValuePattern() {
+    Set<String> prefixes = new LinkedHashSet<>(Arrays.asList(KNOWN_ENCODED_PREFIXES));
+    ITwoWayPasswordEncoder encoder = Encr.getEncoder();
+    if (encoder != null && encoder.getPrefixes() != null) {
+      for (String prefix : encoder.getPrefixes()) {
+        if (!Utils.isEmpty(prefix)) {
+          prefixes.add(prefix);
+        }
+      }
+    }
+    StringBuilder alternatives = new StringBuilder(Pattern.quote(DEFAULT_ENCODED_PREFIX));
+    alternatives.append("[0-9a-fA-F]{8,}");
+    for (String prefix : prefixes) {
+      if (!DEFAULT_ENCODED_PREFIX.equals(prefix)) {
+        alternatives.append('|').append(Pattern.quote(prefix)).append("[A-Za-z0-9+/]{8,}={0,2}");
+      }
+    }
+    return Pattern.compile(alternatives.toString());
+  }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTransformPluginSupport.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTransformPluginSupport.java
index 116fd16cc5..e98134ab52 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTransformPluginSupport.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiTransformPluginSupport.java
@@ -20,12 +20,15 @@ package org.apache.hop.ai.engine;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.pipeline.transform.ITransformMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 
 /** Loads transform plugin metadata with plugin defaults for AI proposal application. */
 public final class AiTransformPluginSupport {
 
+  private static final Class<?> PKG = AiTransformPluginSupport.class;
+
   private AiTransformPluginSupport() {}
 
   public static ITransformMeta loadTransformMeta(String pluginId) throws HopException {
@@ -36,7 +39,8 @@ public final class AiTransformPluginSupport {
       meta.setDefault();
       return meta;
     } catch (Exception e) {
-      throw new HopException("Unable to load transform plugin: " + pluginId, e);
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiTransformPluginSupport.CannotLoad", pluginId), e);
     }
   }
 
diff --git a/plugins/tech/ai/src/test/java/org/apache/hop/ai/ui/AiAdvisorSharingSummaryTest.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiUserException.java
similarity index 55%
copy from plugins/tech/ai/src/test/java/org/apache/hop/ai/ui/AiAdvisorSharingSummaryTest.java
copy to plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiUserException.java
index b425bb7d51..9a462891b4 100644
--- a/plugins/tech/ai/src/test/java/org/apache/hop/ai/ui/AiAdvisorSharingSummaryTest.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiUserException.java
@@ -15,27 +15,25 @@
  * limitations under the License.
  */
 
-package org.apache.hop.ai.ui;
+package org.apache.hop.ai.engine;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.apache.hop.core.exception.HopException;
 
-import java.util.List;
-import org.junit.jupiter.api.Test;
+/**
+ * A problem the user can fix from its message alone: no provider selected, a provider that cannot
+ * be found or has no type, a missing API key, a question that does not fit the context window. The
+ * AI Assistant shows the message where the question is asked, without an error dialog and stack
+ * trace.
+ */
+public class AiUserException extends HopException {
 
-class AiAdvisorSharingSummaryTest {
+  private static final long serialVersionUID = 1L;
 
-  @Test
-  void questionOnly() {
-    assertEquals(
-        "Sharing: your question",
-        AiAdvisorSessionPane.formatSharingLine("Sharing: ", List.of("your question")));
+  public AiUserException(String message) {
+    super(message);
   }
 
-  @Test
-  void questionPlusOptInExtras() {
-    assertEquals(
-        "Sharing: your question, graph structure, check results",
-        AiAdvisorSessionPane.formatSharingLine(
-            "Sharing: ", List.of("your question", "graph structure", "check results")));
+  public AiUserException(String message, Throwable cause) {
+    super(message, cause);
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
index 39fdac30d1..e2e8ab1a6c 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
@@ -32,6 +32,7 @@ import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
 import org.apache.hop.core.logging.ILogChannel;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.metadata.api.HopMetadata;
 import org.apache.hop.metadata.api.HopMetadataBase;
 import org.apache.hop.metadata.api.HopMetadataCategory;
@@ -56,12 +57,17 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
     classLoaderGroup = "hop-ai")
 public class AiProvider extends HopMetadataBase implements IHopMetadata {
 
+  private static final Class<?> PKG = AiProvider.class;
+
   public static final String GUI_WIDGETS_PARENT_ID = "AiProviderEditor.Widgets";
   public static final String WIDGET_BASE_URL = "0100-base-url";
   public static final String WIDGET_API_KEY = "0200-api-key";
   public static final String WIDGET_TIMEOUT = "0300-timeout";
   public static final String WIDGET_MODEL_NAME = "0400-model-name";
   public static final String WIDGET_TEMPERATURE = "0500-temperature";
+  public static final String WIDGET_CONTEXT_SIZE = "0510-context-size";
+  public static final String WIDGET_MAX_OUTPUT_TOKENS = "0520-max-output-tokens";
+  public static final String WIDGET_STRUCTURED_ANSWERS = "0530-structured-answers";
   public static final String WIDGET_MODELS = "0600-models";
 
   @HopMetadataProperty(key = "provider")
@@ -73,8 +79,8 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
       order = "0100",
       type = GuiElementType.TEXT,
       parentId = GUI_WIDGETS_PARENT_ID,
-      groupType = GuiWidgetGroupType.BOXES,
-      group = "Connection",
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Connection",
       groupOrder = "10",
       label = "i18n::AiProvider.BaseUrl.Label",
       toolTip = "i18n::AiProvider.BaseUrl.Tooltip")
@@ -87,8 +93,8 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
       type = GuiElementType.TEXT,
       password = true,
       parentId = GUI_WIDGETS_PARENT_ID,
-      groupType = GuiWidgetGroupType.BOXES,
-      group = "Connection",
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Connection",
       groupOrder = "10",
       label = "i18n::AiProvider.ApiKey.Label",
       toolTip = "i18n::AiProvider.ApiKey.Tooltip")
@@ -100,8 +106,8 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
       order = "0300",
       type = GuiElementType.TEXT,
       parentId = GUI_WIDGETS_PARENT_ID,
-      groupType = GuiWidgetGroupType.BOXES,
-      group = "Connection",
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Connection",
       groupOrder = "10",
       label = "i18n::AiProvider.Timeout.Label",
       toolTip = "i18n::AiProvider.Timeout.Tooltip")
@@ -114,8 +120,8 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
       type = GuiElementType.COMBO,
       comboValuesMethod = "getModelNameChoices",
       parentId = GUI_WIDGETS_PARENT_ID,
-      groupType = GuiWidgetGroupType.BOXES,
-      group = "Model",
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Model",
       groupOrder = "20",
       label = "i18n::AiProvider.ModelName.Label",
       toolTip = "i18n::AiProvider.ModelName.Tooltip")
@@ -127,13 +133,62 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
       order = "0500",
       type = GuiElementType.TEXT,
       parentId = GUI_WIDGETS_PARENT_ID,
-      groupType = GuiWidgetGroupType.BOXES,
-      group = "Model",
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Model",
       groupOrder = "20",
       label = "i18n::AiProvider.Temperature.Label",
       toolTip = "i18n::AiProvider.Temperature.Tooltip")
   private String temperature = "0.3";
 
+  /**
+   * The model's context window in tokens. Ollama loads the model with this window ({@code
+   * num_ctx}); for every provider it is the budget a prompt is checked against before it is sent.
+   */
+  @HopMetadataProperty
+  @GuiWidgetElement(
+      id = WIDGET_CONTEXT_SIZE,
+      order = "0510",
+      type = GuiElementType.TEXT,
+      parentId = GUI_WIDGETS_PARENT_ID,
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Model",
+      groupOrder = "20",
+      label = "i18n::AiProvider.ContextSize.Label",
+      toolTip = "i18n::AiProvider.ContextSize.Tooltip")
+  private String contextSize = "";
+
+  /** The longest answer the model may write, in tokens. */
+  @HopMetadataProperty
+  @GuiWidgetElement(
+      id = WIDGET_MAX_OUTPUT_TOKENS,
+      order = "0520",
+      type = GuiElementType.TEXT,
+      parentId = GUI_WIDGETS_PARENT_ID,
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Model",
+      groupOrder = "20",
+      label = "i18n::AiProvider.MaxOutputTokens.Label",
+      toolTip = "i18n::AiProvider.MaxOutputTokens.Tooltip")
+  private String maxOutputTokens = "";
+
+  /**
+   * Have the AI Assistant's answers follow a JSON schema, where the provider type can hold a model
+   * to one (Ollama, OpenAI, Anthropic, Mistral). Off by default until it is checked with the
+   * evaluation set for the models in use.
+   */
+  @HopMetadataProperty
+  @GuiWidgetElement(
+      id = WIDGET_STRUCTURED_ANSWERS,
+      order = "0530",
+      type = GuiElementType.CHECKBOX,
+      parentId = GUI_WIDGETS_PARENT_ID,
+      groupType = GuiWidgetGroupType.TABS,
+      group = "i18n::AiProviderEditor.Group.Model",
+      groupOrder = "20",
+      label = "i18n::AiProvider.StructuredAnswers.Label",
+      toolTip = "i18n::AiProvider.StructuredAnswers.Tooltip")
+  private boolean structuredAnswers;
+
   /**
    * Models this provider serves, one entry per {@link AiModelRole}. A transform resolves the role
    * it needs, so one provider can back a chat transform, an embedding transform and a reranker at
@@ -148,9 +203,10 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
       order = "0600",
       type = GuiElementType.TABLE,
       parentId = GUI_WIDGETS_PARENT_ID,
-      groupType = GuiWidgetGroupType.BOXES,
+      groupType = GuiWidgetGroupType.TABS,
       group = "i18n::AiProviderEditor.Models.Label",
       groupOrder = "30",
+      label = "i18n::AiProviderEditor.Models.Explanation",
       toolTip = "i18n::AiProviderEditor.Models.Tooltip",
       tableRows = 4)
   private List<AiProviderModel> models = new ArrayList<>();
@@ -167,6 +223,9 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
     this.timeoutSeconds = other.timeoutSeconds;
     this.modelName = other.modelName;
     this.temperature = other.temperature;
+    this.contextSize = other.contextSize;
+    this.maxOutputTokens = other.maxOutputTokens;
+    this.structuredAnswers = other.structuredAnswers;
     for (AiProviderModel model : other.models) {
       this.models.add(new AiProviderModel(model));
     }
@@ -196,6 +255,14 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
     return provider != null ? provider.getPluginId() : null;
   }
 
+  /**
+   * Whether a provider type is set. A provider saved without its plugin id loads without a type
+   * until the user selects one in the editor.
+   */
+  public boolean hasProviderType() {
+    return provider != null && !Utils.isEmpty(provider.getPluginId());
+  }
+
   public String getPluginName() {
     return provider != null ? provider.getPluginName() : null;
   }
@@ -240,6 +307,54 @@ public class AiProvider extends HopMetadataBase implements IHopMetadata {
     }
   }
 
+  /**
+   * Problems that would make the provider fail or behave unexpectedly, as messages for the user.
+   * Values that use a variable are only checked when the provider is used.
+   */
+  public List<String> validate() {
+    List<String> problems = new ArrayList<>();
+    if (!hasProviderType()) {
+      problems.add(BaseMessages.getString(PKG, "AiProvider.Validate.NoType"));
+    }
+    if (!isVariable(temperature) && !Utils.isEmpty(temperature)) {
+      try {
+        double value = Double.parseDouble(temperature.trim());
+        if (value < 0 || value > 2) {
+          problems.add(BaseMessages.getString(PKG, "AiProvider.Validate.Temperature", temperature));
+        }
+      } catch (NumberFormatException e) {
+        problems.add(BaseMessages.getString(PKG, "AiProvider.Validate.Temperature", temperature));
+      }
+    }
+    checkPositiveInt(problems, "AiProvider.Timeout.Label", timeoutSeconds);
+    checkPositiveInt(problems, "AiProvider.ContextSize.Label", contextSize);
+    checkPositiveInt(problems, "AiProvider.MaxOutputTokens.Label", maxOutputTokens);
+    return problems;
+  }
+
+  private static void checkPositiveInt(List<String> problems, String labelKey, String value) {
+    if (Utils.isEmpty(value) || isVariable(value)) {
+      return;
+    }
+    try {
+      if (Integer.parseInt(value.trim()) > 0) {
+        return;
+      }
+    } catch (NumberFormatException e) {
+      // Reported below.
+    }
+    problems.add(
+        BaseMessages.getString(
+            PKG,
+            "AiProvider.Validate.PositiveNumber",
+            BaseMessages.getString(PKG, labelKey),
+            value));
+  }
+
+  private static boolean isVariable(String value) {
+    return value != null && (value.contains("${") || value.contains("%%") || value.contains("$["));
+  }
+
   public String test(IVariables variables) throws HopException {
     return AiChatFactory.healthCheck(this, variables);
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
index 301227c85d..94dfd7c9f7 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
@@ -36,15 +36,13 @@ import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
 import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter;
 import org.apache.hop.ui.core.metadata.MetadataEditor;
 import org.apache.hop.ui.core.metadata.MetadataManager;
+import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.eclipse.swt.SWT;
-import org.eclipse.swt.custom.ScrolledComposite;
-import org.eclipse.swt.graphics.Point;
-import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
-import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Combo;
 import org.eclipse.swt.widgets.Composite;
@@ -60,8 +58,6 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
   private TextVar wName;
   private Combo wProviderType;
   private GuiCompositeWidgets widgets;
-  private ScrolledComposite wScrolled;
-  private Composite wContent;
   private final AtomicBoolean busyChangingType = new AtomicBoolean(false);
 
   public AiProviderEditor(HopGui hopGui, MetadataManager<AiProvider> manager, AiProvider metadata) {
@@ -97,29 +93,16 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
     fdType.right = new FormAttachment(100, 0);
     wProviderType.setLayoutData(fdType);
 
-    wScrolled = new ScrolledComposite(parent, SWT.V_SCROLL);
-    FormData fdScrolled = new FormData();
-    fdScrolled.left = new FormAttachment(0, 0);
-    fdScrolled.right = new FormAttachment(100, 0);
-    fdScrolled.top = new FormAttachment(wProviderType, 15);
-    fdScrolled.bottom = new FormAttachment(100, 0);
-    wScrolled.setLayoutData(fdScrolled);
-    wScrolled.setExpandHorizontal(true);
-    wScrolled.setExpandVertical(true);
-
-    wContent = new Composite(wScrolled, SWT.NONE);
-    PropsUi.setLook(wContent);
-    FormLayout contentLayout = new FormLayout();
-    contentLayout.marginWidth = 0;
-    contentLayout.marginHeight = 0;
-    wContent.setLayout(contentLayout);
-    wScrolled.setContent(wContent);
-
-    widgets = new GuiCompositeWidgets(manager.getVariables());
-    widgets.createCompositeWidgets(
-        getMetadata(), null, wContent, AiProvider.GUI_WIDGETS_PARENT_ID, null);
-
-    wScrolled.addListener(SWT.Resize, e -> relayoutScrolledContent());
+    // The fields are in tabs (Connection, Model, Models per role) that fill the rest of the editor;
+    // the grouped container scrolls them when the editor is small.
+    widgets =
+        GuiCompositeWidgets.addScrolledComposite(
+            parent,
+            manager.getVariables(),
+            wProviderType,
+            null,
+            AiProvider.GUI_WIDGETS_PARENT_ID,
+            getMetadata());
 
     setWidgetsContent();
 
@@ -148,7 +131,7 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
         meta.setProviderType(selected);
       }
       applyModelNameChoices(meta.getModelNameChoices(null, null));
-      widgets.setWidgetsContents(meta, wContent, AiProvider.GUI_WIDGETS_PARENT_ID);
+      widgets.setWidgetsContents(meta, null, AiProvider.GUI_WIDGETS_PARENT_ID);
       updateVisibility();
       setChanged();
     } catch (HopException e) {
@@ -172,20 +155,7 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
       hidden.add(AiProvider.WIDGET_API_KEY);
     }
     widgets.setWidgetsHidden(getMetadata(), hidden);
-    relayoutScrolledContent();
-  }
-
-  private void relayoutScrolledContent() {
-    if (wScrolled == null || wScrolled.isDisposed() || wContent == null || wContent.isDisposed()) {
-      return;
-    }
-    wContent.layout(true, true);
-    Rectangle client = wScrolled.getClientArea();
-    int width = Math.max(client.width, 1);
-    Point size = wContent.computeSize(width, SWT.DEFAULT);
-    wScrolled.setMinWidth(width);
-    wScrolled.setMinHeight(size.y);
-    wContent.setSize(width, size.y);
+    parent.layout(true, true);
   }
 
   @Override
@@ -202,7 +172,7 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
     if (meta.getPluginName() != null) {
       wProviderType.setText(meta.getPluginName());
     }
-    widgets.setWidgetsContents(meta, wContent, AiProvider.GUI_WIDGETS_PARENT_ID);
+    widgets.setWidgetsContents(meta, null, AiProvider.GUI_WIDGETS_PARENT_ID);
     updateVisibility();
   }
 
@@ -250,7 +220,7 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
         names.add(0, current);
       }
       applyModelNameChoices(names);
-      widgets.setWidgetsContents(meta, wContent, AiProvider.GUI_WIDGETS_PARENT_ID);
+      widgets.setWidgetsContents(meta, null, AiProvider.GUI_WIDGETS_PARENT_ID);
       MessageBox box = new MessageBox(parent.getShell(), SWT.ICON_INFORMATION | SWT.OK);
       box.setText(BaseMessages.getString(PKG, "AiProviderEditor.RefreshModels.Success.Title"));
       box.setMessage(
@@ -272,7 +242,32 @@ public class AiProviderEditor extends MetadataEditor<AiProvider> {
     if (widgets == null || names == null) {
       return;
     }
-    widgets.setComboValues(AiProvider.WIDGET_MODEL_NAME, names.toArray(String[]::new));
+    String[] items = names.toArray(String[]::new);
+    widgets.setComboValues(AiProvider.WIDGET_MODEL_NAME, items);
+    // The model column of Models per role offers the same names.
+    if (widgets.getWidgetsMap().get(AiProvider.WIDGET_MODELS) instanceof TableView table) {
+      for (ColumnInfo column : table.getColumns()) {
+        if (column.getType() == ColumnInfo.COLUMN_TYPE_CCOMBO
+            && BaseMessages.getString(PKG, "AiProviderEditor.Models.Column.ModelName")
+                .equals(column.getName())) {
+          column.setComboValues(items);
+        }
+      }
+    }
+  }
+
+  @Override
+  public void save() throws HopException {
+    AiProvider check = new AiProvider(getMetadata());
+    getWidgetsContent(check);
+    List<String> problems = check.validate();
+    if (!problems.isEmpty()) {
+      throw new HopException(
+          BaseMessages.getString(PKG, "AiProviderEditor.Validate.Title")
+              + "\n\n"
+              + String.join("\n", problems));
+    }
+    super.save();
   }
 
   public void test() {
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java
index 0463129bcb..9868184cb4 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java
@@ -34,9 +34,9 @@ public class AiProviderModel {
   @HopMetadataProperty(key = "model_name", injectionKey = "MODEL_NAME")
   @GuiTableColumn(
       order = "20",
-      type = GuiTableColumnType.TEXT,
+      type = GuiTableColumnType.COMBO,
       label = "i18n::AiProviderEditor.Models.Column.ModelName",
-      variables = false)
+      comboValuesMethod = "getModelNameChoices")
   private String modelName = "";
 
   public AiProviderModel() {}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSession.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSession.java
index 972f7176e8..5c40884919 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSession.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSession.java
@@ -18,9 +18,11 @@
 package org.apache.hop.ai.session;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 import java.util.function.Supplier;
 import lombok.Getter;
@@ -30,6 +32,7 @@ import org.apache.hop.ai.advisor.AiAdvisorMetadataSelection;
 import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.advisor.IAiAdvisor;
 import org.apache.hop.ai.engine.AiProposalPreview;
+import org.apache.hop.i18n.BaseMessages;
 
 /**
  * One advisory conversation. Lives in {@link AiAdvisorSessionStore} so perspective, dialog and dock
@@ -51,9 +54,33 @@ public class AiAdvisorSession {
   private String artifactName = "";
   private String artifactKind = "";
   private String focusNodeName = "";
+
+  /**
+   * The file of the pipeline or workflow, kept when its tab closes and {@link #getArtifact()} is
+   * let go, so the session is found again when the file is reopened.
+   */
+  private String artifactFilename;
+
+  /** The project the session was started in; see {@link AiAdvisorSessionStore#getSessions()}. */
+  private String scope;
+
   private Object artifact;
   private Supplier<String> logSupplier;
+
+  /** The latest run of the pipeline or workflow; see {@code AiAdvisorOpenRequest}. */
+  private Supplier<String> runIdSupplier;
+
+  /**
+   * The run that was the latest when the user last switched Logs on or off. That choice holds until
+   * the next run: a new log is what a question after a run is usually about.
+   */
+  private String logChoiceRunId;
+
   private Map<String, Boolean> inclusions = new LinkedHashMap<>();
+
+  /** Inclusions the user switched on or off, which the assistant then leaves alone. */
+  private Set<String> userChosenInclusions = new HashSet<>();
+
   private List<AiAdvisorMetadataSelection> metadataSelections = new ArrayList<>();
   private Map<String, Object> attributes = new LinkedHashMap<>();
   private Map<String, List<String>> inclusionSelections = new LinkedHashMap<>();
@@ -64,6 +91,16 @@ public class AiAdvisorSession {
   private volatile boolean cancelled;
   private volatile Thread workerThread;
 
+  /** The id of the latest run, or null when there was none or it is not known. */
+  public String currentRunId() {
+    Supplier<String> supplier = runIdSupplier;
+    try {
+      return supplier == null ? null : supplier.get();
+    } catch (RuntimeException e) {
+      return null;
+    }
+  }
+
   public boolean isEmpty() {
     return turns.isEmpty();
   }
@@ -92,6 +129,18 @@ public class AiAdvisorSession {
     return copy;
   }
 
+  /**
+   * Forget the summaries a question was sent with, once its answer is recorded. Changes applied
+   * while it was waiting stay for the next question.
+   */
+  public void removePendingAppliedSummaries(List<String> sent) {
+    if (sent != null) {
+      for (String summary : sent) {
+        pendingAppliedSummaries.remove(summary);
+      }
+    }
+  }
+
   public void recordApplied(AiAdvisorTurn turn, List<AiProposal> applied) {
     recordApplied(turn, applied, null);
   }
@@ -117,7 +166,7 @@ public class AiAdvisorSession {
     if (areaLabel != null && !areaLabel.isBlank()) {
       return areaLabel;
     }
-    return "General";
+    return BaseMessages.getString(AiAdvisorSession.class, "AiAdvisorSession.Area.General");
   }
 
   public String displayTitle() {
@@ -127,6 +176,6 @@ public class AiAdvisorSession {
     if (artifactName != null && !artifactName.isBlank()) {
       return artifactName;
     }
-    return "New session";
+    return BaseMessages.getString(AiAdvisorSession.class, "AiAdvisorSession.Title.New");
   }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionArchive.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionArchive.java
new file mode 100644
index 0000000000..639d5f98b0
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionArchive.java
@@ -0,0 +1,329 @@
+/*
+ * 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.hop.ai.session;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.ai.advisor.AiAdvisorMetadataSelection;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.ai.engine.AiMetadataBackup;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+
+/**
+ * Keeps the AI Assistant sessions of a project between Hop GUI runs, in the audit folder ({@code
+ * HOP_AUDIT_FOLDER}), where Hop GUI also remembers the open files of each project.
+ *
+ * <p>Questions, answers, proposals and the session options are kept. The context that was sent with
+ * a question is not: it is gathered again from the open file. The pipeline or workflow itself is
+ * found again by its file name when it is opened.
+ */
+public final class AiAdvisorSessionArchive {
+
+  static final String AUDIT_TYPE = "ai-assistant";
+  static final String AUDIT_NAME = "sessions";
+  static final String DEFAULT_GROUP = "hop-gui";
+
+  /** The newest sessions per project that are kept; older ones are dropped when saving. */
+  static final int MAX_SESSIONS = 50;
+
+  private static final ObjectMapper MAPPER = new ObjectMapper();
+
+  private AiAdvisorSessionArchive() {}
+
+  public static void save(String scope, List<AiAdvisorSession> sessions) throws HopException {
+    List<AiAdvisorSession> kept =
+        sessions.size() > MAX_SESSIONS
+            ? sessions.subList(sessions.size() - MAX_SESSIONS, sessions.size())
+            : sessions;
+    List<Map<String, Object>> list = new ArrayList<>();
+    for (AiAdvisorSession session : kept) {
+      list.add(toMap(session));
+    }
+    Map<String, Object> state = new LinkedHashMap<>();
+    try {
+      state.put("json", MAPPER.writeValueAsString(list));
+    } catch (Exception e) {
+      throw new HopException("Unable to write the AI Assistant sessions", e);
+    }
+    AuditManager.getActive()
+        .storeState(group(scope), AUDIT_TYPE, new AuditState(AUDIT_NAME, state));
+  }
+
+  public static List<AiAdvisorSession> load(String scope) throws HopException {
+    List<AiAdvisorSession> sessions = new ArrayList<>();
+    AuditState state = AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE, AUDIT_NAME);
+    if (state == null || state.getStateMap() == null) {
+      return sessions;
+    }
+    Object json = state.getStateMap().get("json");
+    if (!(json instanceof String text) || Utils.isEmpty(text)) {
+      return sessions;
+    }
+    try {
+      List<Map<String, Object>> list =
+          MAPPER.readValue(text, new TypeReference<List<Map<String, Object>>>() {});
+      for (Map<String, Object> map : list) {
+        AiAdvisorSession session = fromMap(map);
+        session.setScope(scope);
+        sessions.add(session);
+      }
+    } catch (Exception e) {
+      throw new HopException("Unable to read the saved AI Assistant sessions", e);
+    }
+    return sessions;
+  }
+
+  static final String LAST_PROVIDER_NAME = "last-provider";
+
+  /**
+   * Remember the provider picked last in a project, for new sessions when no default provider is
+   * configured. Kept even when conversations are not: it holds only a name.
+   */
+  public static void saveLastProvider(String scope, String providerName) throws HopException {
+    Map<String, Object> state = new LinkedHashMap<>();
+    state.put("name", providerName == null ? "" : providerName);
+    AuditManager.getActive()
+        .storeState(group(scope), AUDIT_TYPE, new AuditState(LAST_PROVIDER_NAME, state));
+  }
+
+  /** The provider picked last in a project, or null when none was remembered. */
+  public static String loadLastProvider(String scope) throws HopException {
+    AuditState state =
+        AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE, LAST_PROVIDER_NAME);
+    if (state == null
+        || state.getStateMap() == null
+        || !(state.getStateMap().get("name") instanceof String name)
+        || Utils.isEmpty(name)) {
+      return null;
+    }
+    return name;
+  }
+
+  static String group(String scope) {
+    return Utils.isEmpty(scope) ? DEFAULT_GROUP : scope;
+  }
+
+  static Map<String, Object> toMap(AiAdvisorSession session) {
+    Map<String, Object> map = new LinkedHashMap<>();
+    map.put("title", session.getTitle());
+    map.put("advisorPluginId", session.getAdvisorPluginId());
+    map.put("scenarioId", session.getScenarioId());
+    map.put("providerName", session.getProviderName());
+    map.put("location", session.getLocation());
+    map.put("areaLabel", session.getAreaLabel());
+    map.put("artifactName", session.getArtifactName());
+    map.put("artifactKind", session.getArtifactKind());
+    map.put("artifactFilename", session.getArtifactFilename());
+    map.put("focusNodeName", session.getFocusNodeName());
+    map.put("inclusions", new LinkedHashMap<>(session.getInclusions()));
+    map.put("userChosenInclusions", new ArrayList<>(session.getUserChosenInclusions()));
+    List<Map<String, String>> selections = new ArrayList<>();
+    for (AiAdvisorMetadataSelection selection : session.getMetadataSelections()) {
+      Map<String, String> entry = new LinkedHashMap<>();
+      entry.put("typeKey", selection.getTypeKey());
+      entry.put("name", selection.getName());
+      selections.add(entry);
+    }
+    map.put("metadataSelections", selections);
+    map.put("inclusionSelections", new LinkedHashMap<>(session.getInclusionSelections()));
+    List<Map<String, Object>> turns = new ArrayList<>();
+    for (AiAdvisorTurn turn : session.getTurns()) {
+      turns.add(toMap(turn));
+    }
+    map.put("turns", turns);
+    // Applied after the last answer: the next question still has to tell the model.
+    map.put("pendingAppliedSummaries", new ArrayList<>(session.getPendingAppliedSummaries()));
+    return map;
+  }
+
+  static Map<String, Object> toMap(AiAdvisorTurn turn) {
+    Map<String, Object> map = new LinkedHashMap<>();
+    map.put("userPrompt", turn.getUserPrompt());
+    map.put("assistantAdvice", turn.getAssistantAdvice());
+    map.put("rawAnswer", turn.getRawAnswer());
+    map.put("errorMessage", turn.getErrorMessage());
+    map.put("proposalBlockPresent", turn.isProposalBlockPresent());
+    map.put("proposalParseError", turn.getProposalParseError());
+    map.put("inputTokenCount", turn.getInputTokenCount());
+    map.put("outputTokenCount", turn.getOutputTokenCount());
+    map.put("durationMs", turn.getDurationMs());
+    map.put("appliedSummaries", new ArrayList<>(turn.getAppliedSummaries()));
+    // Undo of saved metadata stays possible after a restart. The earlier version is kept as the
+    // metadata JSON stores it, passwords encoded.
+    List<Map<String, Object>> backups = new ArrayList<>();
+    for (AiMetadataBackup backup : turn.getMetadataBackups()) {
+      Map<String, Object> entry = new LinkedHashMap<>();
+      entry.put("typeKey", backup.typeKey());
+      entry.put("name", backup.name());
+      entry.put("previousJson", backup.previousJson());
+      entry.put("savedJson", backup.savedJson());
+      backups.add(entry);
+    }
+    map.put("metadataBackups", backups);
+    List<Map<String, Object>> proposals = new ArrayList<>();
+    for (AiProposal proposal : turn.getProposals()) {
+      Map<String, Object> entry = new LinkedHashMap<>();
+      entry.put("id", proposal.getId());
+      entry.put("description", proposal.getDescription());
+      entry.put("riskLevel", proposal.getRiskLevel());
+      entry.put("type", proposal.getType());
+      entry.put("parameters", new LinkedHashMap<>(proposal.getParameters()));
+      proposals.add(entry);
+    }
+    map.put("proposals", proposals);
+    return map;
+  }
+
+  @SuppressWarnings("unchecked")
+  static AiAdvisorSession fromMap(Map<String, Object> map) {
+    AiAdvisorSession session = new AiAdvisorSession();
+    session.setTitle(text(map, "title"));
+    session.setAdvisorPluginId(text(map, "advisorPluginId"));
+    session.setScenarioId(text(map, "scenarioId"));
+    session.setProviderName(text(map, "providerName"));
+    if (!Utils.isEmpty(text(map, "location"))) {
+      session.setLocation(text(map, "location"));
+    }
+    session.setAreaLabel(text(map, "areaLabel"));
+    session.setArtifactName(text(map, "artifactName"));
+    session.setArtifactKind(text(map, "artifactKind"));
+    session.setArtifactFilename((String) map.get("artifactFilename"));
+    session.setFocusNodeName(text(map, "focusNodeName"));
+    if (map.get("inclusions") instanceof Map<?, ?> inclusions) {
+      for (Map.Entry<?, ?> entry : inclusions.entrySet()) {
+        session
+            .getInclusions()
+            .put(String.valueOf(entry.getKey()), Boolean.TRUE.equals(entry.getValue()));
+      }
+    }
+    if (map.get("userChosenInclusions") instanceof List<?> chosen) {
+      session.setUserChosenInclusions(new HashSet<>());
+      for (Object id : chosen) {
+        session.getUserChosenInclusions().add(String.valueOf(id));
+      }
+    }
+    if (map.get("metadataSelections") instanceof List<?> selections) {
+      for (Object item : selections) {
+        if (item instanceof Map<?, ?> entry) {
+          session
+              .getMetadataSelections()
+              .add(
+                  new AiAdvisorMetadataSelection(
+                      String.valueOf(entry.get("typeKey")), String.valueOf(entry.get("name"))));
+        }
+      }
+    }
+    if (map.get("inclusionSelections") instanceof Map<?, ?> selections) {
+      for (Map.Entry<?, ?> entry : selections.entrySet()) {
+        List<String> ids = new ArrayList<>();
+        if (entry.getValue() instanceof List<?> values) {
+          for (Object value : values) {
+            ids.add(String.valueOf(value));
+          }
+        }
+        session.getInclusionSelections().put(String.valueOf(entry.getKey()), ids);
+      }
+    }
+    if (map.get("turns") instanceof List<?> turns) {
+      for (Object item : turns) {
+        if (item instanceof Map<?, ?> turnMap) {
+          session.addTurn(turnFromMap((Map<String, Object>) turnMap));
+        }
+      }
+    }
+    if (map.get("pendingAppliedSummaries") instanceof List<?> pending) {
+      for (Object summary : pending) {
+        session.getPendingAppliedSummaries().add(String.valueOf(summary));
+      }
+    }
+    return session;
+  }
+
+  @SuppressWarnings("unchecked")
+  static AiAdvisorTurn turnFromMap(Map<String, Object> map) {
+    AiAdvisorTurn turn = new AiAdvisorTurn();
+    turn.setUserPrompt(text(map, "userPrompt"));
+    turn.setAssistantAdvice(text(map, "assistantAdvice"));
+    turn.setRawAnswer((String) map.get("rawAnswer"));
+    turn.setErrorMessage((String) map.get("errorMessage"));
+    turn.setProposalBlockPresent(Boolean.TRUE.equals(map.get("proposalBlockPresent")));
+    turn.setProposalParseError((String) map.get("proposalParseError"));
+    turn.setInputTokenCount(number(map.get("inputTokenCount")));
+    turn.setOutputTokenCount(number(map.get("outputTokenCount")));
+    Integer duration = number(map.get("durationMs"));
+    turn.setDurationMs(duration == null ? null : duration.longValue());
+    if (map.get("appliedSummaries") instanceof List<?> applied) {
+      for (Object summary : applied) {
+        turn.getAppliedSummaries().add(String.valueOf(summary));
+      }
+    }
+    if (map.get("metadataBackups") instanceof List<?> backups) {
+      for (Object item : backups) {
+        if (item instanceof Map<?, ?> entry
+            && entry.get("typeKey") instanceof String typeKey
+            && entry.get("name") instanceof String name) {
+          turn.getMetadataBackups()
+              .add(
+                  new AiMetadataBackup(
+                      typeKey,
+                      name,
+                      (String) entry.get("previousJson"),
+                      (String) entry.get("savedJson")));
+        }
+      }
+    }
+    if (map.get("proposals") instanceof List<?> proposals) {
+      for (Object item : proposals) {
+        if (item instanceof Map<?, ?> entry) {
+          AiProposal proposal = new AiProposal();
+          proposal.setId((String) entry.get("id"));
+          proposal.setDescription((String) entry.get("description"));
+          if (entry.get("riskLevel") instanceof String risk) {
+            proposal.setRiskLevel(risk);
+          }
+          proposal.setType((String) entry.get("type"));
+          if (entry.get("parameters") instanceof Map<?, ?> parameters) {
+            for (Map.Entry<?, ?> parameter : parameters.entrySet()) {
+              proposal
+                  .getParameters()
+                  .put(String.valueOf(parameter.getKey()), String.valueOf(parameter.getValue()));
+            }
+          }
+          turn.getProposals().add(proposal);
+        }
+      }
+    }
+    return turn;
+  }
+
+  private static String text(Map<String, Object> map, String key) {
+    Object value = map.get(key);
+    return value == null ? "" : String.valueOf(value);
+  }
+
+  private static Integer number(Object value) {
+    return value instanceof Number number ? number.intValue() : null;
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionStore.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionStore.java
index f5f40a2962..e9a29b599f 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionStore.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionStore.java
@@ -18,14 +18,25 @@
 package org.apache.hop.ai.session;
 
 import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Supplier;
 import org.apache.hop.ai.advisor.AiAdvisorOpenRequest;
+import org.apache.hop.ai.config.HopAiConfigSingleton;
+import org.apache.hop.core.file.IHasFilename;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.gui.HopNamespace;
 import org.apache.hop.ui.hopgui.HopGui;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Shell;
 
 /**
@@ -34,11 +45,26 @@ import org.eclipse.swt.widgets.Shell;
  */
 public class AiAdvisorSessionStore {
 
+  private static final Class<?> PKG = AiAdvisorSessionStore.class;
+
   static final String SHELL_DATA_KEY = AiAdvisorSessionStore.class.getName();
 
   private final List<AiAdvisorSession> sessions = new ArrayList<>();
   private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
   private String activeSessionId;
+
+  /** Keeps conversations in the audit folder; see {@link AiAdvisorSessionArchive}. */
+  boolean persistent;
+
+  private final Set<String> loadedScopes = new HashSet<>();
+  private boolean saveScheduled;
+
+  /**
+   * The provider picked last in each project, for new sessions when no default provider is
+   * configured. Remembered in the audit folder.
+   */
+  private final Map<String, String> lastProviderNames = new HashMap<>();
+
   private boolean firing;
 
   public static AiAdvisorSessionStore get(HopGui hopGui) {
@@ -51,16 +77,92 @@ public class AiAdvisorSessionStore {
       return store;
     }
     AiAdvisorSessionStore store = new AiAdvisorSessionStore();
+    // Only the store of Hop GUI keeps conversations on disk, not the throwaway ones of tests.
+    store.persistent = true;
     shell.setData(SHELL_DATA_KEY, store);
+    // A change made just before Hop GUI closes is still waiting for its delayed save.
+    shell.addListener(
+        SWT.Dispose,
+        e -> {
+          store.stopWaitingQuestions();
+          store.saveNow();
+        });
     return store;
   }
 
+  /**
+   * The project a session belongs to: Hop GUI's namespace, which is the name of the open project.
+   * Replaceable so tests can switch projects.
+   */
+  Supplier<String> scope = AiAdvisorSessionStore::currentNamespace;
+
+  static String currentNamespace() {
+    try {
+      return HopNamespace.getNamespace();
+    } catch (RuntimeException e) {
+      return null;
+    }
+  }
+
+  /**
+   * The sessions of the open project. A session holds its pipeline, its conversation and the
+   * provider and metadata it names, which all belong to the project it was started in.
+   */
   public List<AiAdvisorSession> getSessions() {
-    return sessions;
+    String current = scope.get();
+    loadOnce(current);
+    List<AiAdvisorSession> visible = new ArrayList<>();
+    for (AiAdvisorSession session : sessions) {
+      if (Objects.equals(session.getScope(), current)) {
+        visible.add(session);
+      }
+    }
+    return visible;
   }
 
   public AiAdvisorSession getActiveSession() {
-    return find(activeSessionId);
+    AiAdvisorSession active = find(activeSessionId);
+    if (active == null) {
+      // After a project switch the active session can belong to the other project.
+      List<AiAdvisorSession> visible = getSessions();
+      if (!visible.isEmpty()) {
+        active = visible.get(visible.size() - 1);
+        activeSessionId = active.getId();
+      }
+    }
+    return active;
+  }
+
+  /** The provider picked last in the open project, or null. */
+  public String getLastProviderName() {
+    String key = AiAdvisorSessionArchive.group(scope.get());
+    if (!lastProviderNames.containsKey(key)) {
+      String saved = null;
+      if (persistent) {
+        try {
+          saved = AiAdvisorSessionArchive.loadLastProvider(key);
+        } catch (Exception e) {
+          LogChannel.UI.logError("Unable to read the last used AI provider", e);
+        }
+      }
+      lastProviderNames.put(key, saved);
+    }
+    return lastProviderNames.get(key);
+  }
+
+  public void setLastProviderName(String lastProviderName) {
+    String key = AiAdvisorSessionArchive.group(scope.get());
+    if (Objects.equals(lastProviderNames.get(key), lastProviderName)) {
+      return;
+    }
+    lastProviderNames.put(key, lastProviderName);
+    if (persistent) {
+      try {
+        AiAdvisorSessionArchive.saveLastProvider(key, lastProviderName);
+      } catch (Exception e) {
+        LogChannel.UI.logError("Unable to remember the last used AI provider", e);
+      }
+    }
   }
 
   public String getActiveSessionId() {
@@ -76,7 +178,7 @@ public class AiAdvisorSessionStore {
     if (sessionId == null) {
       return null;
     }
-    for (AiAdvisorSession session : sessions) {
+    for (AiAdvisorSession session : getSessions()) {
       if (sessionId.equals(session.getId())) {
         return session;
       }
@@ -85,6 +187,8 @@ public class AiAdvisorSessionStore {
   }
 
   public AiAdvisorSession add(AiAdvisorSession session) {
+    session.setScope(scope.get());
+    loadOnce(session.getScope());
     sessions.add(session);
     activeSessionId = session.getId();
     fireChanged();
@@ -92,9 +196,15 @@ public class AiAdvisorSessionStore {
   }
 
   public void remove(String sessionId) {
+    AiAdvisorSession removed = find(sessionId);
+    if (removed != null) {
+      // A request that is still running would otherwise go on, and keep spending tokens.
+      removed.requestCancel();
+    }
     sessions.removeIf(session -> session.getId().equals(sessionId));
     if (Objects.equals(activeSessionId, sessionId)) {
-      activeSessionId = sessions.isEmpty() ? null : sessions.get(sessions.size() - 1).getId();
+      List<AiAdvisorSession> visible = getSessions();
+      activeSessionId = visible.isEmpty() ? null : visible.get(visible.size() - 1).getId();
     }
     fireChanged();
   }
@@ -103,33 +213,92 @@ public class AiAdvisorSessionStore {
     if (request == null || !request.isReuseExisting()) {
       return null;
     }
-    for (AiAdvisorSession session : sessions) {
+    AiAdvisorSession sameFile = null;
+    for (AiAdvisorSession session : getSessions()) {
       if (!Objects.equals(session.getAdvisorPluginId(), nvl(request.getAdvisorPluginId()))) {
         continue;
       }
       if (!Objects.equals(nvl(session.getLocation()), nvl(request.getLocation()))) {
         continue;
       }
-      if (!Objects.equals(nvl(session.getArtifactName()), nvl(request.getArtifactName()))) {
+      if (request.getArtifact() == null) {
+        // Nothing to identify the pipeline or workflow by, so fall back to the name.
+        if (session.getArtifact() == null
+            && Utils.isEmpty(session.getArtifactFilename())
+            && Objects.equals(nvl(session.getArtifactName()), nvl(request.getArtifactName()))) {
+          return session;
+        }
         continue;
       }
-      return session;
+      if (session.getArtifact() == request.getArtifact()) {
+        return session;
+      }
+      // The same saved file, opened again after its tab was closed. A name is not enough: two
+      // files in different folders, or two unsaved pipelines, can have the same name.
+      String filename = filenameOf(request.getArtifact());
+      if (sameFile == null && !Utils.isEmpty(filename) && filename.equals(filenameOf(session))) {
+        sameFile = session;
+      }
+    }
+    return sameFile;
+  }
+
+  static String filenameOf(Object artifact) {
+    return artifact instanceof IHasFilename hasFilename ? hasFilename.getFilename() : null;
+  }
+
+  static String filenameOf(AiAdvisorSession session) {
+    String filename = filenameOf(session.getArtifact());
+    return filename != null ? filename : session.getArtifactFilename();
+  }
+
+  /**
+   * A pipeline or workflow tab was closed: its sessions let go of the file and its log, and keep
+   * the conversation and the file name for when the file is opened again.
+   */
+  public void release(Object artifact) {
+    boolean changed = false;
+    for (AiAdvisorSession session : sessions) {
+      if (artifact != null && session.getArtifact() == artifact) {
+        String filename = filenameOf(artifact);
+        if (!Utils.isEmpty(filename)) {
+          session.setArtifactFilename(filename);
+        }
+        session.setArtifact(null);
+        session.setLogSupplier(null);
+        session.setRunIdSupplier(null);
+        changed = true;
+      }
+    }
+    if (changed) {
+      fireChanged();
     }
-    return null;
   }
 
   public AiAdvisorSession open(AiAdvisorOpenRequest request) {
     AiAdvisorSession existing = findReusable(request);
     if (existing != null) {
-      if (!Utils.isEmpty(request.getFocusNodeName())) {
-        existing.setFocusNodeName(request.getFocusNodeName());
-      }
+      // AI Help opened on the pipeline or workflow itself clears a focus set from a transform or
+      // action earlier, so that node's XML is no longer sent.
+      existing.setFocusNodeName(nvl(request.getFocusNodeName()));
       if (request.getArtifact() != null) {
         existing.setArtifact(request.getArtifact());
+        existing.setArtifactFilename(filenameOf(request.getArtifact()));
+      }
+      // A new pipeline gets its name from the file name when it is first saved.
+      if (Objects.equals(existing.getTitle(), existing.getArtifactName())
+          && !Utils.isEmpty(request.getTitle())) {
+        existing.setTitle(request.getTitle());
+      }
+      if (!Utils.isEmpty(request.getArtifactName())) {
+        existing.setArtifactName(request.getArtifactName());
       }
       if (request.getLogSupplier() != null) {
         existing.setLogSupplier(request.getLogSupplier());
       }
+      if (request.getRunIdSupplier() != null) {
+        existing.setRunIdSupplier(request.getRunIdSupplier());
+      }
       mergeAttributes(existing, request);
       activeSessionId = existing.getId();
       fireChanged();
@@ -146,12 +315,43 @@ public class AiAdvisorSessionStore {
       session.setArtifactKind(nvl(request.getArtifactKind()));
       session.setFocusNodeName(nvl(request.getFocusNodeName()));
       session.setArtifact(request.getArtifact());
+      session.setArtifactFilename(filenameOf(request.getArtifact()));
       session.setLogSupplier(request.getLogSupplier());
+      session.setRunIdSupplier(request.getRunIdSupplier());
       session.setAttributes(copyAttributes(request.getAttributes()));
     }
     return add(session);
   }
 
+  /**
+   * Link a session that has no pipeline or workflow to one. It takes the advisor, title and log of
+   * that file, and keeps its conversation.
+   */
+  public void link(AiAdvisorSession session, AiAdvisorOpenRequest request) {
+    if (session == null || request == null || request.getArtifact() == null) {
+      return;
+    }
+    session.setAdvisorPluginId(nvl(request.getAdvisorPluginId()));
+    session.setScenarioId("");
+    if (request.getLocation() != null) {
+      session.setLocation(request.getLocation());
+    }
+    session.setAreaLabel(nvl(request.getAreaLabel()));
+    session.setArtifact(request.getArtifact());
+    session.setArtifactFilename(filenameOf(request.getArtifact()));
+    session.setArtifactName(nvl(request.getArtifactName()));
+    session.setArtifactKind(nvl(request.getArtifactKind()));
+    session.setLogSupplier(request.getLogSupplier());
+    session.setRunIdSupplier(request.getRunIdSupplier());
+    session.setFocusNodeName(nvl(request.getFocusNodeName()));
+    if (session.isEmpty() || Utils.isEmpty(session.getTitle())) {
+      session.setTitle(nvl(request.getTitle()));
+    }
+    mergeAttributes(session, request);
+    activeSessionId = session.getId();
+    fireChanged();
+  }
+
   static void mergeAttributes(AiAdvisorSession session, AiAdvisorOpenRequest request) {
     if (session == null || request == null || request.getAttributes() == null) {
       return;
@@ -180,6 +380,7 @@ public class AiAdvisorSessionStore {
     if (firing) {
       return;
     }
+    saveSoon();
     firing = true;
     try {
       for (Runnable listener : listeners) {
@@ -193,4 +394,85 @@ public class AiAdvisorSessionStore {
   private static String nvl(String value) {
     return value == null ? "" : value;
   }
+
+  private static boolean keepConversations() {
+    return HopAiConfigSingleton.getConfig().isKeepConversations();
+  }
+
+  /**
+   * Bring back the saved conversations of a project the first time its sessions are shown. Only
+   * once they were read is the project saved: while Keep conversations is off nothing is read, and
+   * switching it on later must not write the sessions in memory over the saved ones.
+   */
+  private void loadOnce(String scopeName) {
+    String key = AiAdvisorSessionArchive.group(scopeName);
+    if (!persistent || !keepConversations() || !loadedScopes.add(key)) {
+      return;
+    }
+    try {
+      List<AiAdvisorSession> saved = AiAdvisorSessionArchive.load(scopeName);
+      sessions.addAll(0, saved);
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to read the saved AI Assistant conversations", e);
+    }
+  }
+
+  /** Save a moment after a change, so a burst of changes is written once. */
+  private void saveSoon() {
+    if (!persistent || saveScheduled) {
+      return;
+    }
+    Display display = Display.getCurrent();
+    if (display == null) {
+      saveNow();
+      return;
+    }
+    saveScheduled = true;
+    display.timerExec(
+        1500,
+        () -> {
+          saveScheduled = false;
+          saveNow();
+        });
+  }
+
+  /**
+   * Hop GUI exits: a question still waiting for its answer cannot finish, as the JVM stops once the
+   * main window is gone. Its turn says so instead of staying unanswered after a restart.
+   */
+  void stopWaitingQuestions() {
+    for (AiAdvisorSession session : sessions) {
+      if (!session.isWorking()) {
+        continue;
+      }
+      session.requestCancel();
+      session.setWorking(false);
+      session.setWorkerThread(null);
+      if (!session.isEmpty()) {
+        AiAdvisorTurn turn = session.getTurns().get(session.getTurns().size() - 1);
+        if (Utils.isEmpty(turn.getAssistantAdvice())) {
+          turn.setErrorMessage(BaseMessages.getString(PKG, "AiAdvisorSessionStore.StoppedByExit"));
+        }
+      }
+    }
+  }
+
+  void saveNow() {
+    if (!persistent || !keepConversations()) {
+      return;
+    }
+    for (String key : loadedScopes) {
+      List<AiAdvisorSession> inScope = new ArrayList<>();
+      for (AiAdvisorSession session : sessions) {
+        if (key.equals(AiAdvisorSessionArchive.group(session.getScope()))) {
+          inScope.add(session);
+        }
+      }
+      try {
+        AiAdvisorSessionArchive.save(key, inScope);
+      } catch (Exception e) {
+        LogChannel.UI.logError("Unable to save the AI Assistant conversations", e);
+      }
+    }
+  }
 }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorTurn.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorTurn.java
index c4a1f9c2e4..21656987e4 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorTurn.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorTurn.java
@@ -22,6 +22,7 @@ import java.util.List;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.ai.engine.AiMetadataBackup;
 
 /** One user/assistant exchange in an {@link AiAdvisorSession}. */
 @Getter
@@ -29,10 +30,35 @@ import org.apache.hop.ai.advisor.AiProposal;
 public class AiAdvisorTurn {
   private String userPrompt = "";
   private String assistantAdvice = "";
+
+  /**
+   * The answer as the model wrote it, proposal block included. The conversation history replays
+   * this: with the block stripped the model learns that its own answers end in an empty example.
+   */
+  private String rawAnswer;
+
   private String errorMessage;
   private List<AiProposal> proposals = new ArrayList<>();
   private List<String> appliedSummaries = new ArrayList<>();
   private boolean proposalBlockPresent;
+  private String proposalParseError;
+
+  /** When the question was sent, for the seconds counter while waiting. */
+  private long startedAtMillis;
+
+  /** Rough size of what was sent, shown while waiting; null when not known. */
+  private Integer estimatedPromptTokens;
+
+  /** The provider and model the question went to, shown while waiting. */
+  private String providerLabel;
+
+  /**
+   * The applied-change summaries this question was sent with, taken off the session once its answer
+   * is recorded. Not kept between Hop GUI runs.
+   */
+  private List<String> sentAppliedSummaries = new ArrayList<>();
+
+  private List<AiMetadataBackup> metadataBackups = new ArrayList<>();
   private Integer inputTokenCount;
   private Integer outputTokenCount;
   private Long durationMs;
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorDialog.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorDialog.java
index 2ddf53f601..cc1e18dbb2 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorDialog.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorDialog.java
@@ -68,6 +68,17 @@ public class AiAdvisorDialog {
     return dialog;
   }
 
+  /** Close the floating window, if it is open. */
+  public static void close(HopGui hopGui) {
+    if (hopGui == null || hopGui.getShell() == null || hopGui.getShell().isDisposed()) {
+      return;
+    }
+    if (hopGui.getShell().getData(SHELL_DATA_KEY) instanceof AiAdvisorDialog dialog
+        && dialog.isOpen()) {
+      dialog.shell.close();
+    }
+  }
+
   boolean isOpen() {
     return shell != null && !shell.isDisposed();
   }
@@ -110,6 +121,7 @@ public class AiAdvisorDialog {
 
     shell.addDisposeListener(
         e -> {
+          keyHandler.removeParentObjectToHandle(workbench);
           props.setScreen(new WindowProperty(shell));
           if (hopGui.getShell() != null
               && !hopGui.getShell().isDisposed()
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorMetadataSelectionDialog.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorMetadataSelectionDialog.java
index f7ffedfc79..12c3b592c7 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorMetadataSelectionDialog.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorMetadataSelectionDialog.java
@@ -81,11 +81,16 @@ public class AiAdvisorMetadataSelectionDialog {
     Button wCancel = new Button(shell, SWT.PUSH);
     wCancel.setText(BaseMessages.getString(PKG, "AiAdvisorMetadataSelectionDialog.Cancel.Label"));
     wCancel.addListener(SWT.Selection, e -> cancel());
+    Button wAll = new Button(shell, SWT.PUSH);
+    wAll.setText(BaseMessages.getString(PKG, "AiAdvisorMetadataSelectionDialog.All.Label"));
+    wAll.setToolTipText(
+        BaseMessages.getString(PKG, "AiAdvisorMetadataSelectionDialog.All.Tooltip"));
+    wAll.addListener(SWT.Selection, e -> selectAllShown());
     Button wNone = new Button(shell, SWT.PUSH);
     wNone.setText(BaseMessages.getString(PKG, "AiAdvisorMetadataSelectionDialog.None.Label"));
     wNone.addListener(SWT.Selection, e -> clearAll());
     BaseTransformDialog.positionBottomButtons(
-        shell, new Button[] {wOk, wCancel, wNone}, margin, null);
+        shell, new Button[] {wOk, wCancel, wAll, wNone}, margin, null);
 
     Label wlFilter = new Label(shell, SWT.LEFT);
     wlFilter.setText(BaseMessages.getString(PKG, "AiAdvisorMetadataSelectionDialog.Filter.Label"));
@@ -219,6 +224,18 @@ public class AiAdvisorMetadataSelectionDialog {
     parentItem.setGrayed(checkedCount > 0 && checkedCount < children.length);
   }
 
+  /** Everything the tree shows, so a filter narrows what All selects. */
+  private void selectAllShown() {
+    for (TreeItem typeItem : wTree.getItems()) {
+      for (TreeItem child : typeItem.getItems()) {
+        if (child.getData(DATA_SELECTION) instanceof AiAdvisorMetadataSelection selection) {
+          checked.add(selection);
+        }
+      }
+    }
+    rebuildTree();
+  }
+
   private void clearAll() {
     checked.clear();
     rebuildTree();
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPerspective.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPerspective.java
index 993342f01e..e146d3c14a 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPerspective.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPerspective.java
@@ -111,6 +111,8 @@ public class AiAdvisorPerspective implements IHopPerspective, IAiAdvisorWorkbenc
     keyHandler.addParentObjectToHandle(this);
     keyHandler.addParentObjectToHandle(workbench);
     hopGui.replaceKeyboardShortcutListeners(workbench, keyHandler);
+
+    AiAdvisorViews.restoreDock(hopGui);
   }
 
   @Override
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPlugins.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPlugins.java
index b73cc95078..67acf0ea85 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPlugins.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorPlugins.java
@@ -20,7 +20,6 @@ package org.apache.hop.ai.ui;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
-import org.apache.hop.ai.advisor.AiAdvisorLocations;
 import org.apache.hop.ai.advisor.AiAdvisorPluginType;
 import org.apache.hop.ai.advisor.IAiAdvisor;
 import org.apache.hop.core.exception.HopException;
@@ -41,12 +40,13 @@ public final class AiAdvisorPlugins {
   }
 
   /**
-   * Advisors for this session location. Empty {@code locations()} means any session. The unbound
-   * perspective lists every advisor.
+   * Advisors for this session location. Empty {@code locations()} means any session. A session that
+   * is not bound to a file (location {@code perspective}) only lists advisors that declare they
+   * work there: the pipeline and workflow advisors need their file and would fail.
    */
   public static List<IPlugin> listForLocation(String location) {
     List<IPlugin> all = list();
-    if (Utils.isEmpty(location) || AiAdvisorLocations.PERSPECTIVE.equals(location)) {
+    if (Utils.isEmpty(location)) {
       return all;
     }
     List<IPlugin> matching = new ArrayList<>();
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProjectEnabledExtension.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProjectEnabledExtension.java
new file mode 100644
index 0000000000..3ed5a65fd3
--- /dev/null
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProjectEnabledExtension.java
@@ -0,0 +1,47 @@
+/*
+ * 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.hop.ai.ui;
+
+import org.apache.hop.ai.session.AiAdvisorSessionStore;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPoint;
+import org.apache.hop.core.extension.IExtensionPoint;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.ui.hopgui.HopGui;
+
+/**
+ * Shows the sessions of the project that was just opened. The session list follows the open
+ * project; this tells the open AI Assistant views to redraw it.
+ */
+@ExtensionPoint(
+    id = "AiAdvisorProjectEnabledExtension",
+    extensionPointId = "HopGuiProjectAfterEnabled",
+    description = "Show the AI Assistant sessions of the project that was opened",
+    classLoaderGroup = "hop-ai")
+public class AiAdvisorProjectEnabledExtension implements IExtensionPoint<Object> {
+  @Override
+  public void callExtensionPoint(ILogChannel log, IVariables variables, Object project)
+      throws HopException {
+    HopGui hopGui = HopGui.peekInstance();
+    if (hopGui == null || hopGui.getShell() == null || hopGui.getShell().isDisposed()) {
+      return;
+    }
+    AiAdvisorSessionStore store = AiAdvisorSessionStore.get(hopGui);
+    hopGui.getShell().getDisplay().asyncExec(store::fireChanged);
+  }
+}
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProposalReviewDialog.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProposalReviewDialog.java
index 917c000f90..30786f12e2 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProposalReviewDialog.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorProposalReviewDialog.java
@@ -29,8 +29,10 @@ import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.graphics.Point;
 import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.MessageBox;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.Table;
 import org.eclipse.swt.widgets.TableColumn;
@@ -49,6 +51,7 @@ public class AiAdvisorProposalReviewDialog {
   private final Function<AiProposal, String> previewFn;
   private Shell shell;
   private Table wProposals;
+  private Button wApply;
   private Text wPreview;
   private boolean applied;
   private List<AiProposal> selectedProposals = List.of();
@@ -72,7 +75,7 @@ public class AiAdvisorProposalReviewDialog {
 
     int margin = PropsUi.getMargin();
 
-    Button wApply = new Button(shell, SWT.PUSH);
+    wApply = new Button(shell, SWT.PUSH);
     wApply.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Apply.Label"));
     wApply.setToolTipText(
         BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Apply.Tooltip"));
@@ -102,17 +105,32 @@ public class AiAdvisorProposalReviewDialog {
     wProposals =
         new Table(
             shell,
-            SWT.CHECK | SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL | SWT.FULL_SELECTION | SWT.MULTI);
+            SWT.CHECK | SWT.BORDER | SWT.V_SCROLL | SWT.H_SCROLL | SWT.FULL_SELECTION | SWT.SINGLE);
     PropsUi.setLook(wProposals);
-    wProposals.setHeaderVisible(false);
+    wProposals.setHeaderVisible(true);
     wProposals.setLinesVisible(true);
+    // The state is also spelled out in its own column: on some themes the check box of a table row
+    // is hard to see, and a highlighted row is easily mistaken for a selected proposal.
+    TableColumn stateColumn = new TableColumn(wProposals, SWT.NONE);
+    stateColumn.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Column.State"));
     TableColumn column = new TableColumn(wProposals, SWT.NONE);
+    column.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Column.Proposal"));
     column.setResizable(true);
     column.setWidth(580);
+
+    Button wAll = new Button(shell, SWT.PUSH);
+    wAll.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.All.Label"));
+    wAll.addListener(SWT.Selection, e -> chooseAll(true));
+    Button wNone = new Button(shell, SWT.PUSH);
+    wNone.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.None.Label"));
+    wNone.addListener(SWT.Selection, e -> chooseAll(false));
+    wNone.setLayoutData(new FormDataBuilder().right(100, -margin).top(0, margin).result());
+    wAll.setLayoutData(new FormDataBuilder().right(wNone, -margin).top(0, margin).result());
+
     wProposals.setLayoutData(
         new FormDataBuilder()
             .left(0, margin)
-            .top(wlList, margin)
+            .top(wAll, margin)
             .right(100, -margin)
             .bottom(wlPreview, -margin)
             .result());
@@ -122,25 +140,42 @@ public class AiAdvisorProposalReviewDialog {
       AiProposalValidation validation = i < validations.size() ? validations.get(i) : null;
       boolean blocked = validation != null && validation.isBlocked();
       TableItem item = new TableItem(wProposals, SWT.NONE);
-      item.setText(proposalLabel(proposal, validation));
-      item.setChecked(!blocked);
+      item.setText(1, proposalLabel(proposal, validation));
       if (blocked) {
         item.setData(BLOCKED_ITEM_KEY, Boolean.TRUE);
       }
+      // Deletes, replacements and overwrites wait for the user to choose them.
+      setChosen(item, !blocked && !(validation != null && validation.isOptIn()));
     }
+    stateColumn.pack();
     column.pack();
 
     wProposals.addListener(
         SWT.Selection,
         e -> {
-          if (e.detail == SWT.CHECK) {
-            TableItem item = (TableItem) e.item;
-            if (item != null && Boolean.TRUE.equals(item.getData(BLOCKED_ITEM_KEY))) {
-              item.setChecked(false);
-            }
+          if (e.detail == SWT.CHECK && e.item instanceof TableItem item) {
+            setChosen(item, item.getChecked());
           }
           updatePreview();
         });
+    // A click in the state column, or a double click anywhere, also switches the proposal on/off.
+    wProposals.addListener(
+        SWT.MouseDown,
+        e -> {
+          TableItem item = wProposals.getItem(new Point(e.x, e.y));
+          if (item != null && item.getBounds(0).contains(e.x, e.y)) {
+            setChosen(item, !item.getChecked());
+            updatePreview();
+          }
+        });
+    wProposals.addListener(
+        SWT.DefaultSelection,
+        e -> {
+          if (e.item instanceof TableItem item) {
+            setChosen(item, !item.getChecked());
+            updatePreview();
+          }
+        });
 
     wPreview =
         new Text(shell, SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | SWT.H_SCROLL | SWT.READ_ONLY);
@@ -153,7 +188,11 @@ public class AiAdvisorProposalReviewDialog {
             .right(100, -margin)
             .result());
 
+    if (wProposals.getItemCount() > 0) {
+      wProposals.setSelection(0);
+    }
     updatePreview();
+    updateApplyButton();
     BaseTransformDialog.setSize(shell);
     shell.open();
     while (!shell.isDisposed()) {
@@ -182,21 +221,74 @@ public class AiAdvisorProposalReviewDialog {
     return selected;
   }
 
+  private void setChosen(TableItem item, boolean chosen) {
+    boolean blocked = Boolean.TRUE.equals(item.getData(BLOCKED_ITEM_KEY));
+    boolean value = chosen && !blocked;
+    item.setChecked(value);
+    String state;
+    if (blocked) {
+      state = "AiAdvisorProposalReviewDialog.State.Blocked";
+    } else if (value) {
+      state = "AiAdvisorProposalReviewDialog.State.Apply";
+    } else {
+      state = "AiAdvisorProposalReviewDialog.State.Skip";
+    }
+    item.setText(0, BaseMessages.getString(PKG, state));
+    updateApplyButton();
+  }
+
+  private void chooseAll(boolean chosen) {
+    for (TableItem item : wProposals.getItems()) {
+      setChosen(item, chosen);
+    }
+    updatePreview();
+  }
+
+  private void updateApplyButton() {
+    if (wApply == null || wApply.isDisposed()) {
+      return;
+    }
+    int count = readSelectedFromWidgets().size();
+    wApply.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Apply.Count", count));
+    wApply.getParent().layout(true, true);
+  }
+
+  /** The highlighted proposal, or the first one: what it does and whether it will be applied. */
   private void updatePreview() {
+    int index = Math.max(0, wProposals.getSelectionIndex());
+    if (index >= proposals.size()) {
+      wPreview.setText("");
+      return;
+    }
+    TableItem item = wProposals.getItem(index);
+    AiProposalValidation validation = index < validations.size() ? validations.get(index) : null;
     StringBuilder preview = new StringBuilder();
-    List<AiProposal> selected = readSelectedFromWidgets();
-    for (int i = 0; i < selected.size(); i++) {
-      if (i > 0) {
-        preview.append("\n\n---\n\n");
-      }
-      preview.append(previewFn.apply(selected.get(i)));
+    if (validation != null && validation.isBlocked()) {
+      preview.append(
+          BaseMessages.getString(
+              PKG, "AiAdvisorProposalReviewDialog.Preview.Blocked", validation.getReason()));
+    } else if (item.getChecked()) {
+      preview.append(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Preview.Apply"));
+    } else {
+      preview.append(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Preview.Skip"));
+    }
+    if (validation != null && !Utils.isEmpty(validation.getWarning())) {
+      preview.append('\n').append(validation.getWarning());
     }
+    preview.append("\n\n").append(previewFn.apply(proposals.get(index)));
     wPreview.setText(preview.toString());
   }
 
   private void applySelected() {
     selectedProposals = readSelectedFromWidgets();
-    applied = !selectedProposals.isEmpty();
+    if (selectedProposals.isEmpty()) {
+      MessageBox box = new MessageBox(shell, SWT.ICON_INFORMATION | SWT.OK);
+      box.setText(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.Title"));
+      box.setMessage(BaseMessages.getString(PKG, "AiAdvisorProposalReviewDialog.NothingChosen"));
+      box.open();
+      return;
+    }
+    applied = true;
     PropsUi.getInstance().setScreen(new WindowProperty(shell));
     shell.dispose();
   }
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java
index e9bef66663..4fb21d9f69 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java
@@ -18,10 +18,15 @@
 package org.apache.hop.ai.ui;
 
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import org.apache.hop.ai.advisor.AiAdvisorInclusion;
 import org.apache.hop.ai.advisor.AiAdvisorInclusionChoice;
 import org.apache.hop.ai.advisor.AiAdvisorLocations;
@@ -33,14 +38,19 @@ import org.apache.hop.ai.advisor.AiProposal;
 import org.apache.hop.ai.advisor.AiProposalValidation;
 import org.apache.hop.ai.advisor.IAiAdvisor;
 import org.apache.hop.ai.advisors.AiAdvisorInclusions;
+import org.apache.hop.ai.advisors.pipeline.PipelineAiAdvisor;
+import org.apache.hop.ai.advisors.workflow.WorkflowAiAdvisor;
+import org.apache.hop.ai.config.AiRequestOnClose;
 import org.apache.hop.ai.config.HopAiConfig;
 import org.apache.hop.ai.config.HopAiConfigSingleton;
 import org.apache.hop.ai.engine.AiAdvisorEngine;
 import org.apache.hop.ai.engine.AiAdvisorExtraContext;
 import org.apache.hop.ai.engine.AiClipboardProposals;
+import org.apache.hop.ai.engine.AiMetadataBackup;
 import org.apache.hop.ai.engine.AiMetadataProposalSupport;
 import org.apache.hop.ai.engine.AiProposalPreview;
 import org.apache.hop.ai.engine.AiProposalTypes;
+import org.apache.hop.ai.engine.AiUserException;
 import org.apache.hop.ai.metadata.AiProvider;
 import org.apache.hop.ai.session.AiAdvisorSession;
 import org.apache.hop.ai.session.AiAdvisorSessionStore;
@@ -49,12 +59,14 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.FormDataBuilder;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.bus.HopGuiEvents;
 import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
+import org.apache.hop.ui.core.dialog.EnterTextDialog;
 import org.apache.hop.ui.core.dialog.ErrorDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.widget.MetaSelectionLine;
@@ -65,10 +77,12 @@ import org.apache.hop.ui.hopgui.file.shared.HopGuiAbstractGraph;
 import org.apache.hop.ui.hopgui.perspective.TabItemHandler;
 import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
 import org.apache.hop.ui.util.EnvironmentUtils;
+import org.apache.hop.ui.util.HelpUtils;
 import org.eclipse.swt.SWT;
 import org.eclipse.swt.events.PaintEvent;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
 import org.eclipse.swt.layout.RowLayout;
 import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Combo;
@@ -77,7 +91,9 @@ import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Display;
 import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Link;
 import org.eclipse.swt.widgets.Listener;
+import org.eclipse.swt.widgets.MessageBox;
 import org.eclipse.swt.widgets.Text;
 
 /** Chat UI for the selected {@link AiAdvisorSession}. */
@@ -103,67 +119,316 @@ public class AiAdvisorSessionPane extends Composite {
   private final Map<String, Button> inclusionButtons = new LinkedHashMap<>();
   private final Map<String, Button> inclusionPickers = new LinkedHashMap<>();
   private Label wlStatus;
+  private Composite questionArea;
+
+  /** Position in the earlier questions while browsing with Up and Down; -1 is the draft. */
+  private int historyIndex = -1;
+
+  private String historyDraft = "";
+  private boolean showingHistory;
+  private FormData questionData;
+  private FormData statusData;
+  private Link wFocus;
+  private FormData focusData;
   private AiAdvisorTranscriptPanel transcript;
   private Text wPrompt;
   private Button wSend;
   private Button wMetadataSelect;
   private final List<IPlugin> advisorPlugins = new ArrayList<>();
   private final SendShortcutGuard sendShortcutGuard = new SendShortcutGuard();
+
+  /** Sessions with a question sent from this view, for {@link #cancelWhenClosed()}. */
+  private final Set<AiAdvisorSession> sentFromHere =
+      Collections.newSetFromMap(new IdentityHashMap<>());
+
   private boolean updatingUi;
 
   public AiAdvisorSessionPane(Composite parent, IAiAdvisorWorkbenchHost host) {
+    this(parent, host, AiAdvisorSessionStore.get(host.getHopGui()));
+  }
+
+  AiAdvisorSessionPane(
+      Composite parent, IAiAdvisorWorkbenchHost host, AiAdvisorSessionStore store) {
     super(parent, SWT.NONE);
     this.host = host;
-    this.store = AiAdvisorSessionStore.get(host.getHopGui());
+    this.store = store;
     PropsUi.setLook(this);
     setLayout(PropsUi.getInstance().createFormLayout());
     int margin = PropsUi.getMargin();
     GuiResource gui = GuiResource.getInstance();
 
-    Label wQuestion = new Label(this, SWT.CENTER);
-    wQuestion.setImage(
-        gui.getImage("ui/images/help.svg", ConstUi.LARGE_ICON_SIZE, ConstUi.LARGE_ICON_SIZE));
-    wQuestion.setToolTipText(BaseMessages.getString(PKG, "AiAdvisor.Prompt.Question.Tooltip"));
-    PropsUi.setLook(wQuestion);
+    Control top = createHeader(margin);
+
+    // Question at the bottom, transcript above it. The question field grows with its text, from
+    // three lines up to twelve or 40% of the pane, like the input of most chat tools; past that it
+    // scrolls. When space is short, as in a low bottom dock, the transcript keeps a few lines and
+    // the question field gives way, down to one line.
+    questionArea = new Composite(this, SWT.NONE);
+    questionData = new FormDataBuilder().left().right().bottom().result();
+    questionArea.setLayoutData(questionData);
+
+    transcript = new AiAdvisorTranscriptPanel(this);
+    transcript.setUndoMetadata(this::undoMetadataForTurn);
+    transcript.setLayoutData(
+        new FormDataBuilder()
+            .left()
+            .right()
+            .top(top, margin)
+            .bottom(questionArea, -margin)
+            .result());
+
+    PropsUi.setLook(questionArea);
+    FormLayout questionLayout = new FormLayout();
+    questionLayout.marginTop = margin;
+    questionArea.setLayout(questionLayout);
+
+    // Messages about the question (what is missing, what went wrong) sit right above it.
+    wlStatus = new Label(questionArea, SWT.LEFT | SWT.WRAP);
+    PropsUi.setLook(wlStatus);
+    statusData = new FormDataBuilder().left().right().top().result();
+    wlStatus.setLayoutData(statusData);
 
-    wSend = new Button(this, SWT.PUSH | SWT.FLAT);
+    wSend = new Button(questionArea, SWT.PUSH | SWT.FLAT);
     wSend.setImage(
         gui.getImage("ui/images/logo_icon.svg", ConstUi.LARGE_ICON_SIZE, ConstUi.LARGE_ICON_SIZE));
     wSend.setToolTipText(BaseMessages.getString(PKG, "AiAdvisor.Send.Tooltip"));
     wSend.addListener(SWT.Selection, e -> onSendOrCancel());
 
-    wPrompt = new Text(this, SWT.MULTI | SWT.WRAP | SWT.BORDER | SWT.V_SCROLL);
+    wPrompt = new Text(questionArea, SWT.MULTI | SWT.WRAP | SWT.BORDER | SWT.V_SCROLL);
     applyPromptFieldLook(wPrompt);
     wPrompt.setMessage(BaseMessages.getString(PKG, "AiAdvisor.Prompt.Message"));
     if (!EnvironmentUtils.getInstance().isWeb()) {
       wPrompt.addPaintListener(e -> paintPromptHint(wPrompt, e));
     }
     wPrompt.setLayoutData(
-        new FormDataBuilder()
-            .left(wQuestion, margin)
-            .right(wSend, -margin)
-            .bottom()
-            .height((int) (70 * PropsUi.getNativeZoomFactor()))
-            .result());
+        new FormDataBuilder().left().right(wSend, -margin).top(wlStatus, margin).bottom().result());
     installSendShortcut(wPrompt);
+    wPrompt.addListener(SWT.KeyDown, this::browseHistory);
+    wPrompt.addListener(
+        SWT.Modify,
+        e -> {
+          if (!showingHistory) {
+            // Typing makes this the draft again; Up starts from the newest question.
+            historyIndex = -1;
+          }
+        });
+    wSend.setLayoutData(new FormDataBuilder().right().top(wPrompt, 0, SWT.CENTER).result());
 
-    wQuestion.setLayoutData(new FormDataBuilder().left().bottom(wPrompt, 0, SWT.CENTER).result());
-    wSend.setLayoutData(new FormDataBuilder().right().bottom(wPrompt, 0, SWT.CENTER).result());
+    wPrompt.addListener(SWT.Modify, e -> sizeQuestionArea());
+    addListener(SWT.Resize, e -> sizeQuestionArea());
+    addListener(SWT.Dispose, e -> cancelWhenClosed());
+    setStatus("");
+  }
 
-    Control top = createHeader(margin);
+  /**
+   * The window or dock closes. With "Cancel the question" configured, questions sent from here that
+   * still wait for their answer are cancelled. By default they finish in the background and their
+   * answer is recorded in the session. Moving the assistant with Float or Dock is not a close: the
+   * sessions go on in the new view. When Hop GUI exits, the session store has already stopped them.
+   */
+  private void cancelWhenClosed() {
+    boolean cancelled =
+        cancelOnClose(
+            sentFromHere,
+            HopAiConfigSingleton.getConfig().getRequestOnClose(),
+            AiAdvisorViews.isMoving());
+    sentFromHere.clear();
+    if (cancelled) {
+      store.fireChanged();
+    }
+  }
 
-    wlStatus = new Label(this, SWT.LEFT | SWT.WRAP);
-    PropsUi.setLook(wlStatus);
-    wlStatus.setLayoutData(new FormDataBuilder().left().right().top(top, margin).result());
+  /**
+   * Cancel the questions of these sessions that still wait for an answer, when the configuration
+   * says so and the view is not just moving.
+   *
+   * @return whether a question was cancelled
+   */
+  static boolean cancelOnClose(
+      Collection<AiAdvisorSession> sessions, AiRequestOnClose onClose, boolean moving) {
+    if (moving || onClose != AiRequestOnClose.CANCEL) {
+      return false;
+    }
+    boolean cancelled = false;
+    for (AiAdvisorSession sent : sessions) {
+      if (sent.isWorking()) {
+        sent.requestCancel();
+        List<AiAdvisorTurn> turns = sent.getTurns();
+        if (!turns.isEmpty()) {
+          recordResult(sent, turns.get(turns.size() - 1), null, null, true);
+        }
+        cancelled = true;
+      }
+    }
+    return cancelled;
+  }
 
-    transcript = new AiAdvisorTranscriptPanel(this);
-    transcript.setLayoutData(
-        new FormDataBuilder()
-            .left()
-            .right()
-            .top(wlStatus, margin)
-            .bottom(wPrompt, -margin)
-            .result());
+  /**
+   * Up on the first line shows the previous question of this session, Down on the last line the
+   * next one, and past the newest the text that was being typed. As in shells and chat tools.
+   */
+  private void browseHistory(Event event) {
+    if (session == null
+        || (event.stateMask & SWT.MODIFIER_MASK) != 0
+        || (event.keyCode != SWT.ARROW_UP && event.keyCode != SWT.ARROW_DOWN)) {
+      return;
+    }
+    List<String> questions = earlierQuestions(session);
+    if (questions.isEmpty()) {
+      return;
+    }
+    if (event.keyCode == SWT.ARROW_UP) {
+      if (wPrompt.getCaretLineNumber() != 0 || historyIndex >= questions.size() - 1) {
+        return;
+      }
+      if (historyIndex < 0) {
+        historyDraft = wPrompt.getText();
+      }
+      historyIndex++;
+      showQuestion(questions.get(historyIndex));
+    } else {
+      if (historyIndex < 0 || wPrompt.getCaretLineNumber() != wPrompt.getLineCount() - 1) {
+        return;
+      }
+      historyIndex--;
+      showQuestion(historyIndex < 0 ? historyDraft : questions.get(historyIndex));
+    }
+    event.doit = false;
+  }
+
+  /** The questions of a session, newest first, without repeats of the same text in a row. */
+  static List<String> earlierQuestions(AiAdvisorSession session) {
+    List<String> questions = new ArrayList<>();
+    List<AiAdvisorTurn> turns = session.getTurns();
+    for (int i = turns.size() - 1; i >= 0; i--) {
+      String question = turns.get(i).getUserPrompt();
+      if (!Utils.isEmpty(question)
+          && (questions.isEmpty() || !questions.get(questions.size() - 1).equals(question))) {
+        questions.add(question);
+      }
+    }
+    return questions;
+  }
+
+  private void showQuestion(String text) {
+    showingHistory = true;
+    try {
+      wPrompt.setText(Const.NVL(text, ""));
+      wPrompt.setSelection(wPrompt.getCharCount());
+    } finally {
+      showingHistory = false;
+    }
+  }
+
+  static final int QUESTION_MIN_LINES = 3;
+  static final int QUESTION_MAX_LINES = 12;
+  static final int QUESTION_MAX_PERCENT = 40;
+
+  /** Lines of the transcript that stay visible when the pane is low. */
+  static final int TRANSCRIPT_MIN_LINES = 4;
+
+  /** Fit the question area to its text, within the limits above. */
+  void sizeQuestionArea() {
+    if (questionArea == null || questionArea.isDisposed() || wPrompt.isDisposed()) {
+      return;
+    }
+    int lineHeight = Math.max(wPrompt.getLineHeight(), 10);
+    int trim = wPrompt.computeTrim(0, 0, 0, 0).height;
+    int width = Math.max(wPrompt.getSize().x, 100);
+    int text = wPrompt.computeSize(width, SWT.DEFAULT).y;
+    int status =
+        wlStatus.isVisible() ? wlStatus.computeSize(getClientArea().width, SWT.DEFAULT).y : 0;
+    int margins = 2 * PropsUi.getMargin();
+    int headerBottom =
+        sharingPanel != null && !sharingPanel.isDisposed()
+            ? sharingPanel.getBounds().y + sharingPanel.getBounds().height + PropsUi.getMargin()
+            : 0;
+    int prompt =
+        promptHeight(
+            text,
+            lineHeight,
+            trim,
+            getClientArea().height,
+            getClientArea().height - headerBottom - status - margins);
+    int height = prompt + status + margins;
+    if (questionData.height != height) {
+      questionData.height = height;
+      layout(true, true);
+    }
+  }
+
+  /**
+   * The height of the question field: its text, between three and twelve lines and at most 40% of
+   * the pane, but never so high that the transcript has less than {@link #TRANSCRIPT_MIN_LINES}
+   * lines. The field keeps at least one line.
+   *
+   * @param below the height left under the header for the question field and the transcript
+   */
+  static int promptHeight(int text, int lineHeight, int trim, int paneHeight, int below) {
+    int minimum = QUESTION_MIN_LINES * lineHeight + trim;
+    int maximum =
+        Math.max(
+            minimum,
+            Math.min(
+                QUESTION_MAX_LINES * lineHeight + trim, paneHeight * QUESTION_MAX_PERCENT / 100));
+    int prompt = Math.max(minimum, Math.min(text, maximum));
+    if (paneHeight <= 0) {
+      // Not laid out yet; sized again on the first resize.
+      return prompt;
+    }
+    int roomLeft = below - TRANSCRIPT_MIN_LINES * lineHeight;
+    return Math.max(lineHeight + trim, Math.min(prompt, roomLeft));
+  }
+
+  /** A form layout without margins, for panels nested in this one, which has its own. */
+  private static FormLayout innerFormLayout() {
+    FormLayout layout = new FormLayout();
+    layout.marginWidth = 0;
+    layout.marginHeight = 0;
+    return layout;
+  }
+
+  private void updateFocus() {
+    if (wFocus == null || wFocus.isDisposed()) {
+      return;
+    }
+    String focus = session != null ? session.getFocusNodeName() : null;
+    boolean show = !Utils.isEmpty(focus);
+    if (show) {
+      String kind =
+          "workflow".equals(session.getArtifactKind())
+              ? BaseMessages.getString(PKG, "AiAdvisor.Focus.Action")
+              : BaseMessages.getString(PKG, "AiAdvisor.Focus.Transform");
+      wFocus.setText(
+          BaseMessages.getString(PKG, "AiAdvisor.Focus.Label", kind, focus.replace("&", "&&")));
+      wFocus.setToolTipText(BaseMessages.getString(PKG, "AiAdvisor.Focus.Tooltip"));
+    }
+    focusData.height = show ? SWT.DEFAULT : 0;
+    wFocus.setVisible(show);
+    layout(true, true);
+  }
+
+  private void clearFocus() {
+    if (session == null) {
+      return;
+    }
+    session.setFocusNodeName("");
+    updateFocus();
+    updateSharingSummary();
+    store.fireChanged();
+  }
+
+  /** An empty status line takes no space, so it does not leave a gap above the transcript. */
+  private void setStatus(String text) {
+    if (wlStatus == null || wlStatus.isDisposed()) {
+      return;
+    }
+    String value = Const.NVL(text, "");
+    wlStatus.setText(value);
+    statusData.height = value.isEmpty() ? 0 : SWT.DEFAULT;
+    wlStatus.setVisible(!value.isEmpty());
+    layout(true, true);
+    sizeQuestionArea();
   }
 
   /**
@@ -284,6 +549,19 @@ public class AiAdvisorSessionPane extends Composite {
     }
   }
 
+  /** Whether the error is a problem its message alone explains; see {@link AiUserException}. */
+  static boolean isExplained(Throwable error) {
+    for (Throwable cause = error; cause != null; cause = cause.getCause()) {
+      if (cause instanceof AiUserException) {
+        return true;
+      }
+      if (cause.getCause() == cause) {
+        break;
+      }
+    }
+    return false;
+  }
+
   static String userVisibleError(Throwable error) {
     if (error == null) {
       return "";
@@ -312,8 +590,10 @@ public class AiAdvisorSessionPane extends Composite {
 
     wAdvisor = new Combo(this, SWT.READ_ONLY | SWT.BORDER);
     PropsUi.setLook(wAdvisor);
+    // Assistant, scenario and provider share one row, so a small bottom dock keeps room for the
+    // conversation.
     wAdvisor.setLayoutData(
-        new FormDataBuilder().left(wlAdvisor, margin).top().right(50, -margin).result());
+        new FormDataBuilder().left(wlAdvisor, margin).top().right(30, -margin).result());
     wAdvisor.addListener(
         SWT.Selection,
         e -> {
@@ -329,7 +609,8 @@ public class AiAdvisorSessionPane extends Composite {
 
     wScenario = new Combo(this, SWT.READ_ONLY | SWT.BORDER);
     PropsUi.setLook(wScenario);
-    wScenario.setLayoutData(new FormDataBuilder().left(wlScenario, margin).top().right().result());
+    wScenario.setLayoutData(
+        new FormDataBuilder().left(wlScenario, margin).top().right(55, -margin).result());
     wScenario.addListener(
         SWT.Selection,
         e -> {
@@ -354,7 +635,12 @@ public class AiAdvisorSessionPane extends Composite {
             BaseMessages.getString(PKG, "AiAdvisor.Provider.Label"),
             BaseMessages.getString(PKG, "AiAdvisor.Provider.Tooltip"),
             true);
-    wProvider.setLayoutData(new FormDataBuilder().left().right().top(wAdvisor, margin).result());
+    wProvider.setLayoutData(
+        new FormDataBuilder()
+            .left(wScenario, margin)
+            .right()
+            .top(wAdvisor, 0, SWT.CENTER)
+            .result());
     wProvider.addModifyListener(
         e -> {
           if (updatingUi || session == null) {
@@ -365,18 +651,29 @@ public class AiAdvisorSessionPane extends Composite {
             return;
           }
           session.setProviderName(name);
+          if (providerExists(name)) {
+            store.setLastProviderName(name);
+          }
           store.fireChanged();
         });
 
+    // Shown when AI Help was opened on a transform or action; its settings go with each question.
+    wFocus = new Link(this, SWT.NONE);
+    PropsUi.setLook(wFocus);
+    focusData = new FormDataBuilder().left().right().top(wProvider, margin).result();
+    focusData.height = 0;
+    wFocus.setLayoutData(focusData);
+    wFocus.setVisible(false);
+    wFocus.addListener(SWT.Selection, e -> clearFocus());
+
     sharingPanel = new Composite(this, SWT.NONE);
     PropsUi.setLook(sharingPanel);
-    sharingPanel.setLayout(PropsUi.getInstance().createFormLayout());
-    sharingPanel.setLayoutData(
-        new FormDataBuilder().left().right().top(wProvider, margin).result());
+    sharingPanel.setLayout(innerFormLayout());
+    sharingPanel.setLayoutData(new FormDataBuilder().left().right().top(wFocus, margin).result());
 
     sharingHeader = new Composite(sharingPanel, SWT.NONE);
     PropsUi.setLook(sharingHeader);
-    sharingHeader.setLayout(PropsUi.getInstance().createFormLayout());
+    sharingHeader.setLayout(innerFormLayout());
     sharingHeader.setLayoutData(new FormDataBuilder().left().right().top().result());
     sharingHeader.setCursor(getDisplay().getSystemCursor(SWT.CURSOR_HAND));
     sharingHeader.addListener(SWT.MouseDown, e -> toggleSharingPanel());
@@ -387,13 +684,26 @@ public class AiAdvisorSessionPane extends Composite {
     wSharingToggle.addListener(SWT.Selection, e -> toggleSharingPanel());
     wSharingToggle.setLayoutData(new FormDataBuilder().left().top().result());
 
+    // Tooltips cannot hold a link, so the way to the explanation sits on the line itself.
+    Link wSharingHelp = new Link(sharingHeader, SWT.NONE);
+    wSharingHelp.setText(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Explain.Link"));
+    PropsUi.setLook(wSharingHelp);
+    wSharingHelp.setLayoutData(
+        new FormDataBuilder().right().top(wSharingToggle, 0, SWT.CENTER).result());
+    wSharingHelp.addListener(
+        SWT.Selection,
+        e ->
+            HelpUtils.openHelp(
+                getShell(),
+                Const.getDocUrl("hop-gui/perspective-ai-advisor.html#context-inclusions")));
+
     wlSharing = new Label(sharingHeader, SWT.LEFT | SWT.WRAP);
     PropsUi.setLook(wlSharing);
     wlSharing.setToolTipText(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Toggle.Tooltip"));
     wlSharing.setLayoutData(
         new FormDataBuilder()
             .left(wSharingToggle, margin)
-            .right()
+            .right(wSharingHelp, -2 * margin)
             .top(wSharingToggle, 0, SWT.CENTER)
             .result());
     wlSharing.addListener(SWT.MouseDown, e -> toggleSharingPanel());
@@ -461,13 +771,30 @@ public class AiAdvisorSessionPane extends Composite {
     if (wlSharing == null || wlSharing.isDisposed()) {
       return;
     }
-    List<String> parts = new ArrayList<>();
-    parts.add(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Question"));
+    // What the user chose comes first, so the focused node and the opt-in items stand out. The
+    // items that always go along are summed up as "the basics" and listed in the tooltip.
+    List<String> chosen = new ArrayList<>();
+    Map<String, String> chosenExplanations = new HashMap<>();
+    if (session != null && !Utils.isEmpty(session.getFocusNodeName())) {
+      String focus =
+          BaseMessages.getString(PKG, "AiAdvisor.Sharing.Focus", session.getFocusNodeName());
+      chosen.add(focus);
+      chosenExplanations.put(focus, BaseMessages.getString(PKG, "AiAdvisor.Sharing.Explain.Focus"));
+    }
+    for (AiAdvisorInclusion inclusion : currentInclusions) {
+      if (!inclusionEnabled(inclusion.getId()) || isBlockedByConfig(inclusion.getId())) {
+        continue;
+      }
+      String summary = summaryFor(inclusion);
+      chosen.add(summary);
+    }
+    List<String> basics = new ArrayList<>();
+    basics.add(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Question"));
     IAiAdvisor advisor = loadSelectedAdvisor();
     if (advisor != null) {
       for (String baseline : advisor.listBaselineSharing()) {
         if (!Utils.isEmpty(baseline)) {
-          parts.add(baseline);
+          basics.add(baseline);
         }
       }
     }
@@ -478,21 +805,55 @@ public class AiAdvisorSessionPane extends Composite {
               host.getVariables(),
               BaseMessages.getString(PKG, "AiAdvisor.Sharing.ExtraNotes"))) {
         if (!Utils.isEmpty(extra)) {
-          parts.add(extra);
+          basics.add(extra);
         }
       }
     }
-    for (AiAdvisorInclusion inclusion : currentInclusions) {
-      if (!inclusionEnabled(inclusion.getId())) {
-        continue;
+    String prefix = BaseMessages.getString(PKG, "AiAdvisor.Sharing.Prefix");
+    wlSharing.setText(
+        chosen.isEmpty()
+            ? prefix + BaseMessages.getString(PKG, "AiAdvisor.Sharing.BasicsOnly")
+            : formatSharingLine(prefix, chosen)
+                + BaseMessages.getString(PKG, "AiAdvisor.Sharing.Basics"));
+    StringBuilder tooltip =
+        new StringBuilder(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Tooltip.Header"));
+    for (String item : chosen) {
+      tooltip.append("\n\u2022 ").append(item);
+      String explanation = chosenExplanations.get(item);
+      if (!Utils.isEmpty(explanation)) {
+        tooltip.append(": ").append(explanation);
       }
-      parts.add(summaryFor(inclusion));
     }
-    wlSharing.setText(
-        formatSharingLine(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Prefix"), parts));
+    for (String item : basics) {
+      tooltip.append("\n\u2022 ").append(item).append(": ").append(explainBasic(item));
+    }
+    tooltip.append("\n\n").append(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Tooltip.Footer"));
+    wlSharing.setToolTipText(tooltip.toString());
     sharingHeader.layout(true, true);
   }
 
+  /** What an item that always goes along is, in a few words, for the Sharing tooltip. */
+  static String explainBasic(String item) {
+    Map<String, String> known = new HashMap<>();
+    known.put(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Question"), "Question");
+    known.put(BaseMessages.getString(PKG, "AiAdvisor.Sharing.ExtraNotes"), "ExtraNotes");
+    for (Class<?> advisor : new Class<?>[] {PipelineAiAdvisor.class, WorkflowAiAdvisor.class}) {
+      String prefix = advisor.getSimpleName();
+      known.put(BaseMessages.getString(advisor, prefix + ".Sharing.Baseline"), "Structure");
+      known.put(
+          BaseMessages.getString(advisor, prefix + ".Sharing.MetadataTypes"), "MetadataTypes");
+      known.put(BaseMessages.getString(advisor, prefix + ".Sharing.DatabasePlugins"), "Databases");
+    }
+    String key = known.get(item);
+    if (key != null) {
+      return BaseMessages.getString(PKG, "AiAdvisor.Sharing.Explain." + key);
+    }
+    if ("ai-context.md".equals(item) || item.endsWith("-advisor.md")) {
+      return BaseMessages.getString(PKG, "AiAdvisor.Sharing.Explain.PluginNotes", item);
+    }
+    return BaseMessages.getString(PKG, "AiAdvisor.Sharing.Explain.ContextFile");
+  }
+
   static String formatSharingLine(String prefix, List<String> parts) {
     String head = prefix != null ? prefix : "";
     if (parts == null || parts.isEmpty()) {
@@ -501,6 +862,15 @@ public class AiAdvisorSessionPane extends Composite {
     return head + String.join(", ", parts);
   }
 
+  /**
+   * Full XML also needs Configuration → Plugins → AI Assistant → Allow sending full XML. While that
+   * is off the checkbox cannot send anything, so it is disabled and left out of the Sharing line.
+   */
+  static boolean isBlockedByConfig(String inclusionId) {
+    return AiAdvisorInclusions.XML.equals(inclusionId)
+        && !HopAiConfigSingleton.getConfig().isAllowSendFullXml();
+  }
+
   private boolean inclusionEnabled(String id) {
     if (session != null && session.getInclusions().containsKey(id)) {
       return Boolean.TRUE.equals(session.getInclusions().get(id));
@@ -539,33 +909,127 @@ public class AiAdvisorSessionPane extends Composite {
   }
 
   public void showSession(AiAdvisorSession session) {
+    if (this.session != session) {
+      historyIndex = -1;
+    }
     this.session = session;
     updatingUi = true;
     try {
       reloadAdvisors();
       reloadProviders();
       if (session == null) {
-        setEnabled(false);
-        wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.NoSession"));
+        // Only the controls are disabled: the hints in the status line and the transcript stay
+        // readable.
+        setInputEnabled(false);
+        updateFocus();
+        setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.NoSession"));
         transcript.showSession(null);
         updateSendButton();
         return;
       }
-      setEnabled(true);
+      setInputEnabled(true);
       selectAdvisor(session.getAdvisorPluginId());
       reloadScenariosAndInclusions();
       selectScenario(session.getScenarioId());
       applyInclusionsFromSession();
+      includeLogAfterARun();
+      updateFocus();
       updateMetadataSelectButton();
       updateInclusionPickerButtons();
-      wlStatus.setText(Const.NVL(session.getStatusMessage(), ""));
+      setStatus(
+          Utils.isEmpty(session.getStatusMessage()) && advisorPlugins.isEmpty()
+              ? noAdvisorMessage()
+              : Const.NVL(session.getStatusMessage(), ""));
       transcript.showSession(session, this::reviewProposalsForTurn);
       updateSendButton();
+      startWorkingLine(session);
     } finally {
       updatingUi = false;
     }
   }
 
+  /**
+   * While the model works, the line right above the question counts the seconds, so the user sees
+   * at the same place every time that the request is alive. It stops when the answer is in, or when
+   * this pane shows another session.
+   */
+  private void startWorkingLine(AiAdvisorSession target) {
+    if (target == null || !target.isWorking() || target.getTurns().isEmpty()) {
+      return;
+    }
+    AiAdvisorTurn turn = target.getTurns().get(target.getTurns().size() - 1);
+    long started =
+        turn.getStartedAtMillis() > 0 ? turn.getStartedAtMillis() : System.currentTimeMillis();
+    Runnable tick =
+        new Runnable() {
+          @Override
+          public void run() {
+            if (isDisposed() || session != target || !target.isWorking()) {
+              return;
+            }
+            setStatus(
+                AiAdvisorTranscriptPanel.workingText(
+                    turn, (System.currentTimeMillis() - started) / 1000));
+            getDisplay().timerExec(1000, this);
+          }
+        };
+    tick.run();
+  }
+
+  /**
+   * Once the pipeline or workflow has run, its log is what most questions are about, so the log
+   * option switches on by itself, visibly. Not when the user set the option since the latest run:
+   * that choice holds until the next run.
+   */
+  private void includeLogAfterARun() {
+    if (session == null) {
+      return;
+    }
+    forgetLogChoiceOfEarlierRun(session);
+    if (session.getLogSupplier() == null
+        || session.getUserChosenInclusions().contains(AiAdvisorInclusions.LOGS)
+        || Boolean.TRUE.equals(session.getInclusions().get(AiAdvisorInclusions.LOGS))) {
+      return;
+    }
+    Button check = inclusionButtons.get(AiAdvisorInclusions.LOGS);
+    if (check == null || check.isDisposed()) {
+      return;
+    }
+    String log = session.getLogSupplier().get();
+    if (Utils.isEmpty(log) || log.isBlank()) {
+      return;
+    }
+    check.setSelection(true);
+    session.getInclusions().put(AiAdvisorInclusions.LOGS, true);
+    updateSharingSummary();
+  }
+
+  /** A choice for the Logs option made before the latest run no longer holds. */
+  static void forgetLogChoiceOfEarlierRun(AiAdvisorSession session) {
+    String runId = session.currentRunId();
+    if (runId != null
+        && session.getUserChosenInclusions().contains(AiAdvisorInclusions.LOGS)
+        && !runId.equals(session.getLogChoiceRunId())) {
+      session.getUserChosenInclusions().remove(AiAdvisorInclusions.LOGS);
+    }
+  }
+
+  private void setInputEnabled(boolean enabled) {
+    for (Control control : new Control[] {wAdvisor, wScenario, wProvider, sharingPanel, wPrompt}) {
+      if (control != null && !control.isDisposed()) {
+        control.setEnabled(enabled);
+      }
+    }
+  }
+
+  /** Why no advisor is offered: none is installed, or none works without a pipeline or workflow. */
+  private String noAdvisorMessage() {
+    if (session != null && session.getArtifact() == null && !AiAdvisorPlugins.list().isEmpty()) {
+      return BaseMessages.getString(PKG, "AiAdvisor.Status.NotBound");
+    }
+    return BaseMessages.getString(PKG, "AiAdvisor.Status.NoAdvisor");
+  }
+
   private void advisorChanged() {
     if (updatingUi || session == null) {
       return;
@@ -613,21 +1077,48 @@ public class AiAdvisorSessionPane extends Composite {
 
   private void reloadProviders() {
     try {
+      // Hop GUI replaces its metadata provider when another project opens. The field keeps the
+      // one it was created with, which lists the providers of the project open back then.
+      wProvider.setMetadataProvider(host.getMetadataProvider());
       wProvider.fillItems();
     } catch (HopException e) {
       // Combo stays empty; Send explains.
     }
     HopAiConfig config = HopAiConfigSingleton.getConfig();
     if (session != null && Utils.isEmpty(session.getProviderName())) {
-      session.setProviderName(Const.NVL(config.getDefaultProviderName(), ""));
+      // The configured default, else the provider used last in this Hop GUI.
+      String provider = config.getDefaultProviderName();
+      if (Utils.isEmpty(provider) || !providerExists(provider)) {
+        provider = store.getLastProviderName();
+      }
+      session.setProviderName(providerExists(provider) ? provider : "");
     }
-    if (session != null && !Utils.isEmpty(session.getProviderName())) {
+    if (session != null && providerExists(session.getProviderName())) {
       wProvider.setText(session.getProviderName());
     } else if (wProvider.getItemCount() == 1) {
       wProvider.select(0);
+      if (session != null) {
+        session.setProviderName(wProvider.getText());
+      }
+    } else {
+      // A name from another project, or a provider that was deleted or renamed. Leaving it in the
+      // combo would make Edit fail on an element that does not exist.
+      wProvider.setText("");
     }
   }
 
+  private boolean providerExists(String name) {
+    if (Utils.isEmpty(name)) {
+      return false;
+    }
+    for (String item : wProvider.getItems()) {
+      if (name.equals(item)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   private void reloadScenariosAndInclusions() {
     wScenario.setItems(new String[0]);
     for (Control child : inclusionsComposite.getChildren()) {
@@ -668,11 +1159,23 @@ public class AiAdvisorSessionPane extends Composite {
               : inclusion.isDefaultSelected();
       check.setSelection(selected);
       String id = inclusion.getId();
+      if (isBlockedByConfig(id)) {
+        check.setSelection(false);
+        check.setEnabled(false);
+        check.setToolTipText(
+            check.getToolTipText()
+                + "\n\n"
+                + BaseMessages.getString(PKG, "AiAdvisor.Sharing.XmlBlocked.Tooltip"));
+      }
       check.addListener(
           SWT.Selection,
           e -> {
             if (session != null) {
               session.getInclusions().put(id, check.getSelection());
+              session.getUserChosenInclusions().add(id);
+              if (AiAdvisorInclusions.LOGS.equals(id)) {
+                session.setLogChoiceRunId(session.currentRunId());
+              }
             }
             if (check.getSelection() && session != null) {
               if (AiAdvisorInclusions.METADATA.equals(id)
@@ -707,12 +1210,52 @@ public class AiAdvisorSessionPane extends Composite {
         inclusionPickers.put(id, picker);
       }
     }
+    Button preview = new Button(inclusionsComposite, SWT.PUSH);
+    preview.setText(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Preview.Label"));
+    preview.setToolTipText(BaseMessages.getString(PKG, "AiAdvisor.Sharing.Preview.Tooltip"));
+    PropsUi.setLook(preview);
+    preview.addListener(SWT.Selection, e -> previewPayload());
+
     updateMetadataSelectButton();
     updateInclusionPickerButtons();
     updateSharingSummary();
     applySharingPanelExpanded();
   }
 
+  /** Show exactly what the next question would send, without sending it. */
+  private void previewPayload() {
+    if (session == null) {
+      return;
+    }
+    try {
+      // Log widgets are SWT; read them here on the UI thread, as a send does.
+      String logExcerpt = session.getLogSupplier() != null ? session.getLogSupplier().get() : null;
+      String text =
+          AiAdvisorEngine.preview(
+              session,
+              loadSelectedAdvisor(),
+              host.getVariables(),
+              host.getMetadataProvider(),
+              logExcerpt,
+              wPrompt.getText());
+      EnterTextDialog dialog =
+          new EnterTextDialog(
+              getShell(),
+              BaseMessages.getString(PKG, "AiAdvisor.Sharing.Preview.Title"),
+              BaseMessages.getString(PKG, "AiAdvisor.Sharing.Preview.Message"),
+              text,
+              true);
+      dialog.setReadOnly();
+      dialog.open();
+    } catch (Exception ex) {
+      new ErrorDialog(
+          getShell(),
+          BaseMessages.getString(PKG, "AiAdvisor.Sharing.Preview.Title"),
+          BaseMessages.getString(PKG, "AiAdvisor.Sharing.Preview.Error"),
+          ex instanceof HopException ? ex : new HopException(ex));
+    }
+  }
+
   private void openMetadataPicker() {
     if (session == null) {
       return;
@@ -756,7 +1299,7 @@ public class AiAdvisorSessionPane extends Composite {
     List<AiAdvisorInclusionChoice> choices =
         advisor.listInclusionChoices(inclusion.getId(), pickerRequest());
     if (choices == null || choices.isEmpty()) {
-      wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Inclusion.Select.Empty"));
+      setStatus(BaseMessages.getString(PKG, "AiAdvisor.Inclusion.Select.Empty"));
       Button check = inclusionButtons.get(inclusion.getId());
       if (check != null) {
         check.setSelection(false);
@@ -901,13 +1444,13 @@ public class AiAdvisorSessionPane extends Composite {
     }
     for (Map.Entry<String, Button> entry : inclusionButtons.entrySet()) {
       Boolean value = session.getInclusions().get(entry.getKey());
-      if (value != null) {
+      if (value != null && !isBlockedByConfig(entry.getKey())) {
         entry.getValue().setSelection(value);
       }
     }
   }
 
-  private void onSendOrCancel() {
+  void onSendOrCancel() {
     if (session != null && session.isWorking()) {
       cancelInFlight();
       return;
@@ -915,13 +1458,23 @@ public class AiAdvisorSessionPane extends Composite {
     send();
   }
 
+  /**
+   * Stop waiting for the answer. The session is free for a new question at once; whether the HTTP
+   * request itself stops depends on the provider's client, and its answer is ignored if it comes.
+   */
   private void cancelInFlight() {
     if (session == null || !session.isWorking()) {
       return;
     }
     session.requestCancel();
-    session.setStatusMessage(BaseMessages.getString(PKG, "AiAdvisor.Status.Cancelled"));
-    wlStatus.setText(session.getStatusMessage());
+    List<AiAdvisorTurn> turns = session.getTurns();
+    if (!turns.isEmpty()) {
+      recordResult(session, turns.get(turns.size() - 1), null, null, true);
+    }
+    setStatus(Const.NVL(session.getStatusMessage(), ""));
+    transcript.showSession(session, this::reviewProposalsForTurn);
+    updateSendButton();
+    store.fireChanged();
   }
 
   private void updateSendButton() {
@@ -937,7 +1490,8 @@ public class AiAdvisorSessionPane extends Composite {
           gui.getImage(
               "ui/images/logo_icon.svg", ConstUi.LARGE_ICON_SIZE, ConstUi.LARGE_ICON_SIZE));
       wSend.setToolTipText(BaseMessages.getString(PKG, "AiAdvisor.Send.Tooltip"));
-      wSend.setEnabled(HopAiConfigSingleton.getConfig().isAiEnabled() && session != null);
+      // Stays clickable when AI is off or something is missing: Send then says what to do.
+      wSend.setEnabled(session != null);
     }
   }
 
@@ -947,58 +1501,102 @@ public class AiAdvisorSessionPane extends Composite {
     }
     HopAiConfig config = HopAiConfigSingleton.getConfig();
     if (!config.isAiEnabled()) {
-      wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
+      setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
       return;
     }
     String prompt = wPrompt.getText();
-    if (Utils.isEmpty(prompt)) {
+    if (Utils.isEmpty(prompt) || prompt.isBlank()) {
+      setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.NoQuestion"));
+      wPrompt.setFocus();
       return;
     }
     IAiAdvisor advisor = loadSelectedAdvisor();
     if (advisor == null) {
-      wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.NoAdvisor"));
+      setStatus(noAdvisorMessage());
+      return;
+    }
+    if (!providerExists(wProvider.getText())) {
+      setStatus(
+          BaseMessages.getString(
+              PKG,
+              wProvider.getItemCount() == 0
+                  ? "AiAdvisor.Status.NoProviderDefined"
+                  : "AiAdvisor.Status.NoProviderSelected"));
       return;
     }
     session.setAdvisorPluginId(selectedAdvisorId());
     session.setScenarioId(selectedScenarioId());
     session.setProviderName(wProvider.getText());
+    includeLogAfterARun();
     for (Map.Entry<String, Button> entry : inclusionButtons.entrySet()) {
-      session.getInclusions().put(entry.getKey(), entry.getValue().getSelection());
+      // A blocked option keeps the user's choice for when the configuration allows it again.
+      if (!isBlockedByConfig(entry.getKey())) {
+        session.getInclusions().put(entry.getKey(), entry.getValue().getSelection());
+      }
     }
 
     AiAdvisorTurn turn = new AiAdvisorTurn();
     turn.setUserPrompt(prompt);
+    turn.setStartedAtMillis(System.currentTimeMillis());
     session.addTurn(turn);
     session.setCancelled(false);
     session.setWorking(true);
-    session.setStatusMessage(BaseMessages.getString(PKG, "AiAdvisor.Status.Working"));
+    // The transcript shows a live waiting line; the status line is for problems.
+    session.setStatusMessage("");
     wPrompt.setText("");
     updateSendButton();
-    wlStatus.setText(session.getStatusMessage());
+    setStatus(session.getStatusMessage());
     transcript.showSession(session, this::reviewProposalsForTurn);
     store.fireChanged();
 
     AiAdvisorSession target = session;
-    // Log widgets are SWT; read them on this UI thread before the background worker starts.
-    final String logExcerpt =
-        target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+    sentFromHere.add(target);
+    // Build the question here on the UI thread: the log widgets are SWT, and the pipeline or
+    // workflow may be edited while the model works. Only the model call runs in the background.
+    AiAdvisorEngine.Prepared prepared;
+    try {
+      String logExcerpt = target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+      prepared =
+          AiAdvisorEngine.prepare(
+              target, advisor, host.getVariables(), host.getMetadataProvider(), logExcerpt);
+    } catch (Exception e) {
+      completeTurn(target, turn, null, e);
+      return;
+    }
+    turn.setSentAppliedSummaries(prepared.request().getAppliedChangeSummaries());
+    turn.setEstimatedPromptTokens(prepared.estimatedTokens());
+    turn.setProviderLabel(prepared.provider().getName());
+    startWorkingLine(target);
+    IVariables variables = host.getVariables();
+    Display display = getDisplay();
     Thread worker =
         BackgroundThreadFacade.start(
             () -> {
+              AiAdvisorResponse response = null;
+              Throwable error = null;
               try {
-                AiAdvisorResponse response =
-                    AiAdvisorEngine.advise(
-                        target,
-                        advisor,
-                        host.getVariables(),
-                        host.getMetadataProvider(),
-                        logExcerpt);
-                host.asyncExec(() -> completeTurn(target, turn, response, null));
+                response = AiAdvisorEngine.execute(target, advisor, variables, prepared);
               } catch (Throwable t) {
                 if (t instanceof InterruptedException) {
                   Thread.currentThread().interrupt();
                 }
-                host.asyncExec(() -> completeTurn(target, turn, null, t));
+                error = t;
+              }
+              AiAdvisorResponse result = response;
+              Throwable failure = error;
+              Thread self = Thread.currentThread();
+              // Record the answer even when this window was closed in the meantime (unless the
+              // configuration cancels it on close): the session is shared with the other views
+              // and would otherwise stay "working" for good.
+              if (!display.isDisposed()) {
+                display.asyncExec(
+                    () -> {
+                      // After Stop, or once a newer question runs, this answer is no longer
+                      // wanted and must not touch the session.
+                      if (target.getWorkerThread() == self) {
+                        completeTurn(target, turn, result, failure);
+                      }
+                    });
               }
             },
             "hop-ai-advisor");
@@ -1008,17 +1606,58 @@ public class AiAdvisorSessionPane extends Composite {
   private void completeTurn(
       AiAdvisorSession target, AiAdvisorTurn turn, AiAdvisorResponse response, Throwable error) {
     boolean cancelled = target.isCancelled();
+    sentFromHere.remove(target);
+    recordResult(target, turn, response, error, cancelled);
+    store.fireChanged();
+    if (isDisposed()) {
+      return;
+    }
+    if (session == target) {
+      setStatus(Const.NVL(target.getStatusMessage(), ""));
+      transcript.showSession(target, this::reviewProposalsForTurn);
+      updateSendButton();
+    }
+    if (error != null && !cancelled && !isExplained(error)) {
+      // The status line already says what to do about an explained problem. Other failures, such
+      // as a network or authentication error from the provider, get the details as well.
+      new ErrorDialog(
+          host.getShell(),
+          BaseMessages.getString(PKG, "AiAdvisor.Send.Error.Title"),
+          BaseMessages.getString(PKG, "AiAdvisor.Send.Error.Message"),
+          error);
+    }
+  }
+
+  /** Put the outcome of a question on the session; needs no widget of this pane. */
+  static void recordResult(
+      AiAdvisorSession target,
+      AiAdvisorTurn turn,
+      AiAdvisorResponse response,
+      Throwable error,
+      boolean cancelled) {
     target.setWorking(false);
     target.setWorkerThread(null);
     if (cancelled) {
       target.setStatusMessage(BaseMessages.getString(PKG, "AiAdvisor.Status.Cancelled"));
+      if (Utils.isEmpty(turn.getAssistantAdvice())) {
+        turn.setErrorMessage(target.getStatusMessage());
+      }
     } else if (error != null) {
       String message = userVisibleError(error);
       turn.setErrorMessage(message);
       target.setStatusMessage(message);
     } else if (response != null) {
+      target.removePendingAppliedSummaries(turn.getSentAppliedSummaries());
       turn.setAssistantAdvice(response.getMarkdownAdvice());
+      if (Utils.isEmpty(turn.getAssistantAdvice())
+          && response.getProposals() != null
+          && !response.getProposals().isEmpty()) {
+        // Some models answer a change request with the proposals only.
+        turn.setAssistantAdvice(BaseMessages.getString(PKG, "AiAdvisor.Transcript.ProposalsOnly"));
+      }
+      turn.setRawAnswer(response.getRawResponse());
       turn.setProposalBlockPresent(response.isProposalBlockPresent());
+      turn.setProposalParseError(response.getProposalParseError());
       turn.setInputTokenCount(response.getInputTokenCount());
       turn.setOutputTokenCount(response.getOutputTokenCount());
       turn.setDurationMs(response.getDurationMs());
@@ -1031,19 +1670,6 @@ public class AiAdvisorSessionPane extends Composite {
         target.setStatusMessage(BaseMessages.getString(PKG, "AiAdvisor.Status.WithProposals"));
       }
     }
-    if (session == target) {
-      wlStatus.setText(Const.NVL(target.getStatusMessage(), ""));
-      transcript.showSession(target, this::reviewProposalsForTurn);
-      updateSendButton();
-    }
-    store.fireChanged();
-    if (error != null && !cancelled) {
-      new ErrorDialog(
-          host.getShell(),
-          BaseMessages.getString(PKG, "AiAdvisor.Send.Error.Title"),
-          BaseMessages.getString(PKG, "AiAdvisor.Send.Error.Message"),
-          error);
-    }
   }
 
   private void reviewProposalsForTurn(int turnIndex) {
@@ -1057,7 +1683,7 @@ public class AiAdvisorSessionPane extends Composite {
     }
     IAiAdvisor advisor = loadSelectedAdvisor();
     if (advisor == null) {
-      wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.NoAdvisor"));
+      setStatus(noAdvisorMessage());
       return;
     }
     AiAdvisorRequest request = new AiAdvisorRequest();
@@ -1072,6 +1698,7 @@ public class AiAdvisorSessionPane extends Composite {
     request.getAttributes().put(AiAdvisorRequest.ATTR_HOP_GUI, host.getHopGui());
 
     List<AiProposalValidation> validation = advisor.validateProposals(request, proposals);
+    markOptIn(proposals, validation);
     AiAdvisorProposalReviewDialog reviewDialog =
         new AiAdvisorProposalReviewDialog(
             host.getShell(),
@@ -1088,10 +1715,28 @@ public class AiAdvisorSessionPane extends Composite {
     if (selected.isEmpty()) {
       return;
     }
+    if (!confirmOverwrites(selected)) {
+      return;
+    }
     try {
-      advisor.applyProposals(request, selected);
+      // All or nothing: the metadata saves undo themselves when one fails, and are undone when the
+      // graph changes fail. The graph changes roll themselves back.
+      AiMetadataProposalSupport.checkAll(selected, host.getMetadataProvider());
+      List<AiMetadataBackup> backups =
+          AiMetadataProposalSupport.saveAll(selected, host.getMetadataProvider());
+      try {
+        advisor.applyProposals(request, selected);
+      } catch (Exception e) {
+        try {
+          AiMetadataProposalSupport.revert(backups, host.getMetadataProvider());
+        } catch (Exception revertError) {
+          e.addSuppressed(revertError);
+        }
+        throw e;
+      }
+      turn.getMetadataBackups().addAll(backups);
       int copied = AiClipboardProposals.copy(selected);
-      int saved = AiMetadataProposalSupport.saveAll(selected, host.getMetadataProvider());
+      int saved = backups.size();
       session.recordApplied(turn, selected, advisor);
       advisor.afterApply(request, selected);
       refreshBoundGraph();
@@ -1099,7 +1744,7 @@ public class AiAdvisorSessionPane extends Composite {
         fireMetadataChanged();
       }
       session.setStatusMessage(appliedStatusMessage(selected, copied, saved));
-      wlStatus.setText(Const.NVL(session.getStatusMessage(), ""));
+      setStatus(Const.NVL(session.getStatusMessage(), ""));
       transcript.showSession(session, this::reviewProposalsForTurn);
       store.fireChanged();
     } catch (Exception ex) {
@@ -1111,6 +1756,88 @@ public class AiAdvisorSessionPane extends Composite {
     }
   }
 
+  /**
+   * Deletes, replacements, settings changes and whatever the model itself rates HIGH risk are
+   * opt-in for every advisor, whatever its validator says.
+   */
+  public static void markOptIn(List<AiProposal> proposals, List<AiProposalValidation> validations) {
+    for (int i = 0; i < proposals.size() && i < validations.size(); i++) {
+      AiProposal proposal = proposals.get(i);
+      AiProposalTypes type = AiProposalTypes.of(proposal);
+      AiProposalValidation validation = validations.get(i);
+      if (validation != null
+          && ((type != null && type.isOptIn())
+              || "HIGH".equalsIgnoreCase(Const.NVL(proposal.getRiskLevel(), "").trim()))) {
+        validation.setOptIn(true);
+      }
+    }
+  }
+
+  private boolean confirmOverwrites(List<AiProposal> selected) {
+    List<String> overwritten = new ArrayList<>();
+    for (AiProposal proposal : selected) {
+      if (AiMetadataProposalSupport.exists(proposal, host.getMetadataProvider())) {
+        overwritten.add(
+            "- "
+                + Const.NVL(proposal.parameter("typeKey"), "")
+                + " "
+                + Const.NVL(
+                    AiMetadataProposalSupport.targetName(proposal, host.getMetadataProvider()),
+                    ""));
+      }
+    }
+    if (overwritten.isEmpty()) {
+      return true;
+    }
+    MessageBox box = new MessageBox(host.getShell(), SWT.ICON_WARNING | SWT.YES | SWT.NO);
+    box.setText(BaseMessages.getString(PKG, "AiAdvisor.Overwrite.Title"));
+    box.setMessage(
+        BaseMessages.getString(PKG, "AiAdvisor.Overwrite.Message", String.join("\n", overwritten)));
+    return box.open() == SWT.YES;
+  }
+
+  private void undoMetadataForTurn(int turnIndex) {
+    if (session == null || turnIndex < 0 || turnIndex >= session.getTurns().size()) {
+      return;
+    }
+    AiAdvisorTurn turn = session.getTurns().get(turnIndex);
+    if (turn.getMetadataBackups().isEmpty()) {
+      return;
+    }
+    // Undo puts back what was there before the assistant saved. Changes made since are lost then,
+    // which the user decides.
+    List<String> changed =
+        AiMetadataProposalSupport.changedSinceSave(
+            turn.getMetadataBackups(), host.getMetadataProvider());
+    if (!changed.isEmpty()) {
+      MessageBox box = new MessageBox(host.getShell(), SWT.ICON_WARNING | SWT.YES | SWT.NO);
+      box.setText(BaseMessages.getString(PKG, "AiAdvisor.UndoMetadata.Changed.Title"));
+      box.setMessage(
+          BaseMessages.getString(
+              PKG, "AiAdvisor.UndoMetadata.Changed.Message", "- " + String.join("\n- ", changed)));
+      if (box.open() != SWT.YES) {
+        return;
+      }
+    }
+    try {
+      AiMetadataProposalSupport.revert(turn.getMetadataBackups(), host.getMetadataProvider());
+      int count = turn.getMetadataBackups().size();
+      turn.getMetadataBackups().clear();
+      fireMetadataChanged();
+      session.setStatusMessage(
+          BaseMessages.getString(PKG, "AiAdvisor.Status.MetadataUndone", count));
+      setStatus(Const.NVL(session.getStatusMessage(), ""));
+      transcript.showSession(session, this::reviewProposalsForTurn);
+      store.fireChanged();
+    } catch (Exception ex) {
+      new ErrorDialog(
+          host.getShell(),
+          BaseMessages.getString(PKG, "AiAdvisor.Apply.Error.Title"),
+          BaseMessages.getString(PKG, "AiAdvisor.UndoMetadata.Error.Message"),
+          ex instanceof HopException ? ex : new HopException(ex));
+    }
+  }
+
   private String appliedStatusMessage(List<AiProposal> selected, int copied, int saved) {
     int graph = 0;
     for (AiProposal proposal : selected) {
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorTranscriptPanel.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorTranscriptPanel.java
index e63050f419..d09811703d 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorTranscriptPanel.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorTranscriptPanel.java
@@ -20,6 +20,7 @@ package org.apache.hop.ai.ui;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.function.IntConsumer;
+import lombok.Setter;
 import org.apache.hop.ai.engine.AiAdvisorMarkdown;
 import org.apache.hop.ai.session.AiAdvisorSession;
 import org.apache.hop.ai.session.AiAdvisorTurn;
@@ -87,6 +88,9 @@ public class AiAdvisorTranscriptPanel extends Composite {
     installWheelForwarding();
   }
 
+  /** Restores the metadata a turn's applied proposals saved; the argument is the turn index. */
+  @Setter private IntConsumer undoMetadata;
+
   public void showSession(AiAdvisorSession session) {
     showSession(session, null);
   }
@@ -98,8 +102,15 @@ public class AiAdvisorTranscriptPanel extends Composite {
     bodies.clear();
     if (session == null || session.getTurns().isEmpty()) {
       Composite block = appendBlock(Role.SYSTEM);
-      appendHeading(
-          block, Role.SYSTEM, BaseMessages.getString(PKG, "AiAdvisor.Transcript.Empty"), null);
+      String empty;
+      if (session == null) {
+        empty = "AiAdvisor.Transcript.NoSession";
+      } else if (session.getArtifact() == null) {
+        empty = "AiAdvisor.Transcript.NotLinked";
+      } else {
+        empty = "AiAdvisor.Transcript.Empty";
+      }
+      appendHeading(block, Role.SYSTEM, BaseMessages.getString(PKG, empty), null);
       appendNote(block, Role.SYSTEM, BaseMessages.getString(PKG, "AiAdvisor.GitWarning"));
     } else {
       List<AiAdvisorTurn> turns = session.getTurns();
@@ -144,10 +155,17 @@ public class AiAdvisorTranscriptPanel extends Composite {
           if (responseBlock == null) {
             responseBlock = appendBlock(Role.ASSISTANT);
           }
-          appendNote(
-              responseBlock,
-              Role.ASSISTANT,
-              BaseMessages.getString(PKG, "AiAdvisor.Review.Dropped"));
+          String dropped = BaseMessages.getString(PKG, "AiAdvisor.Review.Dropped");
+          if (!Utils.isEmpty(turn.getProposalParseError())) {
+            dropped += " " + turn.getProposalParseError();
+          }
+          appendNote(responseBlock, Role.ASSISTANT, dropped);
+        } else if (!Utils.isEmpty(turn.getProposalParseError())) {
+          // The answer talks about changes but has none to review, also after asking again.
+          if (responseBlock == null) {
+            responseBlock = appendBlock(Role.ASSISTANT);
+          }
+          appendNote(responseBlock, Role.ASSISTANT, turn.getProposalParseError());
         }
         if (turn.getAppliedSummaries() != null && !turn.getAppliedSummaries().isEmpty()) {
           Composite block = appendBlock(Role.SYSTEM);
@@ -156,12 +174,57 @@ public class AiAdvisorTranscriptPanel extends Composite {
               Role.SYSTEM,
               BaseMessages.getString(
                   PKG, "AiAdvisor.Transcript.Applied", turn.getAppliedSummaries().size()));
+          if (!turn.getMetadataBackups().isEmpty() && undoMetadata != null) {
+            int turnIndex = i;
+            appendReviewButton(
+                block,
+                BaseMessages.getString(
+                    PKG, "AiAdvisor.UndoMetadata.Label", turn.getMetadataBackups().size()),
+                () -> undoMetadata.accept(turnIndex));
+          }
         }
       }
     }
     refreshScroll(true);
   }
 
+  /**
+   * Words to show while the model works. They say nothing about what the model does, but a line
+   * that changes shows the request is alive, and the seconds show how long it takes.
+   */
+  static final String[] WORKING_KEYS = {
+    "AiAdvisor.Working.1",
+    "AiAdvisor.Working.2",
+    "AiAdvisor.Working.3",
+    "AiAdvisor.Working.4",
+    "AiAdvisor.Working.5",
+    "AiAdvisor.Working.6",
+    "AiAdvisor.Working.7",
+    "AiAdvisor.Working.8"
+  };
+
+  static final int WORKING_WORD_SECONDS = 3;
+
+  static String workingText(AiAdvisorTurn turn, long seconds) {
+    String word =
+        BaseMessages.getString(
+            PKG, WORKING_KEYS[(int) ((seconds / WORKING_WORD_SECONDS) % WORKING_KEYS.length)]);
+    if (turn.getEstimatedPromptTokens() == null || Utils.isEmpty(turn.getProviderLabel())) {
+      return BaseMessages.getString(PKG, "AiAdvisor.Working.Text", word, seconds);
+    }
+    return BaseMessages.getString(
+        PKG,
+        "AiAdvisor.Working.TextWithSize",
+        word,
+        seconds,
+        formatTokens(turn.getEstimatedPromptTokens()),
+        turn.getProviderLabel());
+  }
+
+  static String formatTokens(int tokens) {
+    return tokens < 1000 ? Integer.toString(tokens) : String.format("%.1fk", tokens / 1000.0);
+  }
+
   private Composite appendBlock(Role role) {
     Composite block = new Composite(content, SWT.BORDER);
     GridLayout layout = new GridLayout(1, true);
@@ -372,6 +435,12 @@ public class AiAdvisorTranscriptPanel extends Composite {
       getDisplay().asyncExec(() -> refreshScroll(toBottom));
       return;
     }
+    // The scroller gives the content its width only on its own next layout. Until then, as when a
+    // window opens on a session, the content is narrower: the texts would wrap in that width and
+    // keep the height of it until the next resize.
+    if (content.getSize().x != clientWidth) {
+      content.setSize(clientWidth, Math.max(content.getSize().y, 1));
+    }
     // First pass: let GridLayout assign real widths.
     for (Control body : bodies) {
       if (body.isDisposed()) {
diff --git a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorViews.java b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorViews.java
index ad988084b7..2e17c55aa2 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorViews.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorViews.java
@@ -25,9 +25,11 @@ import org.apache.hop.core.gui.plugin.menu.GuiMenuElement;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.terminal.HopGuiBottomDock;
+import org.eclipse.swt.SWT;
 import org.eclipse.swt.custom.CTabItem;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
@@ -107,6 +109,67 @@ public class AiAdvisorViews {
         container -> createDockedWorkbench(container, hopGui));
   }
 
+  private static final String PREFERRED_VIEW = "AiAssistant.PreferredView";
+  private static final String DOCK_OPEN = "AiAssistant.DockOpen";
+
+  /** Set once Hop GUI starts to close, so the dock tab closing with it is not taken as a choice. */
+  private static volatile boolean exiting;
+
+  /**
+   * Open the AI Assistant tab in the bottom dock again when it was open when Hop GUI last closed,
+   * like the rest of the layout.
+   */
+  public static void restoreDock(HopGui hopGui) {
+    if (hopGui == null || hopGui.getShell() == null || hopGui.getShell().isDisposed()) {
+      return;
+    }
+    // The shell's Dispose event comes before its children are disposed.
+    hopGui.getShell().addListener(SWT.Dispose, e -> exiting = true);
+    if ("Y".equals(PropsUi.getInstance().getCustomParameter(DOCK_OPEN, "N"))) {
+      hopGui.getShell().getDisplay().asyncExec(() -> openDock(hopGui));
+    }
+  }
+
+  /** Remember whether the user last moved the assistant to the dock or to a floating window. */
+  public static void rememberView(IAiAdvisorWorkbenchHost.ViewKind kind) {
+    PropsUi.getInstance().setCustomParameter(PREFERRED_VIEW, kind.name());
+  }
+
+  static boolean prefersDock() {
+    return IAiAdvisorWorkbenchHost.ViewKind.DOCK
+        .name()
+        .equals(PropsUi.getInstance().getCustomParameter(PREFERRED_VIEW, ""));
+  }
+
+  /** Set while Float or Dock closes the view the assistant moved away from. */
+  private static boolean moving;
+
+  /**
+   * Close the view the assistant moved away from. Its sessions go on in the new view, so this is
+   * not a close that cancels a running question.
+   */
+  static void closeAfterMove(Runnable close) {
+    moving = true;
+    try {
+      close.run();
+    } finally {
+      moving = false;
... 5927 lines suppressed ...