--- a/uncanny-automator/src/api/application/class-application-bootstrap.php
+++ b/uncanny-automator/src/api/application/class-application-bootstrap.php
@@ -0,0 +1,34 @@
+<?php
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiApplication;
+
+use Uncanny_AutomatorApiServicesServices_Bootstrap;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolMcp_Bootstrap;
+use Uncanny_AutomatorApiTransportsRestfulRestful_Bootstrap;
+
+/**
+ * Application Bootstrap.
+ *
+ * Central bootstrap class for all API applications.
+ * Initializes services, MCP and RESTful transport layers.
+ *
+ * @since 7.0
+ */
+class Application_Bootstrap {
+
+ /**
+ * Initialize all API applications.
+ *
+ * @since 7.0
+ *
+ * @return void
+ */
+ public function init() {
+ // Initialize services layer (hooks, legacy compatibility).
+ ( new Services_Bootstrap() )->init();
+
+ // Initialize transport layers.
+ ( new Mcp_Bootstrap() )->init();
+ ( new Restful_Bootstrap() )->init();
+ }
+}
--- a/uncanny-automator/src/api/application/mcp/class-mcp-client.php
+++ b/uncanny-automator/src/api/application/mcp/class-mcp-client.php
@@ -0,0 +1,745 @@
+<?php
+/**
+ * MCP Chat Client.
+ *
+ * Handles secure communication with the Model Context Protocol chat service.
+ *
+ * @since 7.0.0
+ */
+
+declare(strict_types=1);
+
+namespace Uncanny_AutomatorApiApplicationMcp;
+
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Context_Service;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Payload_Service;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Public_Key_Manager;
+use Uncanny_AutomatorApiTransportsModel_Context_ProtocolClientClient_Token_Service;
+use Uncanny_AutomatorApi_Server;
+use Uncanny_AutomatorTraitsSingleton;
+use WP_Error;
+use WP_Post;
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * Class Mcp_Client
+ *
+ * @since 7.0.0 Moved to Application layer.
+ */
+// phpcs:disable WordPress.Security.NonceVerification -- MCP client uses custom authentication.
+class Mcp_Client {
+
+ use Singleton;
+
+ /**
+ * Default inference service URL.
+ */
+ const INFERENCE_URL = 'https://llm.automatorplugin.com';
+
+ /**
+ * Default SDK URL for chat components.
+ */
+ const SDK_URL = 'https://llm.automatorplugin.com/sdk.js';
+
+ /**
+ * Default SDK CSS URL for chat components.
+ */
+ const SDK_CSS_URL = 'https://llm.automatorplugin.com/sdk.css';
+
+ /**
+ * Context helper.
+ *
+ * @var Client_Context_Service
+ */
+ private Client_Context_Service $context_service;
+
+ /**
+ * Public key helper.
+ *
+ * @var Client_Public_Key_Manager
+ */
+ private Client_Public_Key_Manager $public_key_manager;
+
+ /**
+ * Token helper.
+ *
+ * @var Client_Token_Service
+ */
+ private Client_Token_Service $token_service;
+
+ /**
+ * Payload helper.
+ *
+ * @var Client_Payload_Service
+ */
+ private Client_Payload_Service $payload_service;
+
+ /**
+ * Constructor.
+ *
+ * @param Client_Context_Service|null $context_service Optional context helper.
+ * @param Client_Public_Key_Manager|null $public_key_manager Optional public key helper.
+ * @param Client_Token_Service|null $token_service Optional token helper.
+ * @param Client_Payload_Service|null $payload_service Optional payload helper.
+ */
+ public function __construct(
+ ?Client_Context_Service $context_service = null,
+ ?Client_Public_Key_Manager $public_key_manager = null,
+ ?Client_Token_Service $token_service = null,
+ ?Client_Payload_Service $payload_service = null
+ ) {
+ $this->context_service = $context_service ? $context_service : new Client_Context_Service();
+ $this->public_key_manager = $public_key_manager ? $public_key_manager : new Client_Public_Key_Manager();
+ $this->token_service = $token_service ? $token_service : new Client_Token_Service();
+ $this->payload_service = $payload_service ? $payload_service : Client_Payload_Service::builder()
+ ->with_token_service( $this->token_service )
+ ->with_public_key_manager( $this->public_key_manager )
+ ->build();
+
+ $this->register_hooks();
+ }
+
+ /**
+ * Register WordPress hooks.
+ *
+ * @return void
+ */
+ private function register_hooks(): void {
+ add_action( 'admin_footer', array( $this, 'load_chat_sdk' ) );
+ add_action( 'edit_form_after_title', array( $this, 'render_launcher' ) );
+ add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
+ }
+
+ /**
+ * Register REST API routes.
+ *
+ * @return void
+ */
+ public function register_rest_routes(): void {
+ register_rest_route(
+ 'uap/v2',
+ '/mcp/chat/refresh',
+ array(
+ 'methods' => 'POST',
+ 'callback' => array( $this, 'refresh_payload' ),
+ 'permission_callback' => array( $this, 'ensure_admin_permissions' ),
+ 'args' => array(
+ 'page_url' => array(
+ 'required' => false,
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'validate_callback' => array( $this, 'validate_page_url' ),
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ 'uap/v2',
+ '/mcp/chat/launcher/(?P<recipe_id>d+)',
+ array(
+ 'methods' => 'GET',
+ 'callback' => array( $this, 'get_launcher_html' ),
+ 'permission_callback' => array( $this, 'ensure_admin_permissions' ),
+ 'args' => array(
+ 'recipe_id' => array(
+ 'required' => true,
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Validate the optional page_url parameter.
+ *
+ * @param mixed $value Candidate value.
+ * @return bool
+ */
+ public function validate_page_url( $value ): bool {
+ if ( null === $value || '' === $value ) {
+ return true;
+ }
+
+ if ( ! is_string( $value ) ) {
+ return false;
+ }
+
+ $value = trim( $value );
+
+ if ( '' === $value ) {
+ return false;
+ }
+
+ if ( 0 === strpos( $value, '//' ) ) {
+ return false;
+ }
+
+ if ( preg_match( '#^[a-zA-Z][a-zA-Z0-9+-.]*:#', $value ) ) {
+ $parts = wp_parse_url( $value );
+ $scheme = strtolower( $parts['scheme'] ?? '' );
+
+ if ( false === $parts || ! in_array( $scheme, array( 'http', 'https' ), true ) ) {
+ return false;
+ }
+
+ return ! empty( $parts['host'] );
+ }
+
+ return 0 === strpos( $value, '/' );
+ }
+
+ /**
+ * Permission callback used by the REST API route.
+ *
+ * @return bool
+ */
+ public function ensure_admin_permissions(): bool {
+ return $this->context_service->user_has_capability();
+ }
+
+ /**
+ * Get the current user's display name.
+ *
+ * @return string
+ */
+ public function get_current_user_display_name(): string {
+ return $this->context_service->get_current_user_display_name();
+ }
+
+ /**
+ * Load the MCP chat SDK in the admin.
+ *
+ * @return void
+ */
+ public function load_chat_sdk(): void {
+ if ( ! $this->context_service->can_access_client() ) {
+ return;
+ }
+
+ if ( ! $this->context_service->is_recipe_screen() ) {
+ return;
+ }
+
+ $force_refresh = isset( $_GET['mcp_refresh_key'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['mcp_refresh_key'] ) );
+
+ if ( ! $this->public_key_manager->ensure_public_key_ready( $force_refresh ) ) {
+ return;
+ }
+
+ printf(
+ '<script src="%s" type="module"></script> <link rel="stylesheet" href="%s">', // phpcs:ignore WordPress.WP.EnqueuedResources -- MCP launcher web component requires inline loading.
+ esc_url( $this->get_sdk_url() ),
+ esc_url( $this->get_sdk_css_url() )
+ );
+ }
+
+ /**
+ * Render the chat launcher button.
+ *
+ * @param WP_Post|null $post Current post.
+ * @return void
+ */
+ public function render_launcher( ?WP_Post $post ): void {
+ if ( ! $this->context_service->should_render_button( $post ) ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is escaped in generate_launcher_html.
+ echo $this->generate_launcher_html( $post->ID );
+ }
+
+ /**
+ * Generate the launcher HTML element including CSS.
+ *
+ * Handles all the logic: payload generation, recipe fetching, context building, CSS styles, and HTML element. Returns empty string on any failure.
+ *
+ * @param int $recipe_id Recipe post ID.
+ * @return string The CSS and launcher HTML, or empty string on failure.
+ */
+ private function generate_launcher_html( int $recipe_id ): string {
+
+ // Ensure the post is a valid recipe.
+ if ( 'uo-recipe' !== get_post_type( $recipe_id ) ) {
+ return '';
+ }
+
+ // Overwrite with the recipe edit page if the recipe ID is available.
+ if ( ! empty( $recipe_id ) ) {
+ $overrides = array(
+ 'page_url' => wp_make_link_relative( get_edit_post_link( $recipe_id ) ),
+ );
+ }
+
+ $payload = $this->payload_service->generate_encrypted_payload( $overrides );
+
+ if ( '' === $payload ) {
+ return '';
+ }
+
+ $recipe = Automator()->get_recipe_object( $recipe_id, ARRAY_A );
+
+ if ( empty( $recipe ) || ! is_array( $recipe ) ) {
+ return '';
+ }
+
+ // Skip if recipe type is not set yet - button will be loaded via AJAX after user selects type.
+ if ( empty( $recipe['recipe_type'] ) ) {
+ return '';
+ }
+
+ $context = array(
+ 'current_mode' => array( 'recipe building', 'running action' ),
+ 'current_user' => array(
+ 'firstname' => $this->context_service->get_current_user_display_name() ?? '',
+ ),
+ 'current_recipe' => $this->transform_recipe_to_context( $recipe ),
+ 'current_user_plan' => array(
+ 'id' => $this->context_service->get_current_user_plan(),
+ 'name' => $this->context_service->get_current_user_plan_name(),
+ 'can_add_scheduled_actions' => 'lite' !== $this->context_service->get_current_user_plan(),
+ 'can_run_loops' => 'lite' !== $this->context_service->get_current_user_plan(),
+ 'can_add_action_conditions' => 'lite' !== $this->context_service->get_current_user_plan(),
+ ),
+ 'metadata' => $this->get_metadata(),
+ );
+
+ $css = '<style>
+ #poststuff {
+ container-type: inline-size;
+ container-name: recipe-container;
+ min-width: auto !important;
+ }
+
+ @container recipe-container (max-width: 800px) {
+ #post-body {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ margin-right: 0 !important;
+ }
+
+ #post-body,
+ #postbox-container-1,
+ #postbox-container-2,
+ #side-sortables {
+ margin-right: 0 !important;
+ width: 100% !important;
+ }
+ }
+
+ ua-chat-launcher {
+ --ua-mpc-chat-launcher-z-index: 159900;
+ }
+ </style>';
+
+ $launcher = sprintf(
+ '<ua-chat-launcher server-url="%s" payload="%s" initial-context='%s' parent-selector="#wpbody" consumer-server-url="%s" consumer-nonce="%s"></ua-chat-launcher>',
+ esc_attr( self::get_inference_url() ),
+ esc_attr( $payload ),
+ esc_attr( base64_encode( wp_json_encode( $context ) ) ),
+ esc_url_raw( rest_url() . AUTOMATOR_REST_API_END_POINT ),
+ esc_attr( wp_create_nonce( 'wp_rest' ) )
+ );
+
+ return $css . $launcher;
+ }
+
+ /**
+ * Transform recipe data structure to match the expected context schema.
+ *
+ * This method maps the recipe data from Automator()->get_recipe_object()
+ * to the structure expected by the MCP chat client (validated by Zod/Pydantic).
+ *
+ * @param array $recipe Recipe data from Automator()->get_recipe_object().
+ * @return array Transformed recipe context with keys: id, title, triggers, actions, conditions_group.
+ */
+ private function transform_recipe_to_context( array $recipe ): array {
+
+ // Initialize context with safe defaults.
+ $context = array(
+ 'id' => 0,
+ 'title' => '',
+ 'recipe_type' => '',
+ 'triggers' => array(),
+ 'actions' => array(),
+ 'conditions_group' => array(),
+ );
+
+ // Extract and validate recipe ID.
+ if ( isset( $recipe['recipe_id'] ) ) {
+ $context['id'] = absint( $recipe['recipe_id'] );
+ }
+
+ // Extract and sanitize recipe title.
+ if ( isset( $recipe['title'] ) && is_string( $recipe['title'] ) ) {
+ $context['title'] = sanitize_text_field( $recipe['title'] );
+ }
+
+ // Extract recipe user type ('user' or 'anonymous').
+ if ( isset( $recipe['recipe_type'] ) && is_string( $recipe['recipe_type'] ) ) {
+ $context['recipe_type'] = sanitize_text_field( $recipe['recipe_type'] );
+ }
+
+ // Transform triggers.
+ $context['triggers'] = $this->extract_triggers( $recipe );
+
+ // Transform actions and condition groups.
+ $actions_data = $this->extract_actions_and_conditions( $recipe );
+
+ $context['actions'] = $actions_data['actions'] ?? array();
+ $context['conditions_group'] = $actions_data['conditions_group'] ?? array();
+
+ /**
+ * Filter the transformed recipe context before it is sent to the MCP client.
+ *
+ * @param array $context Transformed recipe context.
+ * @param array $recipe Original recipe data.
+ */
+ return apply_filters( 'automator_mcp_transform_recipe_context', $context, $recipe );
+ }
+
+ /**
+ * Extract triggers from recipe data.
+ *
+ * @param array $recipe Recipe data.
+ * @return array Array of trigger objects with id and sentence.
+ */
+ private function extract_triggers( array $recipe ): array {
+
+ $triggers = array();
+
+ if ( ! isset( $recipe['triggers']['items'] ) || ! is_array( $recipe['triggers']['items'] ) ) {
+ return $triggers;
+ }
+
+ foreach ( $recipe['triggers']['items'] as $trigger ) {
+
+ if ( ! is_array( $trigger ) ) {
+ continue;
+ }
+
+ // Validate required fields.
+ if ( ! isset( $trigger['id'] ) || ! isset( $trigger['backup']['sentence'] ) ) {
+ continue;
+ }
+
+ // Skip if sentence is empty.
+ if ( empty( $trigger['backup']['sentence'] ) || ! is_string( $trigger['backup']['sentence'] ) ) {
+ continue;
+ }
+
+ $triggers[] = array(
+ 'id' => absint( $trigger['id'] ),
+ 'sentence' => sanitize_text_field( $trigger['backup']['sentence'] ),
+ );
+
+ }
+
+ return $triggers;
+ }
+
+ /**
+ * Extract actions and condition groups from recipe data.
+ *
+ * @param array $recipe Recipe data.
+ * @return array Array with 'actions' and 'conditions_group' keys.
+ */
+ private function extract_actions_and_conditions( array $recipe ): array {
+
+ $actions = array();
+ $conditions_group = array();
+
+ if ( ! isset( $recipe['actions']['items'] ) || ! is_array( $recipe['actions']['items'] ) ) {
+ return array(
+ 'actions' => $actions,
+ 'conditions_group' => $conditions_group,
+ );
+ }
+
+ foreach ( $recipe['actions']['items'] as $item ) {
+
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+
+ $item_type = $item['type'] ?? '';
+
+ // Handle root-level actions.
+ if ( 'action' === $item_type ) {
+ $action = $this->extract_single_action( $item );
+ if ( ! empty( $action ) ) {
+ $actions[] = $action;
+ }
+ }
+
+ // Handle condition groups (filters).
+ if ( 'filter' === $item_type ) {
+ $group = $this->extract_condition_group( $item );
+ // Only add non-empty groups.
+ if ( ! empty( $group['conditions'] ) || ! empty( $group['actions'] ) ) {
+ $conditions_group[] = $group;
+ }
+ }
+ }
+
+ return array(
+ 'actions' => $actions,
+ 'conditions_group' => $conditions_group,
+ );
+ }
+
+ /**
+ * Extract a single action from item data.
+ *
+ * @param array $item Action item data.
+ * @return array Action object with id and sentence, or empty array if invalid.
+ */
+ private function extract_single_action( array $item ): array {
+
+ // Validate required fields.
+ if ( ! isset( $item['id'] ) || ! isset( $item['backup']['sentence'] ) ) {
+ return array();
+ }
+
+ // Skip if sentence is empty.
+ if ( empty( $item['backup']['sentence'] ) || ! is_string( $item['backup']['sentence'] ) ) {
+ return array();
+ }
+
+ return array(
+ 'id' => absint( $item['id'] ),
+ 'sentence' => sanitize_text_field( $item['backup']['sentence'] ),
+ );
+ }
+
+ /**
+ * Extract a condition group (filter) from item data.
+ *
+ * @param array $item Filter item data.
+ * @return array Group object with conditions and actions arrays.
+ */
+ private function extract_condition_group( array $item ): array {
+
+ $group = array(
+ 'conditions' => array(),
+ 'actions' => array(),
+ );
+
+ // Extract conditions.
+ if ( isset( $item['conditions'] ) && is_array( $item['conditions'] ) ) {
+
+ foreach ( $item['conditions'] as $condition ) {
+
+ if ( ! is_array( $condition ) ) {
+ continue;
+ }
+
+ // Validate required fields.
+ if ( ! isset( $condition['id'] ) || ! isset( $condition['backup']['sentence'] ) ) {
+ continue;
+ }
+
+ // Skip if sentence is empty.
+ if ( empty( $condition['backup']['sentence'] ) || ! is_string( $condition['backup']['sentence'] ) ) {
+ continue;
+ }
+
+ $group['conditions'][] = array(
+ 'id' => sanitize_text_field( $condition['id'] ),
+ 'sentence' => sanitize_text_field( $condition['backup']['sentence'] ),
+ );
+
+ }
+ }
+
+ // Extract actions within the filter.
+ if ( isset( $item['items'] ) && is_array( $item['items'] ) ) {
+
+ foreach ( $item['items'] as $filter_action ) {
+
+ if ( ! is_array( $filter_action ) ) {
+ continue;
+ }
+
+ // Only process action types.
+ if ( 'action' !== ( $filter_action['type'] ?? '' ) ) {
+ continue;
+ }
+
+ $action = $this->extract_single_action( $filter_action );
+
+ if ( ! empty( $action ) ) {
+ $group['actions'][] = $action;
+ }
+ }
+ }
+
+ return $group;
+ }
+
+ /**
+ * Inference service URL.
+ *
+ * @return string
+ */
+ public static function get_inference_url(): string {
+ $url = defined( 'AUTOMATOR_MCP_CLIENT_INFERENCE_URL' ) && AUTOMATOR_MCP_CLIENT_INFERENCE_URL
+ ? AUTOMATOR_MCP_CLIENT_INFERENCE_URL
+ : self::INFERENCE_URL;
+
+ return apply_filters( 'automator_mcp_client_inference_url', $url );
+ }
+
+ /**
+ * REST callback that returns a refreshed encrypted payload.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function refresh_payload( WP_REST_Request $request ) {
+ $page_url = $request->get_param( 'page_url' );
+
+ if ( null !== $page_url && ! is_string( $page_url ) ) {
+ return new WP_Error(
+ 'invalid_page_url',
+ esc_html_x( 'The supplied page URL must be a string.', 'MCP client validation error', 'uncanny-automator' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( is_string( $page_url ) && '' !== $page_url && ! $this->validate_page_url( $page_url ) ) {
+ return new WP_Error(
+ 'invalid_page_url',
+ esc_html_x( 'The supplied page URL is invalid.', 'MCP client validation error', 'uncanny-automator' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( ! $this->public_key_manager->ensure_public_key_ready() ) {
+ return new WP_Error(
+ 'public_key_unavailable',
+ esc_html_x( 'Unable to load the required encryption key.', 'MCP client validation error', 'uncanny-automator' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ $payload = $this->payload_service->generate_encrypted_payload(
+ is_string( $page_url ) ? array( 'page_url' => $page_url ) : array()
+ );
+
+ if ( '' === $payload ) {
+ return new WP_Error(
+ 'encryption_failed',
+ esc_html_x( 'Could not generate the encrypted payload.', 'MCP client validation error', 'uncanny-automator' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return rest_ensure_response(
+ array(
+ 'encrypted_payload' => $payload,
+ )
+ );
+ }
+
+ /**
+ * REST callback that returns the chat launcher HTML.
+ *
+ * This endpoint is used to fetch the chat launcher button after the user has selected a recipe type.
+ * Temporary solution until the recipe type selector is removed.
+ *
+ * @param WP_REST_Request $request REST request.
+ * @return WP_REST_Response
+ */
+ public function get_launcher_html( WP_REST_Request $request ) {
+ $html = $this->generate_launcher_html( $request->get_param( 'recipe_id' ) );
+
+ return rest_ensure_response(
+ array(
+ 'html' => $html,
+ )
+ );
+ }
+
+ /**
+ * Get the SDK URL.
+ *
+ * Appends a license hash parameter for beta enrollment verification.
+ * The hash is computed as HMAC-SHA256(license_key, license_key) to avoid
+ * exposing raw license keys in URLs.
+ *
+ * @return string
+ */
+ private function get_sdk_url(): string {
+ // Check if developer explicitly defined a custom SDK URL.
+ $is_custom_url = defined( 'AUTOMATOR_MCP_CLIENT_SDK_URL' ) && AUTOMATOR_MCP_CLIENT_SDK_URL;
+
+ $url = $is_custom_url
+ ? AUTOMATOR_MCP_CLIENT_SDK_URL
+ : self::SDK_URL;
+
+ // Only validate URLs that aren't explicitly defined by developers.
+ // This allows localhost URLs for development while protecting against injection in production.
+ if ( ! $is_custom_url && ! wp_http_validate_url( $url ) ) {
+ $url = self::SDK_URL;
+ }
+
+ // Allow URL overwrite via filter.
+ $url = apply_filters( 'automator_mcp_client_sdk_url', $url );
+
+ // Append license hash for beta enrollment check.
+ $license_key = Api_Server::get_license_key();
+ if ( ! empty( $license_key ) ) {
+ $license_hash = hash_hmac( 'sha256', $license_key, $license_key );
+ $url = add_query_arg( 'l', $license_hash, $url );
+ }
+
+ // Append plugin version for cache busting.
+ $version = AUTOMATOR_PLUGIN_VERSION; // No need to check constant - defined in main plugin file.
+ $url = add_query_arg( 'v', $version, $url );
+
+ return $url;
+ }
+
+ /**
+ * Get the SDK CSS URL.
+ *
+ * @return string
+ */
+ private function get_sdk_css_url(): string {
+ $url = defined( 'AUTOMATOR_MCP_CLIENT_SDK_CSS_URL' ) && AUTOMATOR_MCP_CLIENT_SDK_CSS_URL
+ ? AUTOMATOR_MCP_CLIENT_SDK_CSS_URL
+ : self::SDK_CSS_URL;
+
+ return apply_filters( 'automator_mcp_client_sdk_css_url', $url );
+ }
+
+ /**
+ * Get metadata to send to the MCP client.
+ *
+ * @return string
+ */
+ private function get_metadata(): string {
+
+ global $wp;
+
+ $current_url = home_url( add_query_arg( $_GET, $wp->request ) );
+
+ $server_software = sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification -- No sensitive data.
+
+ $metadata = array(
+ 'plugin_version' => AUTOMATOR_PLUGIN_VERSION,
+ 'current_url' => $current_url,
+ 'php_version' => PHP_VERSION,
+ 'wp_version' => get_bloginfo( 'version' ),
+ 'server_software' => ! empty( $server_software ) ? $server_software : 'unknown',
+ );
+
+ // Safe to ignore false return - array is well-formed. Using base64 to safely encode JSON for payload.
+ return base64_encode( wp_json_encode( $metadata ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- No sensitive data.
+ }
+}
--- a/uncanny-automator/src/api/application/recipe_runner/processor.php
+++ b/uncanny-automator/src/api/application/recipe_runner/processor.php
@@ -0,0 +1,3 @@
+<?php
+declare(strict_types=1);
+// Future recipe runner implementing saga/pipeline pattern.
--- a/uncanny-automator/src/api/application/sub_tooling/class-action-executor.php
+++ b/uncanny-automator/src/api/application/sub_tooling/class-action-executor.php
@@ -0,0 +1,724 @@
+<?php
+/**
+ * Action Executor - Production-grade reflection-based action execution.
+ *
+ * Executes ANY registered Automator action via reflection.
+ * Supports all 4 action styles:
+ * - App_Action (92 actions)
+ * - Abstract Action (82 actions)
+ * - Trait-based (106 actions)
+ * - Legacy (57 actions)
+ *
+ * Total: 500+ agents with comprehensive error handling coverage.
+ *
+ * @package Uncanny_AutomatorApiApplicationSub_Tooling
+ * @since 7.0.0
+ */
+
+namespace Uncanny_AutomatorApiApplicationSub_Tooling;
+
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Code;
+use Uncanny_AutomatorApiServicesActionUtilitiesAction_Validator;
+use WP_Error;
+
+/**
+ * Class Action_Executor
+ *
+ * @since 7.0.0
+ */
+class Action_Executor {
+
+ /**
+ * Banned action codes that cannot be executed via the AI agent.
+ *
+ * These actions are restricted for safety reasons. Each entry maps an action
+ * code to a human-readable reason that will be shown to the AI.
+ *
+ * @since 7.0.0
+ * @var array<string, string>
+ */
+ private const BANNED_ACTIONS = array(
+ 'DB_QUERY_RUN_QUERY_STRING' => 'Running raw database queries is restricted. Use the MySQL select tools (mysql_get_tables, mysql_get_table_columns, mysql_select_from_table) for read-only database operations.',
+ 'DB_QUERY_SELECT_QUERY_RUN' => 'Use the MySQL select tools (mysql_get_tables, mysql_get_table_columns, mysql_select_from_table) instead.',
+ 'DELETEUSER' => 'Deleting users is restricted for safety.',
+ );
+
+ /**
+ * Current action code value object (for logging and downstream use).
+ *
+ * @var Action_Code|null
+ */
+ private $action_code = null;
+
+ /**
+ * Cached validator instance (lazy-loaded).
+ *
+ * @var Action_Validator|null
+ */
+ private $validator = null;
+
+ /**
+ * Execution start time for metrics.
+ *
+ * @var float
+ */
+ private $execution_start = 0.0;
+
+ /**
+ * Execute any action by code using reflection.
+ *
+ * @param string $action_code The action code (e.g., 'SENDEMAIL', 'SLACKSENDMESSAGE').
+ * @param array $fields Key-value field data. Keys must match action's option_code values.
+ * @param int $user_id User context for the action execution.
+ *
+ * @return array{success: bool, data: array, error?: string, tokens?: array, execution_time_ms?: float}|WP_Error
+ */
+ public function run( string $action_code, array $fields, int $user_id ) {
+
+ $this->execution_start = microtime( true );
+
+ // Create Action_Code value object (validates format and length).
+ try {
+ $this->action_code = new Action_Code( $action_code );
+ } catch ( InvalidArgumentException $e ) {
+ // Map exception message to appropriate error code.
+ $error_code = 'invalid_action_code';
+ if ( strpos( $e->getMessage(), 'empty' ) !== false ) {
+ $error_code = 'empty_action_code';
+ } elseif ( strpos( $e->getMessage(), 'uppercase' ) !== false ) {
+ $error_code = 'invalid_action_code_format';
+ }
+
+ $this->log( 'error', 'Action code validation failed: ' . $e->getMessage() );
+ return new WP_Error( $error_code, $e->getMessage() );
+ }
+
+ // Check if action is banned for AI execution.
+ $ban_check = $this->check_action_banned( $this->action_code->get_value() );
+ if ( is_wp_error( $ban_check ) ) {
+ $this->log( 'warning', 'Banned action attempted: ' . $this->action_code->get_value() );
+ return $ban_check;
+ }
+
+ // Validate user ID.
+ $user_validation = $this->validate_user_id( $user_id );
+ if ( is_wp_error( $user_validation ) ) {
+ $this->log( 'error', 'User validation failed: ' . $user_validation->get_error_message() );
+ return $user_validation;
+ }
+
+ // Validate fields against action schema BEFORE normalization (validator expects raw input).
+ $field_validation = $this->validate_fields( $this->action_code->get_value(), $fields );
+ if ( is_wp_error( $field_validation ) ) {
+ $this->log( 'error', 'Field validation failed: ' . $field_validation->get_error_message() );
+ return $field_validation;
+ }
+
+ // Normalize fields AFTER validation (JSON-encode arrays for repeater/multi-select fields).
+ $fields = $this->normalize_fields( $fields );
+
+ // Get action definition from registry.
+ $definition = $this->get_action_definition( $this->action_code );
+ if ( is_wp_error( $definition ) ) {
+ $this->log( 'error', 'Definition not found: ' . $definition->get_error_message() );
+ return $definition;
+ }
+
+ $action = $definition['instance'];
+
+ $action_code_str = $this->action_code->get_value();
+
+ /**
+ * Fires before action execution.
+ *
+ * @param string $action_code The action code.
+ * @param array $fields Field values provided.
+ * @param int $user_id User ID context.
+ * @param object $action The action instance.
+ *
+ * @since 7.0.0
+ */
+ do_action( 'automator_agent_before_execute', $action_code_str, $fields, $user_id, $action );
+
+ // Route to appropriate execution method based on action style.
+ if ( method_exists( $action, 'process_action' ) ) {
+ $result = $this->invoke_modern_action( $action, $fields, $user_id );
+ } else {
+ $method = $definition['method'];
+ if ( empty( $method ) || ! method_exists( $action, $method ) ) {
+ return new WP_Error(
+ 'no_execution_method',
+ /* translators: %s: Action code */
+ sprintf( esc_html_x( "Action '%s' has no executable method", 'Error message when action has no execution method', 'uncanny-automator' ), esc_html( $action_code_str ) )
+ );
+ }
+ $result = $this->invoke_legacy_action( $action, $method, $fields, $user_id );
+ }
+
+ // Add execution metrics.
+ $result['execution_time_ms'] = round( ( microtime( true ) - $this->execution_start ) * 1000, 2 );
+
+ /**
+ * Fires after action execution.
+ *
+ * @param string $action_code The action code.
+ * @param array $fields Field values provided.
+ * @param int $user_id User ID context.
+ * @param array $result Execution result.
+ *
+ * @since 7.0.0
+ */
+ do_action( 'automator_agent_after_execute', $action_code_str, $fields, $user_id, $result );
+
+ $this->log(
+ $result['success'] ? 'info' : 'error',
+ sprintf(
+ 'Execution %s (%.2fms)',
+ $result['success'] ? 'succeeded' : 'failed',
+ $result['execution_time_ms']
+ )
+ );
+
+ return $result;
+ }
+
+ /**
+ * Validate user ID.
+ *
+ * @param int $user_id User ID.
+ *
+ * @return true|WP_Error
+ */
+ private function validate_user_id( int $user_id ) {
+
+ if ( $user_id < 0 ) {
+ return new WP_Error( 'invalid_user_id', 'User ID must be non-negative' );
+ }
+
+ // User ID 0 is valid for anonymous/system actions.
+ if ( $user_id > 0 && ! get_user_by( 'id', $user_id ) ) {
+ return new WP_Error( 'user_not_found', sprintf( 'User ID %d does not exist', $user_id ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if an action is banned from AI execution.
+ *
+ * Some actions are restricted for safety reasons (e.g., destructive operations).
+ * This method checks the BANNED_ACTIONS constant and returns an error if banned.
+ *
+ * @since 7.0.0
+ *
+ * @param string $action_code The action code to check.
+ *
+ * @return true|WP_Error True if allowed, WP_Error if banned.
+ */
+ private function check_action_banned( string $action_code ) {
+
+ // Allow filtering of banned actions for extensibility.
+ $banned_actions = apply_filters( 'automator_agent_banned_actions', self::BANNED_ACTIONS );
+
+ if ( ! isset( $banned_actions[ $action_code ] ) ) {
+ return true;
+ }
+
+ $reason = $banned_actions[ $action_code ];
+
+ // Build a helpful error message for the AI.
+ $message = sprintf(
+ /* translators: 1: Action code, 2: Reason for restriction */
+ "Action '%s' is not available for AI execution. %s If you need to perform this operation, please guide the user to do it manually in WordPress admin or use a recipe instead.",
+ $action_code,
+ $reason
+ );
+
+ return new WP_Error( 'action_banned', $message );
+ }
+
+ /**
+ * Validate fields against action schema.
+ *
+ * Uses Action_Validator to check required fields, formats, and business rules.
+ *
+ * @param string $action_code Action code.
+ * @param array $fields Field values (raw, before normalization).
+ *
+ * @return true|WP_Error
+ */
+ private function validate_fields( string $action_code, array $fields ) {
+
+ $validator = $this->get_validator();
+ $result = $validator->validate( $action_code, $fields );
+
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get or create validator instance (lazy-loaded singleton).
+ *
+ * @return Action_Validator
+ */
+ private function get_validator(): Action_Validator {
+
+ if ( null === $this->validator ) {
+ $this->validator = new Action_Validator();
+ }
+
+ return $this->validator;
+ }
+
+ /**
+ * Normalize field values for action execution.
+ *
+ * Converts array values (repeater fields, multi-select) to JSON strings,
+ * which is the format Automator actions expect.
+ *
+ * @param array $fields Raw field values from AI.
+ *
+ * @return array Normalized fields with arrays JSON-encoded.
+ */
+ private function normalize_fields( array $fields ): array {
+
+ $normalized = array();
+
+ foreach ( $fields as $key => $value ) {
+ if ( is_array( $value ) ) {
+ // Repeater fields and multi-select fields must be JSON strings.
+ $normalized[ $key ] = wp_json_encode( $value );
+ } else {
+ $normalized[ $key ] = $value;
+ }
+ }
+
+ return $normalized;
+ }
+
+ /**
+ * Get the action definition from registry.
+ *
+ * @param Action_Code $action_code Action code value object.
+ *
+ * @return array{instance: object, method: string|null}|WP_Error
+ */
+ private function get_action_definition( Action_Code $action_code ) {
+
+ if ( ! function_exists( 'Automator' ) ) {
+ return new WP_Error( 'automator_not_loaded', 'Automator not initialized' );
+ }
+
+ $code = $action_code->get_value();
+ $definition = Automator()->get_action( $code );
+
+ if ( false === $definition || empty( $definition ) ) {
+ return new WP_Error(
+ 'action_not_found',
+ /* translators: %s: Action code */
+ sprintf( esc_html_x( "Action '%s' not found in registry", 'Error message when action is not found', 'uncanny-automator' ), esc_html( $code ) )
+ );
+ }
+
+ $execution_function = $definition['execution_function'] ?? null;
+
+ if ( ! is_array( $execution_function ) || empty( $execution_function[0] ) ) {
+ return new WP_Error(
+ 'invalid_execution_function',
+ /* translators: %s: Action code */
+ sprintf( esc_html_x( "Action '%s' has invalid execution_function", 'Error message when action has invalid execution function', 'uncanny-automator' ), esc_html( $code ) )
+ );
+ }
+
+ $instance = $execution_function[0];
+ $method = $execution_function[1] ?? null;
+
+ if ( ! is_object( $instance ) ) {
+ return new WP_Error(
+ 'instance_not_object',
+ /* translators: %s: Action code */
+ sprintf( esc_html_x( "Action '%s' instance is not an object", 'Error message when action instance is invalid', 'uncanny-automator' ), esc_html( $code ) )
+ );
+ }
+
+ return array(
+ 'instance' => $instance,
+ 'method' => $method,
+ );
+ }
+
+ /**
+ * Invoke modern action via process_action method.
+ *
+ * Modern actions (App_Action, Abstract Action, Trait-based) all have process_action().
+ * Signature: process_action($user_id, $action_data, $recipe_id, $args, $parsed)
+ *
+ * @param object $action Action instance.
+ * @param array $fields Field values.
+ * @param int $user_id User ID.
+ *
+ * @return array{success: bool, data: array, error?: string}
+ */
+ private function invoke_modern_action( object $action, array $fields, int $user_id ): array {
+
+ $action_data = $this->build_action_data( $fields );
+
+ // Inject state into action instance via reflection.
+ $this->inject_action_state( $action, $user_id, $action_data, $fields );
+
+ try {
+ $method = new ReflectionMethod( $action, 'process_action' );
+
+ // PHP < 8.1 requires setAccessible for protected/private methods.
+ if ( $this->is_php_below_81() ) {
+ $method->setAccessible( true );
+ }
+
+ // Invoke with standard 5-param signature.
+ $result = $method->invoke( $action, $user_id, $action_data, 0, array(), $fields );
+
+ return $this->build_response( $action, $result );
+
+ } catch ( ReflectionException $e ) {
+ return array(
+ 'success' => false,
+ 'data' => array(),
+ 'error' => 'Reflection error: ' . $e->getMessage(),
+ );
+ } catch ( Throwable $e ) {
+ return array(
+ 'success' => false,
+ 'data' => array(),
+ 'error' => $e->getMessage(),
+ );
+ }
+ }
+
+ /**
+ * Invoke legacy action method.
+ *
+ * Legacy actions use custom method names with 4-param signature:
+ * method_name($user_id, $action_data, $recipe_id, $args)
+ *
+ * Error handling: Legacy actions call Automator()->complete_action() with errors.
+ * We capture these via the 'automator_llm_action_error' hook.
+ *
+ * @param object $action Action instance.
+ * @param string $method Method name to call.
+ * @param array $fields Field values.
+ * @param int $user_id User ID.
+ *
+ * @return array{success: bool, data: array, error?: string}
+ */
+ private function invoke_legacy_action( object $action, string $method, array $fields, int $user_id ): array {
+
+ $action_data = $this->build_action_data( $fields, true );
+
+ // Use closure variable to avoid race conditions with instance property.
+ // Each execution gets a unique UUID, allowing concurrent action executions
+ // to correctly match errors to their originating action instance.
+ $captured_error = null;
+ $error_capture = function ( $error_message, $error_action_data = null ) use ( &$captured_error, $action_data ) {
+ // Only capture if this is OUR action (prevents cross-contamination in concurrent execution).
+ // The UUID comparison ensures that if multiple actions execute simultaneously,
+ // each error is attributed to the correct action instance, not a different one.
+ if ( null === $error_action_data || ( isset( $error_action_data['execution_id'] ) && $error_action_data['execution_id'] === $action_data['execution_id'] ) ) {
+ $captured_error = $error_message;
+ }
+ };
+ add_action( 'automator_llm_action_error', $error_capture, 10, 2 );
+
+ try {
+ $reflection = new ReflectionMethod( $action, $method );
+
+ if ( $this->is_php_below_81() ) {
+ $reflection->setAccessible( true );
+ }
+
+ // Legacy 4-param signature.
+ $args = array( 'from_llm' => true );
+ $result = $reflection->invoke( $action, $user_id, $action_data, 0, $args );
+
+ return $this->build_response( $action, $result, true, $captured_error );
+
+ } catch ( ReflectionException $e ) {
+ return array(
+ 'success' => false,
+ 'data' => array(),
+ 'error' => 'Reflection error: ' . $e->getMessage(),
+ );
+ } catch ( Throwable $e ) {
+ return array(
+ 'success' => false,
+ 'data' => array(),
+ 'error' => $e->getMessage(),
+ );
+ } finally {
+ // Always clean up hook to prevent memory leaks.
+ remove_action( 'automator_llm_action_error', $error_capture, 10 );
+ }
+ }
+
+ /**
+ * Build action_data array.
+ *
+ * @param array $fields Field values.
+ * @param bool $is_legacy Whether this is a legacy action.
+ *
+ * @return array
+ */
+ private function build_action_data( array $fields, bool $is_legacy = false ): array {
+
+ $action_data = array(
+ 'ID' => 0,
+ 'meta' => $fields,
+ 'agent_mode' => true,
+ 'execution_id' => wp_generate_uuid4(), // Unique ID to prevent race conditions.
+ );
+
+ // Legacy actions need from_llm flag in action_data for error capture.
+ if ( $is_legacy ) {
+ $action_data['from_llm'] = true;
+ }
+
+ return $action_data;
+ }
+
+ /**
+ * Inject state into action instance.
+ *
+ * Modern actions expect certain properties to be set before process_action() is called.
+ *
+ * @param object $action Action instance.
+ * @param int $user_id User ID.
+ * @param array $action_data Action data array.
+ * @param array $fields Parsed field values.
+ */
+ private function inject_action_state( object $action, int $user_id, array $action_data, array $fields ): void {
+
+ $critical_properties = array(
+ 'user_id' => $user_id,
+ 'action_data' => $action_data,
+ 'recipe_id' => 0,
+ 'args' => array(),
+ 'maybe_parsed' => $fields,
+ );
+
+ foreach ( $critical_properties as $name => $value ) {
+ $set = $this->set_property( $action, $name, $value );
+
+ // Log warning for critical properties that couldn't be set.
+ if ( ! $set && in_array( $name, array( 'maybe_parsed', 'action_data' ), true ) ) {
+ $this->log( 'warning', sprintf( 'Could not set critical property: %s', $name ) );
+ }
+ }
+ }
+
+ /**
+ * Build standardized response from action result.
+ *
+ * @param object $action Action instance.
+ * @param mixed $result Raw result from action method.
+ * @param bool $is_legacy Whether this is a legacy action.
+ * @param string|null $captured_error Error captured from legacy hook.
+ *
+ * @return array{success: bool, data: array, error?: string, tokens?: array}
+ */
+ private function build_response( object $action, $result, bool $is_legacy = false, ?string $captured_error = null ): array {
+
+ // Collect errors from all possible sources.
+ $errors = $this->collect_errors( $action, $result, $is_legacy, $captured_error );
+
+ // Determine success: no errors AND result is not explicitly false.
+ $success = empty( $errors ) && false !== $result;
+
+ $response = array(
+ 'success' => $success,
+ 'data' => $this->normalize_result_data( $result ),
+ );
+
+ // Capture action tokens (from hydrate_tokens) - wrapped in try-catch for safety.
+ $tokens = $this->get_action_tokens( $action );
+ if ( ! empty( $tokens ) ) {
+ $response['tokens'] = $tokens;
+ }
+
+ if ( ! empty( $errors ) ) {
+ $response['error'] = $errors;
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get action tokens from hydrate_tokens() call.
+ *
+ * Actions store output tokens in $this->dev_input via hydrate_tokens().
+ *
+ * @param object $action Action instance.
+ *
+ * @return array
+ */
+ private function get_action_tokens( object $action ): array {
+
+ try {
+ $ref = new ReflectionClass( $action );
+
+ while ( $ref ) {
+ if ( $ref->hasProperty( 'dev_input' ) ) {
+ $prop = $ref->getProperty( 'dev_input' );
+
+ if ( $this->is_php_below_81() ) {
+ $prop->setAccessible( true );
+ }
+
+ $tokens = $prop->getValue( $action );
+ return is_array( $tokens ) ? $tokens : array();
+ }
+ $ref = $ref->getParentClass();
+ }
+ } catch ( ReflectionException $e ) {
+ $this->log( 'warning', 'Failed to retrieve action tokens: ' . $e->getMessage() );
+ }
+
+ return array();
+ }
+
+ /**
+ * Collect errors from all possible sources.
+ *
+ * @param object $action Action instance.
+ * @param mixed $result Raw result.
+ * @param bool $is_legacy Is legacy action.
+ * @param string|null $captured_error Error from legacy hook.
+ *
+ * @return string Combined error message.
+ */
+ private function collect_errors( object $action, $result, bool $is_legacy, ?string $captured_error = null ): string {
+
+ $errors = array();
+
+ // 1. Legacy error from hook (passed as parameter to avoid race conditions).
+ if ( $is_legacy && ! empty( $captured_error ) ) {
+ $errors[] = $captured_error;
+ }
+
+ // 2. Modern action errors via get_log_errors().
+ if ( method_exists( $action, 'get_log_errors' ) ) {
+ $log_errors = $action->get_log_errors();
+ if ( ! empty( $log_errors ) ) {
+ $errors[] = is_array( $log_errors ) ? implode( '; ', $log_errors ) : $log_errors;
+ }
+ }
+
+ // 3. WP_Error result.
+ if ( is_wp_error( $result ) ) {
+ $errors[] = $result->get_error_message();
+ }
+
+ return implode( ' | ', array_filter( $errors ) );
+ }
+
+ /**
+ * Normalize result data to array.
+ *
+ * @param mixed $result Raw result.
+ *
+ * @return array
+ */
+ private function normalize_result_data( $result ): array {
+
+ if ( is_array( $result ) ) {
+ return $result;
+ }
+
+ if ( is_wp_error( $result ) ) {
+ return array(
+ 'wp_error_code' => $result->get_error_code(),
+ 'wp_error_data' => $result->get_error_data(),
+ );
+ }
+
+ if ( null === $result || true === $result ) {
+ return array();
+ }
+
+ if ( is_scalar( $result ) ) {
+ return array( 'result' => $result );
+ }
+
+ if ( is_object( $result ) ) {
+ return array( 'result' => get_class( $result ) );
+ }
+
+ return array();
+ }
+
+ /**
+ * Set property via reflection, traversing parent classes.
+ *
+ * @param object $obj Object instance.
+ * @param string $name Property name.
+ * @param mixed $value Value to set.
+ *
+ * @return bool True if property was set, false otherwise.
+ */
+ private function set_property( object $obj, string $name, $value ): bool {
+
+ try {
+ $ref = new ReflectionClass( $obj );
+
+ // Traverse class hierarchy to find property.
+ while ( $ref ) {
+ if ( $ref->hasProperty( $name ) ) {
+ $prop = $ref->getProperty( $name );
+
+ if ( $this->is_php_below_81() ) {
+ $prop->setAccessible( true );
+ }
+
+ $prop->setValue( $obj, $value );
+ return true;
+ }
+ $ref = $ref->getParentClass();
+ }
+ } catch ( ReflectionException $e ) {
+ $this->log( 'warning', sprintf( 'Failed to set property %s: %s', $name, $e->getMessage() ) );
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if PHP version is below 8.1.
+ *
+ * PHP 8.1+ allows accessing private/protected properties via reflection
+ * without calling setAccessible(). This method centralizes the check.
+ *
+ * @return bool True if PHP version is below 8.1.
+ */
+ private function is_php_below_81(): bool {
+ return PHP_VERSION_ID < 80100;
+ }
+
+ /**
+ * Log message with context.
+ *
+ * @param string $level Log level (info, error, warning, debug).
+ * @param string $message Message to log.
+ */
+ private function log( string $level, string $message ): void {
+
+ if ( ! function_exists( 'automator_log' ) ) {
+ return;
+ }
+
+ $code = $this->action_code ? $this->action_code->get_value() : 'UNKNOWN';
+ $prefix = sprintf( '[ActionExecutor:%s] ', $code );
+
+ automator_log( $prefix . $message, ucfirst( $level ) );
+ }
+}
--- a/uncanny-automator/src/api/application/value_objects/class-url.php
+++ b/uncanny-automator/src/api/application/value_objects/class-url.php
@@ -0,0 +1,86 @@
+<?php
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiApplicationValue_Objects;
+
+/**
+ * URL Value Object.
+ *
+ * Represents a valid URL string.
+ * Tokens ({{...}}) bypass validation as they're resolved at runtime.
+ *
+ * @since 7.0.0
+ */
+class Url {
+
+ private string $value;
+
+ /**
+ * Constructor.
+ *
+ * @param string $value URL string to validate and store.
+ * @throws InvalidArgumentException If invalid URL.
+ */
+ public function __construct( string $value ) {
+ $this->validate( $value );
+ $this->value = $value;
+ }
+
+ /**
+ * Get URL value.
+ *
+ * @return string The URL.
+ */
+ public function get_value(): string {
+ return $this->value;
+ }
+
+ /**
+ * Convert to string.
+ *
+ * @return string The URL value.
+ */
+ public function __toString(): string {
+ return $this->value;
+ }
+
+ /**
+ * Check if URL contains tokens.
+ *
+ * @param string $value Value to check.
+ * @return bool True if contains tokens.
+ */
+ private function contains_tokens( string $value ): bool {
+ return false !== strpos( $value, '{{' ) && false !== strpos( $value, '}}' );
+ }
+
+ /**
+ * Validate URL.
+ *
+ * @param string $value Value to validate.
+ * @throws InvalidArgumentException If invalid URL.
+ */
+ private function validate( string $value ): void {
+
+ $value = trim( $value );
+
+ if ( empty( $value ) ) {
+ throw new InvalidArgumentException( esc_html_x( 'URL cannot be empty', 'URL validation error', 'uncanny-automator' ) );
+ }
+
+ // Skip validation if URL contains tokens - they'll be resolved at runtime.
+ if ( $this->contains_tokens( $value ) ) {
+ return;
+ }
+
+ // Validate URL format.
+ if ( false === filter_var( $value, FILTER_VALIDATE_URL ) ) {
+ throw new InvalidArgumentException(
+ sprintf(
+ /* translators: %s URL value */
+ esc_html_x( 'Invalid URL format: %s', 'URL validation error', 'uncanny-automator' ),
+ esc_html( $value )
+ )
+ );
+ }
+ }
+}
--- a/uncanny-automator/src/api/components/action/class-action-config.php
+++ b/uncanny-automator/src/api/components/action/class-action-config.php
@@ -0,0 +1,282 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsAction;
+
+use Uncanny_AutomatorApiComponentsRecipeValue_ObjectsRecipe_Id;
+use Uncanny_AutomatorApiComponentsActionEnumsAction_Status;
+
+/**
+ * Action Configuration.
+ *
+ * Data transfer object for action configuration with fluent interface.
+ * Contains no validation logic - serves as a bridge between raw data
+ * and validated domain objects.
+ *
+ * @since 7.0.0
+ */
+class Action_Config {
+
+ private array $data = array();
+ private $id;
+ private $recipe_id;
+ private $parent_id;
+ private $integration_code;
+ private $code;
+ private $meta_code;
+ private $user_type;
+ private $status;
+ private $meta = array();
+ private $is_deprecated = false;
+
+ /**
+ * Set a configuration value (generic approach).
+ *
+ * @param string $key Configuration key.
+ * @param mixed $value Configuration value.
+ * @return self
+ */
+ public function set( string $key, $value ): self {
+ $this->data[ $key ] = $value;
+ return $this;
+ }
+
+ /**
+ * Get a configuration value (generic approach).
+ *
+ * @param string $key Configuration key.
+ * @param mixed $default Default value if key not found.
+ * @return mixed
+ */
+ public function get( string $key, $default_value = null ) {
+ return $this->data[ $key ] ?? $default_value;
+ }
+
+ /**
+ * Set action ID.
+ *
+ * @param mixed $id Action ID.
+ * @return self
+ */
+ public function id( $id ): self {
+ $this->id = $id;
+ return $this;
+ }
+
+ /**
+ * Set action recipe ID.
+ *
+ * @param mixed $recipe_id Action recipe ID.
+ * @return self
+ */
+ public function recipe_id( $recipe_id ): self {
+ $this->recipe_id = $recipe_id;
+ return $this;
+ }
+
+ /**
+ * Set action parent ID.
+ *
+ * @param Uncanny_AutomatorApiComponentsInterfacesParent_Id $parent_id Parent identifier (Recipe_ID or Loop_ID).
+ * @return self
+ */
+ public function parent_id( $parent_id ): self {
+ $this->parent_id = $parent_id;
+ return $this;
+ }
+
+ /**
+ * Set action integration code.
+ *
+ * @param string $integration_code Integration identifier (e.g., 'WP', 'MAILCHIMP').
+ * @return self
+ */
+ public function integration_code( string $integration_code ): self {
+ $this->integration_code = $integration_code;
+ return $this;
+ }
+
+ /**
+ * Set action code.
+ *
+ * @param string $code Action code (e.g., 'SEND_EMAIL', 'CREATE_POST').
+ * @return self
+ */
+ public function code( string $code ): self {
+ $this->code = $code;
+ return $this;
+ }
+
+ /**
+ * Set action meta code.
+ *
+ * @param string $meta_code Action meta code identifier.
+ * @return self
+ */
+ public function meta_code( string $meta_code ): self {
+ $this->meta_code = $meta_code;
+ return $this;
+ }
+
+ /**
+ * Set action user type.
+ *
+ * @param string $user_type Action user type ('user' or 'anonymous').
+ * @return self
+ */
+ public function user_type( string $user_type ): self {
+ $this->user_type = $user_type;
+ return $this;
+ }
+
+ /**
+ * Set action status.
+ *
+ * @param string $status Action status ('draft' or 'publish').
+ * @return self
+ */
+ public function status( string $status ): self {
+ $this->status = $status;
+ return $this;
+ }
+
+ /**
+ * Set action meta.
+ *
+ * @param array $meta Action-specific settings and configuration.
+ * @return self
+ */
+ public function meta( array $meta ): self {
+ $this->meta = $meta;
+ return $this;
+ }
+
+ /**
+ * Set action deprecated status.
+ *
+ * @param bool $is_deprecated Whether action is deprecated.
+ * @return self
+ */
+ public function is_deprecated( bool $is_deprecated ): self {
+ $this->is_deprecated = $is_deprecated;
+ return $this;
+ }
+
+ /**
+ * Get action ID.
+ *
+ * @return mixed
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Get action recipe ID.
+ *
+ * @return mixed
+ */
+ public function get_recipe_id() {
+ return $this->recipe_id;
+ }
+
+ /**
+ * Get action parent ID.
+ *
+ * @return Uncanny_AutomatorApiComponentsInterfacesParent_Id|null Parent identifier or null.
+ */
+ public function get_parent_id() {
+ return $this->parent_id;
+ }
+
+ /**
+ * Get action integration code.
+ *
+ * @return string|null
+ */
+ public function get_integration_code(): ?string {
+ return $this->integration_code;
+ }
+
+ /**
+ * Get action code.
+ *
+ * @return string|null
+ */
+ public function get_code(): ?string {
+ return $this->code;
+ }
+
+ /**
+ * Get action meta code.
+ *
+ * @return string|null
+ */
+ public function get_meta_code(): ?string {
+ return $this->meta_code;
+ }
+
+ /**
+ * Get action user type.
+ *
+ * @return string|null
+ */
+ public function get_user_type(): ?string {
+ return $this->user_type;
+ }
+
+ /**
+ * Get action status.
+ *
+ * @return string|null
+ */
+ public function get_status(): ?string {
+ return $this->status;
+ }
+
+ /**
+ * Get action meta.
+ *
+ * @return array
+ */
+ public function get_meta(): array {
+ return $this->meta;
+ }
+
+ /**
+ * Get action deprecated status.
+ *
+ * @return bool
+ */
+ public function get_is_deprecated(): bool {
+ return $this->is_deprecated;
+ }
+
+ /**
+ * Create from array.
+ *
+ * @param array $data Array data.
+ * @return self
+ */
+ public static function from_array( array $data ): self {
+ $config = ( new self() )
+ ->id( $data['action_id'] ?? null )
+ ->recipe_id( $data['action_recipe_id'] ?? null )
+ ->integration_code( $data['action_integration_code'] ?? '' )
+ ->code( $data['action_code'] ?? '' )
+ ->meta_code( $data['action_meta_code'] ?? '' )
+ ->user_type( $data['action_user_type'] ?? 'user' )
+ ->status( $data['action_status'] ?? Action_Status::DRAFT )
+ ->meta( $data['action_meta'] ?? array() )
+ ->is_deprecated( $data['is_deprecated'] ?? false );
+
+ // Set parent_id - defaults to recipe_id as Recipe_Id for backward compatibility
+ if ( isset( $data['action_parent_id'] ) && $data['action_parent_id'] instanceof Uncanny_AutomatorApiComponentsInterfacesParent_Id ) {
+ $config->parent_id( $data['action_parent_id'] );
+ } elseif ( null !== $data['action_recipe_id'] ?? null ) {
+ $config->parent_id( new Recipe_Id( $data['action_recipe_id'] ) );
+ }
+
+ return $config;
+ }
+}
--- a/uncanny-automator/src/api/components/action/class-action.php
+++ b/uncanny-automator/src/api/components/action/class-action.php
@@ -0,0 +1,322 @@
+<?php
+// phpcs:disable WordPress.Security.EscapeOutput, WordPress.WP.I18n
+declare(strict_types=1);
+namespace Uncanny_AutomatorApiComponentsAction;
+
+use Uncanny_AutomatorApiComponentsActionRegistryWP_Action_Registry;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Id;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Integration_Code;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Code;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_User_Type;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Meta;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Meta_Code;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Deprecated;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Recipe_Id;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Parent_Id;
+use Uncanny_AutomatorApiComponentsActionValue_ObjectsAction_Status_Value;
+
+/**
+ * Action Aggregate.
+ *
+ * Pure domain object representing an action instance within a recipe.
+ * Contains zero WordPress dependencies - pure PHP business logic only.
+ *
+ * @since 7.0.0
+ */
+class Action {
+
+ private Action_Id $action_id;
+ private Action_Integration_Code $action_integration_code;
+ private Action_Code $action_code;
+ private Action_Meta_Code $action_meta_code;
+ private Action_User_Type $action_type;
+ private Action_Meta $action_meta;
+ private Action_Recipe_Id $action_recipe_id;
+ private Action_Deprecated $deprecated;
+ private ?Action_Parent_Id $parent_id = null;
+ private ?Action_Status_Value $status = null;
+
+ /**
+ * Constructor.
+ *
+ * @param Action_Config $config Action configuration object.
+ */
+ public function __construct( Action_Config $config ) {
+
+ // Use value objects to ensure data integrity on instance creation instead of runtime.
+ // This way, once the instance is created, we can be sure it's valid.
+ // Any invalid data will throw an exception here.
+ // This also makes the class immutable after creation.
+ // Any changes require creating a new instance with new data.
+ // This way LLMs can reason and drift all they want but at the end of the day,
+ // truth lives in our business logic, not in the LLM's head. ~ Joseph Gabito
+ $this->action_id = new Action_Id( $config->get_id() );
+ $this->action_integration_code = new Action_Integration_Code( $config->get_integration_code() );
+ $this->action_code = new Action_Code( $config->get_code() );
+ $this->action_meta_code = new Action_Meta_Code( $config->get_meta_code() );
+ $this->action_type = new Action_User_Type( $config->get_user_type() );
+ $this->action_meta = new Action_Meta( $config->get_meta() );
+
+ // Extract recipe_id from meta - actions must always belong to a recipe
+ $this->action_recipe_id = new Action_Recipe_Id( $config->get_recipe_id() );
+ $this->deprecated = new Action_Deprecated( $config->get_is_deprecated() );
+
+ // Set parent_id - wraps the Parent_Id interface (Recipe_ID or Loop_ID)
+ if ( null !== $config->get_parent_id() ) {
+ $this->parent_id = new Action_Parent_Id( $config->get_parent_id() );
+ }
+
+ // Set status - defaults to draft
+ if ( null !== $config->get_status() ) {
+ $this->status = new Action_Status_Value( $config->get_status() );
+ }
+
+ $this->validate_business_rules();
+ }
+
+ /**
+ * Get action ID.
+ *
+ * @return Action_Id Action ID.
+ */
+ public function get_action_id(): Action_Id {
+ return $this->action_id;
+ }
+
+ /**
+ * Get action integration code.
+ *
+ * @return Action_Integration_Code Action integration code.
+ */
+ public function get_action_integration_code(): Action_Integration_Code {
+ return $this->action_integration_code;
+ }
+
+ /**
+ * Get action code.
+ *
+ * @return Action_Code Action code.
+ */
+ public function get_action_code(): Action_Code {
+ return $this->action_code;
+ }
+
+ /**
+ * Get action type.
+ *
+ * @return Action_User_Type Action type.
+ */
+ public function get_action_type(): Action_User_Type {
+ return $this->action_type;
+ }
+
+ /**
+ * Get action meta.
+ *
+ * @return Action_Meta Action meta.
+ */
+ public function get_action_meta(): Action_Meta {
+ return $this->action_meta;
+ }
+
+ /**
+ * Get action recipe ID.
+ *
+ * @return Action_Recipe_Id Action recipe ID.
+ */
+ public function get_action_recipe_id(): Action_Recipe_Id {
+ return $this->action_recipe_id;
+ }
+
+ /**
+ * Get action parent ID.
+ *
+ * @return Action_Parent_Id|null Action parent ID or null.
+ */
+ public function get_parent_id(): ?Action_Parent_Id {
+ return $this->parent_id;
+ }
+
+ /**
+ * Get action status.
+ *
+ * @return Action_Status_Value|null Action status or null.
+ */
+ public function get_status(): ?Action_Status_Value {
+ return $this->status;
+ }
+
+ /**
+ * Get action meta code.
+ *
+ * @return Action_Meta_Code Action meta code.
+ */
+ public