diff --git a/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToImage.php b/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToImage.php
index 911d6a0a318f41fe67a6a25608a7824bef60d171..0c5af367233dfbdd07a7913235cc60f32bb25d00 100644
--- a/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToImage.php
+++ b/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToImage.php
@@ -4,6 +4,7 @@ namespace Drupal\ai_automators\Plugin\AiAutomatorType;
 
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\File\FileExists;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\ai\OperationType\Chat\ChatInput;
@@ -135,7 +136,7 @@ class LlmVideoToImage extends VideoToText implements AiAutomatorTypeInterface {
           if (!isset($values[0][0]['timestamp'])) {
             throw new AiAutomatorResponseErrorException('Could not find any timestamp..');
           }
-          $this->createVideoRasterImages($automatorConfig, $entityWrapper->entity, $values[0]['value'][0]['timestamp']);
+          $this->createVideoRasterImages($automatorConfig, $entityWrapper->entity, $values[0][0]['timestamp']);
           $input = new ChatInput([
             new ChatMessage('user', $prompt, $this->images),
           ]);
@@ -154,7 +155,7 @@ class LlmVideoToImage extends VideoToText implements AiAutomatorTypeInterface {
    */
   public function verifyValue(ContentEntityInterface $entity, $value, FieldDefinitionInterface $fieldDefinition, array $automatorConfig) {
     // Should have start and end time.
-    if (!isset($value['timestamp'])) {
+    if (!isset($value[0]['timestamp'])) {
       return FALSE;
     }
     // Otherwise it is ok.
@@ -175,7 +176,7 @@ class LlmVideoToImage extends VideoToText implements AiAutomatorTypeInterface {
 
     // Get the actual file name and replace it with _cut.
     $fileName = pathinfo($realPath, PATHINFO_FILENAME);
-    $newFile = str_replace($fileName, $fileName . '_cut', $entity->{$baseField}->entity->getFileUri());
+    $newFile = str_replace($fileName, $fileName . '_cut', $entity->{$baseField}->entity->getFileUri()) . '.jpg';
 
     $tmpName = "";
     foreach ($values as $keys) {
@@ -186,16 +187,20 @@ class LlmVideoToImage extends VideoToText implements AiAutomatorTypeInterface {
         $tmpNames[] = $tmpName;
 
         $inputVideo = $this->video ?? $realPath;
-        $command = 'ffmpeg -y -nostdin -i "' . $inputVideo . '" -ss "' . $key['timestamp'] . '" -frames:v 1 ' . $tmpName;
 
-        exec($command, $status);
-        if ($status) {
-          throw new AiAutomatorResponseErrorException('Could not generate new videos.');
-        }
+        // Clean the values.
+        $timeStamp = $this->cleanTimestamp($key['timestamp']);
+        $tokens = [
+          'inputFile' => $inputVideo,
+          'timeStamp' => $timeStamp,
+          'outputFile' => $tmpName,
+        ];
+        $command = '-y -nostdin -i {inputFile} -ss {timeStamp} -frames:v 1 {outputFile}';
+        $this->runFfmpegCommand($command, $tokens, 'Could not generate new images.');
       }
 
       // Move the file to the correct place.
-      $fixedFile = $this->fileSystem->move($tmpName, $newFile);
+      $fixedFile = $this->fileSystem->copy($tmpName, $newFile, FileExists::Rename);
 
       // Generate the new file entity.
       $file = File::create([
diff --git a/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToVideo.php b/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToVideo.php
index 630e02a01614c7b1ac77cafc7b8a8ca7a08c926d..3cef5a29947b78df792137b9693514a50dbe2c3d 100644
--- a/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToVideo.php
+++ b/modules/ai_automators/src/Plugin/AiAutomatorType/LlmVideoToVideo.php
@@ -9,7 +9,6 @@ use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\ai\OperationType\Chat\ChatInput;
 use Drupal\ai\OperationType\Chat\ChatMessage;
 use Drupal\ai_automators\Attribute\AiAutomatorType;
-use Drupal\ai_automators\Exceptions\AiAutomatorResponseErrorException;
 use Drupal\ai_automators\PluginBaseClasses\VideoToText;
 use Drupal\ai_automators\PluginInterfaces\AiAutomatorTypeInterface;
 use Drupal\file\Entity\File;
@@ -172,11 +171,14 @@ class LlmVideoToVideo extends VideoToText implements AiAutomatorTypeInterface {
         $tmpName = $this->fileSystem->tempnam($this->tmpDir, 'video') . '.mp4';
         $tmpNames[] = $tmpName;
 
-        $command = "ffmpeg -y -nostdin -i \"$realPath\" -ss {$key['start_time']} -to {$key['end_time']} -c:v libx264 -c:a aac -strict -2 $tmpName";
-        exec($command, $status);
-        if ($status) {
-          throw new AiAutomatorResponseErrorException('Could not generate new videos.');
-        }
+        $tokens = [
+          'startTime' => $this->cleanTimestamp($key['start_time']),
+          'endTime' => $this->cleanTimestamp($key['end_time']),
+          'realPath' => $realPath,
+          'tmpName' => $tmpName,
+        ];
+        $command = "-y -nostdin -i {realPath} -ss {startTime} -to {endTime} -c:v libx264 -c:a aac -strict -2 {tmpName}";
+        $this->runFfmpegCommand($command, $tokens, 'Could not cut out the video.');
       }
 
       // If we only have one video, we can just rename it.
@@ -189,14 +191,15 @@ class LlmVideoToVideo extends VideoToText implements AiAutomatorTypeInterface {
         // Generate list file.
         $text = '';
         foreach ($tmpNames as $tmpName) {
-          $text .= "file '$tmpName'\n";
+          $text .= "file $tmpName\n";
         }
         file_put_contents($this->tmpDir . 'list.txt', $text);
-        $command = "ffmpeg -y -nostdin -f concat -safe 0 -i {$this->tmpDir}list.txt -c:v libx264 -c:a aac -strict -2 $endFile";
-        exec($command, $status);
-        if ($status) {
-          throw new AiAutomatorResponseErrorException('Could not generate new videos.');
-        }
+        $tokens = [
+          'listFile' => $this->tmpDir . 'list.txt',
+          'endFile' => $endFile,
+        ];
+        $command = "-y -nostdin -f concat -safe 0 -i {listFile} -c:v libx264 -c:a aac -strict -2 {endFile}";
+        $this->runFfmpegCommand($command, $tokens, 'Could not mix the videos together.');
       }
       // Move the file to the correct place.
       $fixedFile = $this->fileSystem->move($endFile, $newFile);
diff --git a/modules/ai_automators/src/PluginBaseClasses/VideoToText.php b/modules/ai_automators/src/PluginBaseClasses/VideoToText.php
index 886877df197b62bcad18b1be4e70138fee5e638c..494763626f0a67154007578c6e7f7fed95da6cb5 100644
--- a/modules/ai_automators/src/PluginBaseClasses/VideoToText.php
+++ b/modules/ai_automators/src/PluginBaseClasses/VideoToText.php
@@ -17,6 +17,7 @@ use Drupal\ai\AiProviderPluginManager;
 use Drupal\ai\OperationType\Chat\ChatInput;
 use Drupal\ai\OperationType\Chat\ChatMessage;
 use Drupal\ai\OperationType\GenericType\AudioFile;
+use Drupal\ai\OperationType\GenericType\ImageFile;
 use Drupal\ai\OperationType\SpeechToText\SpeechToTextInput;
 use Drupal\ai\Service\AiProviderFormHelper;
 use Drupal\ai\Service\PromptJsonDecoder\PromptJsonDecoderInterface;
@@ -165,7 +166,7 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
    */
   public function __destruct() {
     if (!empty($this->tmpDir) && file_exists($this->tmpDir)) {
-      exec('rm -rf ' . $this->tmpDir);
+      $this->deleteFilesFromTmpDir('', TRUE);
     }
   }
 
@@ -264,21 +265,24 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
    */
   public function screenshotFromTimestamp(File $video, $timeStamp, array $cropData = []) {
     $path = $video->getFileUri();
-    $realPath = $this->fileSystem->realpath($path);
-    $command = "ffmpeg -y -nostdin -ss $timeStamp -i \"$realPath\" -vframes 1 {$this->tmpDir}/screenshot.jpeg";
+    // Clean values before using them.
+    $command = "-y -nostdin -ss {timeStamp} -i {realPath} -vframes 1 {screenshotFile}";
+    $tokens = [
+      'screenshotFile' => $this->tmpDir . "screenshot.jpeg",
+      'timeStamp' => $this->cleanTimestamp($timeStamp),
+      'realPath' => $this->fileSystem->realpath($path),
+    ];
     // If we need to crop also.
     if (count($cropData)) {
       $realCropData = $this->normalizeCropData($video, $cropData);
-      $command = "ffmpeg -y -nostdin  -ss $timeStamp -i \"$realPath\" -vf \"crop={$realCropData[2]}:{$realCropData[3]}:{$realCropData[0]}:{$realCropData[1]}\" -vframes 1 {$this->tmpDir}/screenshot.jpeg";
+      $tokens['crop'] = 'crop=' . $realCropData[2] . ':' . $realCropData[3] . ':' . $realCropData[0] . ':' . $realCropData[1];
+      $command = "-y -nostdin  -ss {timeStamp} -i {realPath} -vf {crop} -vframes 1 {screenshotFile}";
     }
-
-    exec($command, $status);
-    if ($status) {
-      throw new AiAutomatorRequestErrorException('Could not create video screenshot.');
-    }
-    $newFile = str_replace($video->getFilename(), $video->getFilename() . '_cut', $path);
+    // Run the command.
+    $this->runFfmpegCommand($command, $tokens, 'Could not generate screenshot from video.');
+    $newFile = str_replace($video->getFilename(), $video->getFilename() . '_cut', $path) . '.jpg';
     $newFile = preg_replace('/\.(avi|mp4|mov|wmv|flv|mkv)$/', '.jpg', $newFile);
-    $fixedFile = $this->fileSystem->move("{$this->tmpDir}/screenshot.jpeg", $newFile);
+    $fixedFile = $this->fileSystem->move("{$this->tmpDir}screenshot.jpeg", $newFile);
     $file = File::create([
       'uri' => $fixedFile,
       'status' => 1,
@@ -301,8 +305,8 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
   public function normalizeCropData(File $video, $cropData) {
     $originalWidth = 640;
     // Get the width and height of the video with FFmpeg.
-    $realPath = $this->fileSystem->realpath($video->getFileUri());
-    $command = "ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 \"$realPath\"";
+    $realPathEscaped = escapeshellarg($this->fileSystem->realpath($video->getFileUri()));
+    $command = "ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 $realPathEscaped";
     $result = shell_exec($command);
     [$width] = explode('x', $result);
     $ratio = $width / $originalWidth;
@@ -310,6 +314,10 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
     foreach ($cropData as $key => $value) {
       $newCropData[$key] = round($value * $ratio);
     }
+    // There should only be 4 numeric values.
+    if (count($newCropData) != 4) {
+      throw new AiAutomatorRequestErrorException('The crop data is not in the correct format.');
+    }
     return $newCropData;
   }
 
@@ -355,11 +363,12 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
     // Get the actual file path on the server.
     $realPath = $this->fileSystem->realpath($video);
     // Let FFMPEG do its magic.
-    $command = "ffmpeg -y -nostdin  -i \"$realPath\" -c:a mp3 -b:a 64k {$this->tmpDir}/audio.mp3";
-    exec($command, $status);
-    if ($status) {
-      throw new AiAutomatorResponseErrorException('Could not generate audio from video.');
-    }
+    $command = "-y -nostdin  -i {realPath} -c:a mp3 -b:a 64k {file}";
+    $tokens = [
+      'file' => $this->tmpDir . "audio.mp3",
+      'realPath' => $realPath,
+    ];
+    $this->runFfmpegCommand($command, $tokens, 'Could not generate audio from video.');
     return '';
   }
 
@@ -370,51 +379,52 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
     // Use Whisper to transcribe and then get the segments.
     $input = [
       'model' => 'whisper-1',
-      'file' => fopen($this->tmpDir . '/audio.mp3', 'r'),
+      'file' => fopen($this->tmpDir . 'audio.mp3', 'r'),
       'response_format' => 'json',
     ];
     $instance = $this->aiPluginManager->createInstance($automatorConfig['ai_provider_audio']);
 
-    $input = new SpeechToTextInput(new AudioFile(file_get_contents($this->tmpDir . '/audio.mp3'), 'audio/mpeg', 'audio.mp3'));
+    $input = new SpeechToTextInput(new AudioFile(file_get_contents($this->tmpDir . 'audio.mp3'), 'audio/mpeg', 'audio.mp3'));
     $this->transcription = $instance->speechToText($input, $automatorConfig['ai_model_audio'])->getNormalized();
   }
 
   /**
    * Helper function to get the image raster images from the video.
    */
-  protected function createVideoRasterImages($automatorConfig, File $file, $timestamp = NULL) {
+  protected function createVideoRasterImages($automatorConfig, File $file, $timeStamp = NULL) {
     $this->images = [];
-    exec('rm ' . $this->tmpDir . '/*.jpeg');
+    // Remove all the images.
+    $this->deleteFilesFromTmpDir('jpeg');
     // Get the video file.
     $video = $file->getFileUri();
-    // Get the actual file path on the server.
-    $realPath = $this->fileSystem->realpath($video);
     // Let FFMPEG do its magic.
-    $command = "ffmpeg -y -nostdin  -i \"$realPath\" -vf \"select='gt(scene,0.1)',scale=640:-1,drawtext=fontsize=45:fontcolor=yellow:box=1:boxcolor=black:x=(W-tw)/2:y=H-th-10:text='%{pts\:hms}'\" -vsync vfr {$this->tmpDir}output_frame_%04d.jpeg";
+    $tokens = [
+      'tmpDir' => $this->tmpDir,
+      'realPath' => $this->fileSystem->realpath($video),
+      'thumbFile' => $this->tmpDir . 'output_frame_%04d.jpeg',
+      'rasterFile' => $this->tmpDir . 'raster-%04d.jpeg',
+      'videoFile' => $this->tmpDir . 'tmpVideo.mp4',
+    ];
+    $command = "-y -nostdin  -i {realPath} -vf \"select='gt(scene,0.1)',scale=640:-1,drawtext=fontsize=45:fontcolor=yellow:box=1:boxcolor=black:x=(W-tw)/2:y=H-th-10:text='%{pts\:hms}'\" -vsync vfr {thumbFile}";
     // If its timestamp, just get 0.5 seconds before and after.
-    if ($timestamp) {
-      $command = "ffmpeg -y -nostdin -ss " . $timestamp . " -i \"$realPath\" -t 3 -vf \"scale=640:-1,drawtext=fontsize=45:fontcolor=yellow:box=1:boxcolor=black:x=(W-tw)/2:y=H-th-10:text='%{pts\:hms}'\" -vsync vfr {$this->tmpDir}output_frame_%04d.jpeg";
+    $tokens['timeStamp'] = $this->cleanTimestamp($timeStamp);
+    if ($tokens['timeStamp']) {
+      $command = "-y -nostdin -ss {timeStamp} -i {realPath} -t 3 -vf \"scale=640:-1,drawtext=fontsize=45:fontcolor=yellow:box=1:boxcolor=black:x=(W-tw)/2:y=H-th-10:text='%{pts\:hms}'\" -vsync vfr {thumbFile}";
     }
+    $this->runFfmpegCommand($command, $tokens, 'Could not generate images from video.');
 
-    exec($command, $status);
-    // If it failed, give up.
-    if ($status) {
-      throw new AiAutomatorResponseErrorException('Could not create video thumbs.');
-    }
-    $rasterCommand = "ffmpeg -i {$this->tmpDir}/output_frame_%04d.jpeg -filter_complex \"scale=640:-1,tile=3x3:margin=10:padding=4:color=white\" {$this->tmpDir}/raster-%04d.jpeg";
-    exec($rasterCommand, $status);
-    // If it failed, give up.
-    if ($status) {
-      throw new AiAutomatorResponseErrorException('Could not create video raster.');
-    }
+    $rasterCommand = "-i {thumbFile} -filter_complex \"scale=640:-1,tile=3x3:margin=10:padding=4:color=white\" {rasterFile}";
+    $this->runFfmpegCommand($rasterCommand, $tokens, 'Could not create video raster.');
     $images = glob($this->tmpDir . 'raster-*.jpeg');
     foreach ($images as $uri) {
-      $this->images[] = 'data:image/jpeg;base64,' . base64_encode(file_get_contents($uri));
+      $image = new ImageFile();
+      $image->setFileFromUri($uri);
+      $this->images[] = $image;
     }
     // If timestamp also generate a temp video.
-    if ($timestamp) {
-      $command = "ffmpeg -y -nostdin -ss " . $timestamp . " -i \"$realPath\" -t 3 -c:v libx264 -qscale 0 {$this->tmpDir}tmpVideo.mp4";
-      exec($command, $status);
+    if ($tokens['timeStamp']) {
+      $command = "-y -nostdin -ss {timeStamp} -i {realPath} -t 3 -c:v libx264 -qscale 0 {videoFile}";
+      $this->runFfmpegCommand($command, $tokens, 'Could not generate video from video.');
       $this->video = "{$this->tmpDir}tmpVideo.mp4";
     }
   }
@@ -423,9 +433,9 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
    * Helper function to generate a temp directory.
    */
   protected function createTempDirectory() {
-    $this->tmpDir = $this->fileSystem->getTempDirectory() . '/' . mt_rand(10000, 99999) . '/';
+    $this->tmpDir = $this->fileSystem->getTempDirectory() . '/ai_automator/' . mt_rand(10000, 99999) . '/';
     if (!file_exists($this->tmpDir)) {
-      $this->fileSystem->mkdir($this->tmpDir);
+      $this->fileSystem->mkdir($this->tmpDir, NULL, TRUE);
     }
   }
 
@@ -478,4 +488,94 @@ class VideoToText extends RuleBase implements ContainerFactoryPluginInterface {
     return substr($date->format('H:i:s.u'), 0, -3);
   }
 
+  /**
+   * Clean up timestamp.
+   *
+   * @param string|null $timeStamp
+   *   The timestamp.
+   *
+   * @return string
+   *   The cleaned up timestamp.
+   */
+  public function cleanTimestamp(?string $timeStamp = NULL) {
+    // If its null, just return it.
+    if (!$timeStamp) {
+      return NULL;
+    }
+    // Make sure it follows the h:i:s.ms, since we can't escape this.
+    if (!preg_match('/^(\d{1,2}:\d{2}:\d{2}\.\d{2,3})$/', $timeStamp)) {
+      throw new AiAutomatorRequestErrorException('The timestamp is not in the correct format.');
+    }
+    return $timeStamp;
+  }
+
+  /**
+   * Run FFMPEG command.
+   *
+   * @param string $command
+   *   The command to run.
+   * @param array $tokens
+   *   The tokens to replace.
+   * @param string $error_message
+   *   Error message to throw if it fails.
+   */
+  public function runFfmpegCommand($command, array $tokens, $error_message) {
+    $command = $this->prepareFfmpegCommand($command, $tokens);
+    exec($command, $status);
+    if ($status) {
+      throw new AiAutomatorResponseErrorException($error_message);
+    }
+  }
+
+  /**
+   * Prepare the FFMPEG command.
+   *
+   * @param string $command
+   *   The command to run.
+   * @param array $tokens
+   *   The tokens to replace.
+   *
+   * @return string
+   *   The prepared command.
+   */
+  public function prepareFfmpegCommand($command, array $tokens) {
+    foreach ($tokens as $token => $value) {
+      // Only escape if it is not empty.
+      if (empty($value)) {
+        continue;
+      }
+      $escaped_value = escapeshellarg($value);
+      $command = str_replace("{{$token}}", $escaped_value, $command);
+    }
+    // @todo Add full path to ffmpeg.
+    return "ffmpeg $command";
+  }
+
+  /**
+   * Delete files from the tmp dir.
+   *
+   * @param string $ext
+   *   The extension to delete.
+   * @param bool $remove_directory
+   *   If the directory should be removed.
+   */
+  public function deleteFilesFromTmpDir($ext = '', $remove_directory = FALSE) {
+    // Get the actual tmp directory, to make sure nothing was injected.
+    $tmpDir = $this->fileSystem->getTempDirectory() . '/ai_automator';
+
+    foreach (scandir($tmpDir) as $file) {
+      if ($file == '.' || $file == '..') {
+        continue;
+      }
+      if ($ext && pathinfo($file, PATHINFO_EXTENSION) != $ext) {
+        continue;
+      }
+      unlink($tmpDir . $file);
+    }
+
+    if ($remove_directory) {
+      rmdir($tmpDir);
+    }
+  }
+
 }
diff --git a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Completion.php b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Completion.php
index abff602df7b3123b8d9a35950f56fa41274960fc..89a73e1da0dad5f53ee91afe6c035b0044173f06 100644
--- a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Completion.php
+++ b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Completion.php
@@ -42,6 +42,11 @@ final class Completion extends AiCKEditorPluginBase {
       '#title' => $this->t('Completion pre prompt'),
       '#default_value' => $prompt_complete ?? '',
       '#description' => $this->t('This prompt will be prepended before the user prompt. This field may be left empty too.'),
+      '#states' => [
+        'required' => [
+          ':input[name="editor[settings][plugins][ai_ckeditor_ai][plugins][ai_ckeditor_completion][enabled]"]' => ['checked' => TRUE],
+        ],
+      ],
     ];
 
     return $form;
diff --git a/modules/ai_ckeditor/src/Plugin/AiCKEditor/ReformatHtml.php b/modules/ai_ckeditor/src/Plugin/AiCKEditor/ReformatHtml.php
index 4a788ef3b4b177dfba364ab75c79fd226cbab081..ceb5a5ff2d2c06114e4226d532e0532cf3f00aa7 100644
--- a/modules/ai_ckeditor/src/Plugin/AiCKEditor/ReformatHtml.php
+++ b/modules/ai_ckeditor/src/Plugin/AiCKEditor/ReformatHtml.php
@@ -49,9 +49,13 @@ final class ReformatHtml extends AiCKEditorPluginBase {
     $form['prompt'] = [
       '#type' => 'textarea',
       '#title' => $this->t('Reformat prompt'),
-      '#required' => TRUE,
       '#default_value' => $prompt_reformat,
       '#description' => $this->t('This prompt will be used to reformat the html.'),
+      '#states' => [
+        'required' => [
+          ':input[name="editor[settings][plugins][ai_ckeditor_ai][plugins][ai_ckeditor_reformat_html][enabled]"]' => ['checked' => TRUE],
+        ],
+      ],
     ];
 
     return $form;
diff --git a/modules/ai_ckeditor/src/Plugin/AiCKEditor/SpellFix.php b/modules/ai_ckeditor/src/Plugin/AiCKEditor/SpellFix.php
index b0799b2b7731b37340e746d64e164f3ad35fbdec..f2ab0b0b80b9264682beb643df99052121bacd8f 100644
--- a/modules/ai_ckeditor/src/Plugin/AiCKEditor/SpellFix.php
+++ b/modules/ai_ckeditor/src/Plugin/AiCKEditor/SpellFix.php
@@ -35,11 +35,14 @@ final class SpellFix extends AiCKEditorPluginBase {
    * {@inheritdoc}
    */
   public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $options = $this->aiProviderManager->getSimpleProviderModelOptions('chat');
+    array_shift($options);
+    array_splice($options, 0, 1);
     $form['provider'] = [
       '#type' => 'select',
       '#title' => $this->t('AI provider'),
-      '#options' => $this->aiProviderManager->getSimpleProviderModelOptions('chat'),
-      '#required' => TRUE,
+      '#options' => $options,
+      "#empty_option" => $this->t('-- Default from AI module (chat) --'),
       '#default_value' => $this->configuration['provider'] ?? $this->aiProviderManager->getSimpleDefaultProviderOptions('chat'),
       '#description' => $this->t('Select which provider to use for this plugin. See the <a href=":link">Provider overview</a> for details about each provider.', [':link' => '/admin/config/ai/providers']),
     ];
@@ -48,9 +51,13 @@ final class SpellFix extends AiCKEditorPluginBase {
     $form['prompt'] = [
       '#type' => 'textarea',
       '#title' => $this->t('Spelling fix prompt'),
-      '#required' => TRUE,
       '#default_value' => $prompt_fix_spelling,
       '#description' => $this->t('This prompt will be used to fix the spelling.'),
+      '#states' => [
+        'required' => [
+          ':input[name="editor[settings][plugins][ai_ckeditor_ai][plugins][ai_ckeditor_spellfix][enabled]"]' => ['checked' => TRUE],
+        ],
+      ],
     ];
 
     return $form;
diff --git a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Summarize.php b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Summarize.php
index 58449698f545f3449fd35482b53156e207b71a4e..a9aa055f138e7683314556af0ce080d09b25adbc 100644
--- a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Summarize.php
+++ b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Summarize.php
@@ -48,9 +48,13 @@ final class Summarize extends AiCKEditorPluginBase {
     $form['prompt'] = [
       '#type' => 'textarea',
       '#title' => $this->t('Summarise prompt'),
-      '#required' => TRUE,
       '#default_value' => $prompt_summarise,
       '#description' => $this->t('This prompt will be used to summarise the text.'),
+      '#states' => [
+        'required' => [
+          ':input[name="editor[settings][plugins][ai_ckeditor_ai][plugins][ai_ckeditor_summarize][enabled]"]' => ['checked' => TRUE],
+        ],
+      ],
     ];
     return $form;
   }
diff --git a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Tone.php b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Tone.php
index 848b6be7c871eb10ca3a3acc64403c5b45d62e6a..14b0e67e853bbfbdc2a5a652de2d13024a1ced69 100644
--- a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Tone.php
+++ b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Tone.php
@@ -88,9 +88,13 @@ final class Tone extends AiCKEditorPluginBase {
     $form['prompt'] = [
       '#type' => 'textarea',
       '#title' => $this->t('Change tone prompt'),
-      '#required' => TRUE,
       '#default_value' => $prompt_tone,
       '#description' => $this->t('This prompt will be used to change the tone of voice. {{ tone }} is the target tone of voice that is chosen.'),
+      '#states' => [
+        'required' => [
+          ':input[name="editor[settings][plugins][ai_ckeditor_ai][plugins][ai_ckeditor_tone][enabled]"]' => ['checked' => TRUE],
+        ],
+      ],
     ];
 
     return $form;
@@ -211,8 +215,8 @@ final class Tone extends AiCKEditorPluginBase {
       $prompts_config = $this->getConfigFactory()->get('ai_ckeditor.settings');
       $prompt = $prompts_config->get('prompts.tone');
       $prompt = str_replace('{{ tone }}', $term->label(), $prompt);
-      if ($this->configuration['use_description'] && !empty($term->description->value)) {
-        $prompt .= 'That tone can described as: ' . strip_tags($term->description->value);
+      if ($this->configuration['use_description'] && !empty($term->getDescription())) {
+        $prompt .= 'That tone can described as: ' . strip_tags($term->getDescription());
       }
       $prompt .= "\n\nThe text that we want to change is the following:\n" . $values["plugin_config"]["selected_text"];
       $response = new AjaxResponse();
diff --git a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Translate.php b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Translate.php
index 716fe1692de9b92f7c7ba3c9d577bc7d301e816d..049cb12f5293a42aee007dc8579485097db140a8 100644
--- a/modules/ai_ckeditor/src/Plugin/AiCKEditor/Translate.php
+++ b/modules/ai_ckeditor/src/Plugin/AiCKEditor/Translate.php
@@ -88,9 +88,13 @@ final class Translate extends AiCKEditorPluginBase {
     $form['prompt'] = [
       '#type' => 'textarea',
       '#title' => $this->t('Change translation prompt'),
-      '#required' => TRUE,
       '#default_value' => $prompt_translate,
       '#description' => $this->t('This prompt will be used to translate the text. {{ tone }} is the target tone of voice that is chosen.'),
+      '#states' => [
+        'required' => [
+          ':input[name="editor[settings][plugins][ai_ckeditor_ai][plugins][ai_ckeditor_translate][enabled]"]' => ['checked' => TRUE],
+        ],
+      ],
     ];
 
     return $form;
@@ -204,8 +208,8 @@ final class Translate extends AiCKEditorPluginBase {
       $prompts_config = $this->getConfigFactory()->get('ai_ckeditor.settings');
       $prompt = $prompts_config->get('prompts.translate');
       $prompt = str_replace('{{ lang }}', $term->label(), $prompt);
-      if ($this->configuration['use_description'] && !empty($term->description->value)) {
-        $prompt .= 'Think about the following when translating it into ' . $term->label() . ': ' . strip_tags($term->description->value);
+      if ($this->configuration['use_description'] && !empty($term->getDescription())) {
+        $prompt .= 'Think about the following when translating it into ' . $term->label() . ': ' . strip_tags($term->getDescription());
       }
       $prompt .= "\n\nThe text that we want to translate is the following:\n" . $values["plugin_config"]["selected_text"];
       $response = new AjaxResponse();
diff --git a/modules/ai_eca/ai_eca.info.yml b/modules/ai_eca/ai_eca.info.yml
index 0af3e6ab33a8d39a9e3ba3d93861e016961d3b6d..e71a56ed2e44b515bf7e1781126277c00491247d 100644
--- a/modules/ai_eca/ai_eca.info.yml
+++ b/modules/ai_eca/ai_eca.info.yml
@@ -8,3 +8,4 @@ dependencies:
   - ai:ai
   - drupal:file
   - eca:eca
+  - eca_content:eca_content
diff --git a/modules/ai_search/config/schema/search_api_ai_search.backend.schema.yml b/modules/ai_search/config/schema/search_api_ai_search.backend.schema.yml
index da9b6a3e5d61f1e0f27d96634f50d52e17ce1d8f..52b5c6ed25487afa714e6d4f126755f7ff496a3c 100644
--- a/modules/ai_search/config/schema/search_api_ai_search.backend.schema.yml
+++ b/modules/ai_search/config/schema/search_api_ai_search.backend.schema.yml
@@ -50,6 +50,9 @@ plugin.plugin_configuration.search_api_backend.search_api_ai_search:
         contextual_content_max_percentage:
           type: string
           label: 'Maximum percentage of contextual content'
+        skip_moderation:
+          type: boolean
+          label: 'Whether to skip moderation'
     embedding_strategy_details:
       type: string
       label: 'Additional details for the embedding strategy'
diff --git a/modules/ai_search/src/Base/EmbeddingStrategyPluginBase.php b/modules/ai_search/src/Base/EmbeddingStrategyPluginBase.php
index 0e90dde4c1876a83626e5305a743135c84c8399f..e127dbb1f638cd48bfbbf48904917aab54117584 100644
--- a/modules/ai_search/src/Base/EmbeddingStrategyPluginBase.php
+++ b/modules/ai_search/src/Base/EmbeddingStrategyPluginBase.php
@@ -45,6 +45,13 @@ abstract class EmbeddingStrategyPluginBase implements EmbeddingStrategyInterface
    */
   protected int $chunkSize;
 
+  /**
+   * If we should skip moderation (mostly NO!).
+   *
+   * @var bool
+   */
+  protected bool $skipModeration;
+
   /**
    * The chunk minimum overlap.
    *
@@ -118,6 +125,7 @@ abstract class EmbeddingStrategyPluginBase implements EmbeddingStrategyInterface
     $this->textChunker->setModel($chat_model_id);
     /** @var \Drupal\ai\OperationType\Embeddings\EmbeddingsInterface $embeddingLlm */
     $this->embeddingLlm = $this->aiProviderManager->createInstance($this->providerId);
+    $this->skipModeration = !empty($configuration['skip_moderation']);
     if (!empty($configuration['chunk_size']) && is_numeric($configuration['chunk_size'])) {
       $this->chunkSize = (int) $configuration['chunk_size'];
     }
@@ -179,6 +187,14 @@ abstract class EmbeddingStrategyPluginBase implements EmbeddingStrategyInterface
     if (empty($configuration)) {
       $configuration = $this->getDefaultConfigurationValues();
     }
+
+    $form['skip_moderation'] = [
+      '#title' => $this->t('Skip moderation for embeddings (only for advanced use cases).'),
+      '#description' => $this->t('Only check this if you are 100% sure what you are doing.'),
+      '#type' => 'checkbox',
+      '#default_value' => $configuration['skip_moderation'] ?? FALSE,
+    ];
+
     $form['chunk_size'] = [
       '#title' => $this->t('Maximum chunk size allowed when breaking up larger content'),
       '#description' => $this->t('When the content is longer than this in tokens (which roughly equates to syllables when oversimplified), the content should be broken into smaller "Chunks". This setting defines how to segment or break up the larger text. When configuring Fields for this Index, the fields with the indexing option "Main Content" will be split into chunks no greater than this size. This includes any added "Contextual Content" as well as the "Title" to ensure an accurate vectorized representation of the content. More details are provided when configuring the Fields within your Index. Leave this blank to use the maximum size provided by the selected model.'),
diff --git a/modules/ai_search/src/Plugin/EmbeddingStrategy/EmbeddingBase.php b/modules/ai_search/src/Plugin/EmbeddingStrategy/EmbeddingBase.php
index 78595875897301765e669c2efeea3c41cfa8809b..7645cfd5d1859d41943f2209c133c304a05e0ef4 100644
--- a/modules/ai_search/src/Plugin/EmbeddingStrategy/EmbeddingBase.php
+++ b/modules/ai_search/src/Plugin/EmbeddingStrategy/EmbeddingBase.php
@@ -118,10 +118,14 @@ class EmbeddingBase extends EmbeddingStrategyPluginBase implements EmbeddingStra
       if ($chunk) {
         // Normalize the chunk before embedding it.
         $input = new EmbeddingsInput($chunk);
+        $tags = ['ai_search'];
+        if ($this->skipModeration) {
+          $tags[] = 'skip_moderation';
+        }
         $raw_embeddings[] = $embedding_llm->embeddings(
           $input,
           $this->modelId,
-          ['ai_search'],
+          $tags,
         )->getNormalized();
       }
     }
@@ -313,7 +317,8 @@ class EmbeddingBase extends EmbeddingStrategyPluginBase implements EmbeddingStra
    */
   public function buildBaseMetadata(array $fields, IndexInterface $index): array {
     $metadata = [];
-    $index_config = $this->configFactory->get('ai_search.index.' . $index->id())->getRawData();
+    $index_config = $this->configFactory->get('ai_search.index.' . $index->id())
+      ->getRawData();
     $indexing_options = $index_config['indexing_options'];
     foreach ($fields as $field) {
 
@@ -346,7 +351,8 @@ class EmbeddingBase extends EmbeddingStrategyPluginBase implements EmbeddingStra
    *   The metadata to attach to the vector database record.
    */
   public function addContentToMetadata(array $metadata, string $content, IndexInterface $index): array {
-    $ai_search_index_config = $this->configFactory->get('ai_search.index.' . $index->id())->getRawData();
+    $ai_search_index_config = $this->configFactory->get('ai_search.index.' . $index->id())
+      ->getRawData();
     if (
       !isset($ai_search_index_config['exclude_chunk_from_metadata'])
       || !$ai_search_index_config['exclude_chunk_from_metadata']
diff --git a/src/Plugin/ConfigAction/SetupAiProvider.php b/src/Plugin/ConfigAction/SetupAiProvider.php
index 11d9f0f1bd13ef414381fd2b89185a8b701348f3..eacad19f5f4eb9e94f3eb22073f72fbeacfe53a2 100644
--- a/src/Plugin/ConfigAction/SetupAiProvider.php
+++ b/src/Plugin/ConfigAction/SetupAiProvider.php
@@ -51,8 +51,18 @@ final class SetupAiProvider implements ConfigActionPluginInterface, ContainerFac
     assert(is_array($value));
     // Provider has to be set.
     assert(isset($value['provider']));
+
+    // If the value is empty, we can still try to get it from environment vars.
+    if ((empty($value['key_value']) || str_starts_with($value['key_value'], "\${")) && isset($value['env_var'])) {
+      $value['key_value'] = getenv($value['env_var']);
+    }
+
+    // Stop if we don't have a key for this provider.
+    if (empty($value['key_value'])) {
+      return;
+    }
+
     // Load the provider.
-    $provider = [];
     try {
       $provider = $this->aiProviderPluginManager->createInstance($value['provider']);
     }
@@ -63,12 +73,8 @@ final class SetupAiProvider implements ConfigActionPluginInterface, ContainerFac
     if ($provider->getSetupData()) {
       $setupData = $provider->getSetupData();
     }
-    // If the value is empty, we can still try to get it from environment vars.
-    if ((empty($value['key_value']) || str_starts_with($value['key_value'], "\${")) && isset($value['env_var'])) {
-      $value['key_value'] = getenv($value['env_var']);
-    }
-    if (isset($value['provider']) && !empty($value['key_value']) && !empty($setupData['key_config_name'])) {
-      // Create a key.
+    if (!empty($setupData['key_config_name'])) {
+      // Create a key and set against the provider config.
       $key = $this->createKeyFromApiKey($value['key_name'], $value['key_label'], $value['key_value']);
 
       $this->simpleConfigUpdate->apply($configName, [