diff --git a/docs/experiments/meta-description.md b/docs/experiments/meta-description.md
new file mode 100644
index 00000000..5b246eee
--- /dev/null
+++ b/docs/experiments/meta-description.md
@@ -0,0 +1,473 @@
+# Meta Description
+
+## Summary
+
+The Meta Description experiment adds AI-powered meta description generation to the WordPress post editor. It provides a "Meta Description" sidebar panel with a modal workflow for generating, selecting, editing, and applying meta descriptions. The experiment automatically detects active SEO plugins (Yoast SEO, Rank Math, All in One SEO, SEOPress) and writes to the correct meta field. It registers a WordPress Ability (`ai/meta-description`) that can be used both through the admin UI and directly via REST API requests.
+
+## Overview
+
+### For End Users
+
+When enabled, the Meta Description experiment adds a "Meta Description" panel to the post editor sidebar. Users can generate AI-powered meta description suggestions optimized for search engines, select or edit a suggestion, and apply it to their post.
+
+**Key Features:**
+
+- Generates multiple meta description suggestions (default: 3) from post content and title
+- Suggestions target the optimal 140–160 character range for search engine display
+- Editable textarea allows fine-tuning suggestions before applying
+- Live character count with color-coded indicator (green for 140–160, yellow outside range)
+- Automatic SEO plugin detection — writes to the correct meta field for Yoast SEO, Rank Math, All in One SEO, and SEOPress
+- Falls back to a standard post meta field (`_meta_description`) when no SEO plugin is active
+- Copy to clipboard functionality for use with unsupported SEO plugins or external tools
+- Works with any post type that has `show_in_rest` enabled
+
+**Workflow:**
+
+1. Open or create a post in the editor
+2. Find the "Meta Description" panel in the sidebar
+3. Click "Generate meta description" to open the modal
+4. Review the AI-generated suggestions and select one (or edit the textarea directly)
+5. Click "Apply" to save the description to the appropriate meta field
+6. Save/update the post as usual
+
+### For Developers
+
+The experiment consists of three main components:
+
+1. **Experiment Class** (`WordPress\AI\Experiments\Meta_Description\Meta_Description`): Handles registration, asset enqueuing, post meta registration, and SEO plugin detection
+2. **Ability Class** (`WordPress\AI\Abilities\Meta_Description\Meta_Description`): Implements the core meta description generation logic via the WordPress Abilities API
+3. **SEO Integration** (`WordPress\AI\Abilities\Meta_Description\SEO_Integration`): Utility class for detecting active SEO plugins and resolving the correct meta key
+
+The ability can be called directly via REST API, making it useful for automation, bulk processing, or custom integrations.
+
+## Architecture & Implementation
+
+### Key Hooks & Entry Points
+
+- `WordPress\AI\Experiments\Meta_Description\Meta_Description::register()` wires everything once the experiment is enabled:
+ - `wp_abilities_api_init` → registers the `ai/meta-description` ability (`includes/Abilities/Meta_Description/Meta_Description.php`)
+ - `admin_enqueue_scripts` → enqueues the React bundle and styles on `post.php` and `post-new.php` screens for REST-enabled post types
+ - `init` → registers the fallback post meta key for REST API access (only when no SEO plugin is active)
+
+### Assets & Data Flow
+
+1. **PHP Side:**
+ - `enqueue_assets()` loads `experiments/meta-description` (`src/experiments/meta-description/index.tsx`) and localizes `window.aiMetaDescriptionData` with:
+ - `enabled`: Whether the experiment is enabled
+ - `metaKey`: The resolved meta key for the active SEO plugin (or fallback)
+ - `seoPlugin`: The slug of the detected SEO plugin, or `null`
+
+2. **React Side:**
+ - The React entry point (`index.tsx`) registers a `PluginDocumentSettingPanel` in the editor sidebar
+ - `MetaDescriptionPanel` component shows the current description with edit/regenerate actions, or a generate button if none exists
+ - Clicking generate or edit opens `MetaDescriptionModal` which displays suggestion cards and an editable textarea
+ - `useMetaDescription` hook:
+ - Gets current post ID, content, title, and existing meta description from the editor store
+ - Calls the ability via `runAbility()` when generation is triggered
+ - Updates the post meta via `editPost()` when a description is applied
+ - Copy to clipboard uses WordPress's `useCopyToClipboard` from `@wordpress/compose`
+
+3. **Ability Execution:**
+ - Accepts `content` (string), `title` (string), and `post_id` (integer) as input
+ - If `post_id` is provided, fetches post content and context using `get_post_context()`
+ - Normalizes content using `normalize_content()` helper
+ - Sends content to AI client with system instruction targeting 140–160 character descriptions
+ - Returns an object with an array of description suggestions, each including the text and character count
+
+### Input Schema
+
+The ability accepts the following input parameters:
+
+```php
+array(
+ 'content' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'description' => 'Post content to generate a meta description for.',
+ ),
+ 'title' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'description' => 'The post title, used to avoid duplication in the generated description.',
+ ),
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ 'description' => 'The post ID to generate a meta description for. If provided without content, the post content will be used.',
+ ),
+)
+```
+
+### Output Schema
+
+The ability returns an object containing an array of description suggestions:
+
+```php
+array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'descriptions' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'text' => array( 'type' => 'string' ),
+ 'character_count' => array( 'type' => 'integer' ),
+ ),
+ ),
+ ),
+ ),
+)
+```
+
+### Permissions
+
+The ability checks permissions based on the input:
+
+- **If `post_id` is provided:**
+ - Verifies the post exists
+ - Checks `current_user_can( 'edit_post', $post_id )`
+ - Ensures the post type has `show_in_rest` enabled
+
+- **If `post_id` is not provided:**
+ - Checks `current_user_can( 'edit_posts' )`
+
+### SEO Plugin Detection
+
+The `SEO_Integration` utility class detects active SEO plugins and resolves the correct meta key:
+
+| Plugin | Slug | Meta Key |
+|--------|------|----------|
+| Yoast SEO | `yoast-seo` | `_yoast_wpseo_metadesc` |
+| Rank Math | `rank-math` | `rank_math_description` |
+| All in One SEO | `all-in-one-seo` | `_aioseo_description` |
+| SEOPress | `seopress` | `_seopress_titles_desc` |
+| None (fallback) | — | `_meta_description` |
+
+When no SEO plugin is active, the experiment registers the fallback `_meta_description` meta key for REST-enabled post types so it can be read and written through the WordPress data layer.
+
+## Using the Ability via REST API
+
+The meta description ability can be called directly via REST API, making it useful for automation, bulk processing, or custom integrations.
+
+### Endpoint
+
+```text
+POST /wp-json/wp-abilities/v1/abilities/ai/meta-description/run
+```
+
+### Authentication
+
+You can authenticate using either:
+
+1. **Application Password** (Recommended)
+2. **Cookie Authentication with Nonce**
+
+See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions.
+
+### Request Examples
+
+#### Example 1: Generate from Content and Title
+
+```bash
+curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/meta-description/run" \
+ -u "username:application-password" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": {
+ "content": "This is a comprehensive article about artificial intelligence and machine learning. AI has revolutionized many industries including healthcare, finance, and transportation.",
+ "title": "How AI is Transforming Industries"
+ }
+ }'
+```
+
+**Response:**
+
+```json
+{
+ "descriptions": [
+ {
+ "text": "Discover how artificial intelligence and machine learning are revolutionizing healthcare, finance, and transportation with data-driven insights and automation.",
+ "character_count": 156
+ },
+ {
+ "text": "Learn how AI transforms industries from healthcare to transportation through advanced machine learning algorithms and predictive analytics capabilities.",
+ "character_count": 153
+ },
+ {
+ "text": "Explore the impact of AI and machine learning across healthcare, finance, and transportation sectors, reshaping how industries process data and make decisions.",
+ "character_count": 160
+ }
+ ]
+}
+```
+
+#### Example 2: Generate from Post ID
+
+```bash
+curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/meta-description/run" \
+ -u "username:application-password" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "input": {
+ "post_id": 123
+ }
+ }'
+```
+
+This will automatically fetch the content and title from post ID 123 and generate meta description suggestions.
+
+#### Example 3: Using WordPress API Fetch (in Gutenberg/Admin)
+
+```javascript
+import apiFetch from '@wordpress/api-fetch';
+
+async function generateMetaDescriptions(content, title, postId = null) {
+ const input = { content, title };
+ if (postId) {
+ input.post_id = postId;
+ }
+
+ try {
+ const result = await apiFetch({
+ path: '/wp-abilities/v1/abilities/ai/meta-description/run',
+ method: 'POST',
+ data: { input },
+ });
+ return result.descriptions; // Array of { text, character_count }
+ } catch (error) {
+ console.error('Error generating meta descriptions:', error);
+ throw error;
+ }
+}
+```
+
+### Error Responses
+
+The ability may return the following error codes:
+
+- `post_not_found`: The provided post ID does not exist
+- `content_not_provided`: No content was provided and no valid post ID was found
+- `no_results`: The AI client did not return any results
+- `insufficient_capabilities`: The current user does not have permission to generate meta descriptions
+
+Example error response:
+
+```json
+{
+ "code": "content_not_provided",
+ "message": "Content is required to generate a meta description.",
+ "data": {
+ "status": 400
+ }
+}
+```
+
+## Extending the Experiment
+
+### Customizing the System Instruction
+
+The system instruction that guides the AI can be customized by modifying:
+
+```php
+includes/Abilities/Meta_Description/system-instruction.php
+```
+
+This file returns a string that instructs the AI on how to generate meta descriptions. You can modify the character length requirements, tone, style, or other parameters.
+
+### Filtering the Prompt Content
+
+You can filter the assembled prompt before it is sent to the AI model:
+
+```php
+add_filter( 'ai_meta_description_prompt', function( $prompt, $content, $title ) {
+ // Append custom instructions to the prompt
+ $prompt .= "\n\nFocus on the environmental impact angle.";
+ return $prompt;
+}, 10, 3 );
+```
+
+### Filtering the Number of Suggestions
+
+You can change how many description candidates are generated:
+
+```php
+add_filter( 'ai_meta_description_candidate_count', function( $count ) {
+ return 5; // Generate 5 suggestions instead of the default 3
+} );
+```
+
+### Filtering the Result Temperature
+
+You can adjust the AI temperature for more creative or more consistent results:
+
+```php
+add_filter( 'ai_meta_description_result_temperature', function( $temperature ) {
+ return 0.3; // Lower temperature for more consistent output
+} );
+```
+
+### Registering Additional SEO Plugins
+
+You can add support for additional SEO plugins:
+
+```php
+add_filter( 'ai_meta_description_seo_plugins', function( $plugins ) {
+ $plugins['my-seo-plugin'] = array(
+ 'file' => 'my-seo-plugin/my-seo-plugin.php',
+ 'meta_key' => '_my_seo_meta_description',
+ );
+ return $plugins;
+} );
+```
+
+### Overriding the Meta Key
+
+You can override the resolved meta key regardless of which SEO plugin is detected:
+
+```php
+add_filter( 'ai_meta_description_meta_key', function( $key, $plugin_slug ) {
+ return '_custom_meta_description_key';
+}, 10, 2 );
+```
+
+### Filtering Preferred Models
+
+You can filter which AI models are used for meta description generation using the `ai_experiments_preferred_models_for_text_generation` filter:
+
+```php
+add_filter( 'ai_experiments_preferred_models_for_text_generation', function( $models ) {
+ return array(
+ array( 'openai', 'gpt-4' ),
+ array( 'anthropic', 'claude-haiku-4-5' ),
+ );
+} );
+```
+
+### Customizing Content Normalization
+
+The `normalize_content()` helper function processes content before sending it to the AI. You can filter the normalized content:
+
+```php
+// Filter content before normalization
+add_filter( 'ai_experiments_pre_normalize_content', function( $content ) {
+ // Custom preprocessing
+ return $content;
+} );
+
+// Filter content after normalization
+add_filter( 'ai_experiments_normalize_content', function( $content ) {
+ // Custom post-processing
+ return $content;
+} );
+```
+
+## Testing
+
+### Manual Testing
+
+1. **Enable the experiment:**
+ - Go to `Settings → AI Experiments`
+ - Toggle **Meta Description** to enabled
+ - Ensure you have valid AI credentials configured
+
+2. **Test in the editor:**
+ - Create or edit a post with content
+ - Find the "Meta Description" panel in the editor sidebar
+ - Click "Generate meta description" to open the modal
+ - Verify that 3 suggestions are generated with character counts
+ - Select a suggestion and verify it populates the textarea
+ - Edit the text and verify the character count updates live
+ - Click "Apply" and verify the description appears in the sidebar panel
+ - Click "Edit description" and verify the modal opens with the current text
+ - Click the regenerate icon and verify new suggestions are generated
+ - Test "Copy to clipboard" and verify the text is copied
+
+3. **Test SEO plugin integration:**
+ - With Yoast SEO active, verify the description is saved to `_yoast_wpseo_metadesc`
+ - Without any SEO plugin, verify the description is saved to `_meta_description`
+ - Verify the correct meta key is displayed in the localized data
+
+4. **Test with different post types:**
+ - The experiment loads for any REST-enabled post type except attachments
+ - Test with posts, pages, and custom post types
+
+5. **Test REST API:**
+ - Use curl or Postman to test the REST endpoint
+ - Verify authentication works
+ - Test with different input combinations
+ - Verify error handling for invalid inputs
+
+### Automated Testing
+
+Tests are located in:
+
+- `tests/Integration/Includes/Abilities/Meta_DescriptionTest.php`
+- `tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php`
+- `tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php`
+
+Run tests with:
+
+```bash
+npm run test:php
+```
+
+## Notes & Considerations
+
+### Requirements
+
+- The experiment requires valid AI credentials to be configured
+- The experiment loads for any post type with `show_in_rest` enabled, except attachments
+- Users must have `edit_posts` capability (or `edit_post` for specific posts when using post ID)
+
+### Performance
+
+- Meta description generation is an AI operation and may take several seconds
+- The UI shows a loading state (busy button) while generation is in progress
+- Multiple candidates are generated in a single API call using `candidate_count`
+
+### Content Processing
+
+- Content is normalized before being sent to the AI (HTML stripped, shortcodes removed, etc.)
+- The `normalize_content()` function handles this processing
+- Additional context from post metadata (title, categories, tags) can be included when using post ID
+- The post title is passed separately to prevent duplication in the generated description
+
+### AI Model Selection
+
+- The ability uses `get_preferred_models_for_text_generation()` to determine which AI models to use
+- Models are tried in order until one succeeds
+- Temperature defaults to 0.7 for balanced creativity and consistency (filterable via `ai_meta_description_result_temperature`)
+
+### System Instruction
+
+The system instruction guides the AI to:
+
+- Generate descriptions between 140 and 160 characters
+- Use plain text only (no markdown, HTML, or special formatting)
+- Avoid duplicating or closely mirroring the post title
+- Avoid keyword stuffing or repetitive terms
+- Use active, action-oriented language that encourages click-through
+- Accurately reflect the actual content
+
+### Limitations
+
+- Descriptions are generated in real-time and not cached
+- The ability does not support batch processing (one request generates multiple candidates for a single post)
+- Generated descriptions are suggestions and should be reviewed before publishing
+- SEO plugin integration is read/write only for supported plugins — unsupported plugins require the `ai_meta_description_seo_plugins` filter or copy-to-clipboard
+- The experiment requires JavaScript to be enabled in the admin
+
+## Related Files
+
+- **Experiment:** `includes/Experiments/Meta_Description/Meta_Description.php`
+- **Ability:** `includes/Abilities/Meta_Description/Meta_Description.php`
+- **SEO Integration:** `includes/Abilities/Meta_Description/SEO_Integration.php`
+- **System Instruction:** `includes/Abilities/Meta_Description/system-instruction.php`
+- **React Entry:** `src/experiments/meta-description/index.tsx`
+- **React Components:** `src/experiments/meta-description/components/`
+- **Styles:** `src/experiments/meta-description/index.scss`
+- **Types:** `src/experiments/meta-description/types.ts`
+- **Tests:** `tests/Integration/Includes/Abilities/Meta_DescriptionTest.php`
+- **Tests:** `tests/Integration/Includes/Abilities/Meta_Description/SEO_IntegrationTest.php`
+- **Tests:** `tests/Integration/Includes/Experiments/Meta_Description/Meta_DescriptionTest.php`
diff --git a/includes/Abilities/Meta_Description/Meta_Description.php b/includes/Abilities/Meta_Description/Meta_Description.php
new file mode 100644
index 00000000..76c759f4
--- /dev/null
+++ b/includes/Abilities/Meta_Description/Meta_Description.php
@@ -0,0 +1,319 @@
+ 'object',
+ 'properties' => array(
+ 'content' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'description' => esc_html__( 'Post content to generate a meta description for.', 'ai' ),
+ ),
+ 'title' => array(
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ 'description' => esc_html__( 'The post title, used to avoid duplication in the generated description.', 'ai' ),
+ ),
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ 'description' => esc_html__( 'The post ID to generate a meta description for. If provided without content, the post content will be used.', 'ai' ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @since x.x.x
+ */
+ protected function output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'description' => esc_html__( 'Generated meta description suggestions.', 'ai' ),
+ 'properties' => array(
+ 'descriptions' => array(
+ 'type' => 'array',
+ 'description' => esc_html__( 'Array of meta description suggestions.', 'ai' ),
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'text' => array(
+ 'type' => 'string',
+ 'description' => esc_html__( 'The meta description text.', 'ai' ),
+ ),
+ 'character_count' => array(
+ 'type' => 'integer',
+ 'description' => esc_html__( 'The character count of the description.', 'ai' ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @since x.x.x
+ */
+ protected function execute_callback( $input ) {
+ $args = wp_parse_args(
+ $input,
+ array(
+ 'content' => null,
+ 'title' => null,
+ 'post_id' => null,
+ ),
+ );
+
+ $content = '';
+ $title = $args['title'] ?? '';
+ $context = '';
+
+ // If a post ID is provided, fetch content and context from the post.
+ if ( $args['post_id'] ) {
+ $post = get_post( (int) $args['post_id'] );
+
+ if ( ! $post instanceof WP_Post ) {
+ return new WP_Error(
+ 'post_not_found',
+ /* translators: %d: Post ID. */
+ sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) )
+ );
+ }
+
+ $post_context = get_post_context( $post->ID );
+ $content = $post_context['content'] ?? '';
+ unset( $post_context['content'] );
+ $context = $post_context;
+
+ // Use the post title if none was provided.
+ if ( empty( $title ) && ! empty( $post->post_title ) ) {
+ $title = $post->post_title;
+ }
+ }
+
+ // Prefer explicitly provided content over post content.
+ if ( $args['content'] ) {
+ $content = normalize_content( $args['content'] );
+ }
+
+ if ( empty( $content ) ) {
+ return new WP_Error(
+ 'content_not_provided',
+ esc_html__( 'Content is required to generate a meta description.', 'ai' )
+ );
+ }
+
+ $descriptions = $this->generate_descriptions( $content, $title, $context );
+ if ( is_wp_error( $descriptions ) ) {
+ return $descriptions;
+ }
+
+ if ( empty( $descriptions ) ) {
+ return new WP_Error(
+ 'no_results',
+ esc_html__( 'No meta description suggestions were generated.', 'ai' )
+ );
+ }
+
+ return array( 'descriptions' => $descriptions );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @since x.x.x
+ */
+ protected function permission_callback( $args ) {
+ $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : 0;
+
+ if ( $post_id ) {
+ $post = get_post( $post_id );
+
+ if ( ! $post instanceof WP_Post ) {
+ return new WP_Error(
+ 'post_not_found',
+ /* translators: %d: Post ID. */
+ sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id )
+ );
+ }
+
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ return new WP_Error(
+ 'insufficient_capabilities',
+ esc_html__( 'You do not have permission to generate meta descriptions for this post.', 'ai' )
+ );
+ }
+
+ $post_type_obj = get_post_type_object( $post->post_type );
+ if ( ! $post_type_obj instanceof WP_Post_Type || empty( $post_type_obj->show_in_rest ) ) {
+ return false;
+ }
+ } elseif ( ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error(
+ 'insufficient_capabilities',
+ esc_html__( 'You do not have permission to generate meta descriptions.', 'ai' )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @since x.x.x
+ */
+ protected function meta(): array {
+ return array(
+ 'show_in_rest' => true,
+ );
+ }
+
+ /**
+ * Generate meta description suggestions from the given content.
+ *
+ * @since x.x.x
+ *
+ * @param string $content The content to generate descriptions from.
+ * @param string $title The post title.
+ * @param string|array $context Additional context to use.
+ * @return array|\WP_Error The generated descriptions, or a WP_Error.
+ */
+ protected function generate_descriptions( string $content, string $title, $context ) {
+ // Convert the context to a string if it's an array.
+ if ( is_array( $context ) ) {
+ $context = implode(
+ "\n",
+ array_map(
+ static function ( $key, $value ) {
+ return sprintf(
+ '%s: %s',
+ ucwords( str_replace( '_', ' ', $key ) ),
+ $value
+ );
+ },
+ array_keys( $context ),
+ $context
+ )
+ );
+ }
+
+ $prompt = '' . $content . '';
+
+ if ( ! empty( $title ) ) {
+ $prompt .= "\n\n" . $title . '';
+ }
+
+ if ( ! empty( $context ) ) {
+ $prompt .= "\n\n" . $context . '';
+ }
+
+ /**
+ * Filters the prompt content sent to the AI model for meta description generation.
+ *
+ * Allows developers to modify or augment the content before it is sent to the model.
+ *
+ * @since x.x.x
+ *
+ * @param string $prompt The assembled prompt including content, title, and context tags.
+ * @param string $content The normalized post content.
+ * @param string $title The post title.
+ */
+ $prompt = (string) apply_filters( 'wpai_meta_description_prompt', $prompt, $content, $title );
+
+ /**
+ * Filters the number of meta description candidates to generate.
+ *
+ * @since x.x.x
+ *
+ * @param int $candidate_count The number of candidates to request from the AI model.
+ */
+ $candidate_count = (int) apply_filters( 'wpai_meta_description_candidate_count', self::DEFAULT_CANDIDATE_COUNT );
+
+ /**
+ * Filters the temperature for the result of the meta description generation.
+ *
+ * @since x.x.x
+ *
+ * @param float $result_temperature The temperature for the result of the meta description generation.
+ */
+ $result_temperature = (float) apply_filters( 'wpai_meta_description_result_temperature', 0.7 );
+
+ $results = wp_ai_client_prompt( $prompt )
+ ->using_system_instruction( $this->get_system_instruction() )
+ ->using_temperature( $result_temperature )
+ ->using_candidate_count( $candidate_count )
+ ->using_model_preference( ...get_preferred_models_for_text_generation() )
+ ->generate_texts();
+
+ if ( is_wp_error( $results ) ) {
+ return $results;
+ }
+
+ if ( ! is_array( $results ) ) {
+ return new WP_Error(
+ 'no_results',
+ esc_html__( 'No meta description suggestions were generated.', 'ai' )
+ );
+ }
+
+ $descriptions = array();
+
+ foreach ( $results as $result ) {
+ if ( ! is_string( $result ) || empty( trim( $result ) ) ) {
+ continue;
+ }
+
+ $text = sanitize_text_field( trim( $result, ' "\'' ) );
+
+ $descriptions[] = array(
+ 'text' => $text,
+ 'character_count' => mb_strlen( $text ),
+ );
+ }
+
+ return $descriptions;
+ }
+}
diff --git a/includes/Abilities/Meta_Description/SEO_Integration.php b/includes/Abilities/Meta_Description/SEO_Integration.php
new file mode 100644
index 00000000..1865770f
--- /dev/null
+++ b/includes/Abilities/Meta_Description/SEO_Integration.php
@@ -0,0 +1,121 @@
+ Map of plugin slug to detection info.
+ */
+ public static function get_supported_plugins(): array {
+ $plugins = array(
+ 'yoast-seo' => array(
+ 'file' => 'wordpress-seo/wp-seo.php',
+ 'meta_key' => '_yoast_wpseo_metadesc',
+ ),
+ 'rank-math' => array(
+ 'file' => 'seo-by-rank-math/rank-math.php',
+ 'meta_key' => 'rank_math_description',
+ ),
+ 'all-in-one-seo' => array(
+ 'file' => 'all-in-one-seo-pack/all_in_one_seo_pack.php',
+ 'meta_key' => '_aioseo_description',
+ ),
+ 'seopress' => array(
+ 'file' => 'wp-seopress/seopress.php',
+ 'meta_key' => '_seopress_titles_desc',
+ ),
+ );
+
+ /**
+ * Filters the list of supported SEO plugins for meta description integration.
+ *
+ * Allows developers to register additional SEO plugins or modify existing entries.
+ *
+ * @since x.x.x
+ *
+ * @param array $plugins Map of plugin slug to detection info.
+ */
+ return (array) apply_filters( 'wpai_meta_description_seo_plugins', $plugins );
+ }
+
+ /**
+ * Detects the currently active SEO plugin.
+ *
+ * @since x.x.x
+ *
+ * @return string|null The slug of the active SEO plugin, or null if none detected.
+ */
+ public static function detect_active_plugin(): ?string {
+ if ( ! function_exists( 'is_plugin_active' ) ) {
+ require_once ABSPATH . 'wp-admin/includes/plugin.php';
+ }
+
+ foreach ( self::get_supported_plugins() as $slug => $info ) {
+ if ( is_plugin_active( $info['file'] ) ) {
+ return $slug;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the meta key to use for storing the meta description.
+ *
+ * @since x.x.x
+ *
+ * @param string|null $plugin_slug Optional. The SEO plugin slug. If null, auto-detects.
+ * @return string The meta key.
+ */
+ public static function get_meta_key( ?string $plugin_slug = null ): string {
+ if ( null === $plugin_slug ) {
+ $plugin_slug = self::detect_active_plugin();
+ }
+
+ $plugins = self::get_supported_plugins();
+ $key = self::FALLBACK_META_KEY;
+
+ if ( $plugin_slug && isset( $plugins[ $plugin_slug ] ) ) {
+ $key = $plugins[ $plugin_slug ]['meta_key'];
+ }
+
+ /**
+ * Filters the meta key used to store the meta description.
+ *
+ * @since x.x.x
+ *
+ * @param string $key The meta key.
+ * @param string|null $plugin_slug The detected SEO plugin slug, or null.
+ */
+ return (string) apply_filters( 'wpai_meta_description_meta_key', $key, $plugin_slug );
+ }
+}
diff --git a/includes/Abilities/Meta_Description/system-instruction.php b/includes/Abilities/Meta_Description/system-instruction.php
new file mode 100644
index 00000000..165624b9
--- /dev/null
+++ b/includes/Abilities/Meta_Description/system-instruction.php
@@ -0,0 +1,31 @@
+ __( 'Meta Description Generation', 'ai' ),
+ 'description' => __( 'Generates meta description suggestions with SEO plugin integration', 'ai' ),
+ 'category' => Experiment_Category::EDITOR,
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @since x.x.x
+ */
+ public function register(): void {
+ add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
+ $this->register_post_meta();
+ }
+
+ /**
+ * Registers the meta description ability.
+ *
+ * @since x.x.x
+ */
+ public function register_abilities(): void {
+ wp_register_ability(
+ 'ai/' . $this->get_id(),
+ array(
+ 'label' => $this->get_label(),
+ 'description' => $this->get_description(),
+ 'ability_class' => Meta_Description_Ability::class,
+ ),
+ );
+ }
+
+ /**
+ * Registers the fallback post meta key for REST API access.
+ *
+ * This ensures the meta key is accessible through the WordPress data layer
+ * when no SEO plugin is active to manage it.
+ *
+ * @since x.x.x
+ */
+ public function register_post_meta(): void {
+ $meta_key = SEO_Integration::get_meta_key();
+ $seo_plugin = SEO_Integration::detect_active_plugin();
+
+ // Only register the fallback meta key. SEO plugins register their own.
+ if ( null !== $seo_plugin ) {
+ return;
+ }
+
+ $post_types = get_post_types( array( 'show_in_rest' => true ), 'names' );
+
+ foreach ( $post_types as $post_type ) {
+ if ( 'attachment' === $post_type ) {
+ continue;
+ }
+
+ register_post_meta(
+ $post_type,
+ $meta_key,
+ array(
+ 'show_in_rest' => true,
+ 'single' => true,
+ 'type' => 'string',
+ 'revisions_enabled' => true,
+ 'auth_callback' => static function () {
+ return current_user_can( 'edit_posts' );
+ },
+ )
+ );
+ }
+ }
+
+ /**
+ * Enqueues and localizes the admin script.
+ *
+ * @since x.x.x
+ *
+ * @param string $hook_suffix The current admin page hook suffix.
+ */
+ public function enqueue_assets( string $hook_suffix ): void {
+ if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) {
+ return;
+ }
+
+ $screen = get_current_screen();
+
+ if (
+ ! $screen ||
+ ! in_array( $screen->post_type, get_post_types( array( 'show_in_rest' => true ), 'names' ), true ) ||
+ 'attachment' === $screen->post_type
+ ) {
+ return;
+ }
+
+ $seo_plugin = SEO_Integration::detect_active_plugin();
+
+ Asset_Loader::enqueue_script( 'meta_description', 'experiments/meta-description' );
+ Asset_Loader::enqueue_style( 'meta_description', 'experiments/meta-description' );
+ Asset_Loader::localize_script(
+ 'meta_description',
+ 'MetaDescriptionData',
+ array(
+ 'enabled' => $this->is_enabled(),
+ 'metaKey' => SEO_Integration::get_meta_key( $seo_plugin ),
+ 'seoPlugin' => $seo_plugin,
+ )
+ );
+ }
+}
diff --git a/src/experiments/meta-description/components/CharacterCount.tsx b/src/experiments/meta-description/components/CharacterCount.tsx
new file mode 100644
index 00000000..056114c7
--- /dev/null
+++ b/src/experiments/meta-description/components/CharacterCount.tsx
@@ -0,0 +1,51 @@
+/**
+ * Character count indicator component for meta descriptions.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { sprintf, __ } from '@wordpress/i18n';
+
+const MIN_LENGTH = 140;
+const MAX_LENGTH = 160;
+
+interface CharacterCountProps {
+ count: number;
+}
+
+/**
+ * Renders a color-coded character count indicator.
+ *
+ * Green when within 140–160 range, yellow outside.
+ *
+ * @param props Component props.
+ * @param props.count The current character count.
+ */
+export default function CharacterCount( {
+ count,
+}: CharacterCountProps ): JSX.Element {
+ const isInRange = count >= MIN_LENGTH && count <= MAX_LENGTH;
+
+ let rangeClass = 'ai-meta-description__char-count';
+ rangeClass += isInRange
+ ? ` ${ rangeClass }--in-range`
+ : ` ${ rangeClass }--out-of-range`;
+
+ return (
+
+ { sprintf(
+ /* translators: %d: character count */
+ __( '%d characters', 'ai' ),
+ count
+ ) }
+
+ );
+}
diff --git a/src/experiments/meta-description/components/MetaDescriptionModal.tsx b/src/experiments/meta-description/components/MetaDescriptionModal.tsx
new file mode 100644
index 00000000..1fae1f42
--- /dev/null
+++ b/src/experiments/meta-description/components/MetaDescriptionModal.tsx
@@ -0,0 +1,175 @@
+/**
+ * Modal component for generating and editing meta description suggestions.
+ */
+
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Modal, Button, TextareaControl } from '@wordpress/components';
+import { useCopyToClipboard } from '@wordpress/compose';
+import { dispatch } from '@wordpress/data';
+import { store as noticesStore } from '@wordpress/notices';
+
+/**
+ * Internal dependencies
+ */
+import SuggestionCard from './SuggestionCard';
+import CharacterCount from './CharacterCount';
+import type { MetaDescriptionSuggestion } from '../types';
+
+interface MetaDescriptionModalProps {
+ isGenerating: boolean;
+ suggestions: MetaDescriptionSuggestion[];
+ initialDescription: string;
+ onGenerate: () => Promise< void >;
+ onApply: ( text: string ) => void;
+ onClose: () => void;
+}
+
+/**
+ * Modal for generating, selecting, and editing meta descriptions.
+ *
+ * @param props Component props.
+ * @param props.isGenerating Whether generation is in progress.
+ * @param props.suggestions Array of generated suggestions.
+ * @param props.initialDescription Pre-existing description to edit.
+ * @param props.onGenerate Callback to trigger generation.
+ * @param props.onApply Callback to apply the description.
+ * @param props.onClose Callback to close the modal.
+ */
+export default function MetaDescriptionModal( {
+ isGenerating,
+ suggestions,
+ initialDescription,
+ onGenerate,
+ onApply,
+ onClose,
+}: MetaDescriptionModalProps ): JSX.Element {
+ const [ selectedIndex, setSelectedIndex ] = useState< number | null >(
+ null
+ );
+ const [ editableText, setEditableText ] = useState( initialDescription );
+
+ const { createSuccessNotice } = dispatch( noticesStore );
+
+ const copyButtonRef = useCopyToClipboard< HTMLButtonElement >(
+ () => editableText,
+ () => {
+ createSuccessNotice(
+ __( 'Meta description copied to clipboard.', 'ai' ),
+ {
+ type: 'snackbar',
+ isDismissible: true,
+ }
+ );
+ }
+ );
+
+ const handleSelectSuggestion = ( index: number, text: string ) => {
+ setSelectedIndex( index );
+ setEditableText( text );
+ };
+
+ const handleApply = () => {
+ onApply( editableText );
+ onClose();
+ };
+
+ const hasSuggestions = suggestions.length > 0;
+
+ let generateButtonLabel: string = hasSuggestions
+ ? __( 'Regenerate Suggestions', 'ai' )
+ : __( 'Generate Suggestions', 'ai' );
+
+ if ( isGenerating ) {
+ generateButtonLabel = __( 'Generating', 'ai' );
+ }
+
+ return (
+
+