Repository: Azure/azqr

Issue:
Carbon Aware Recommendations
## Describe the feature
Is it possible to get the carbon aware recommendations at all?

https://learn.microsoft.com/en-us/azure/carbon-optimization/api-export-data


Hints from issue comments:
@cmendible: Have to check if the `Carbon Optimization Reader` is included in the `Reader` role. But otherwise should not be an issue. 

What report type (https://learn.microsoft.com/en-us/azure/carbon-optimization/api-export-data#report-types) would you like to see?  @PixelRobots: I think the ItemDetailsReport will give the best information.@PixelRobots: Looking at the reader role it looks like it has the `Carbon Optimization Reader` role.@PixelRobots: hey @cmendible Just wondering if you have had any time to look at this issue yet?@cmendible: > hey [@cmendible](https://github.com/cmendible) Just wondering if you have had any time to look at this issue yet?

Sorry my friend I've been fixing some bugs and also swamped in escalations. We'll take a look soon.

Solution patch:
diff --git a/cmd/azqr/commands/plugins.go b/cmd/azqr/commands/plugins.go
new file mode 100644
index 0000000..57e3d03
--- /dev/null
+++ b/cmd/azqr/commands/plugins.go
@@ -0,0 +1,126 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package commands
+
+import (
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	"github.com/Azure/azqr/internal/plugins"
+	"github.com/spf13/cobra"
+)
+
+func init() {
+	pluginsCmd.AddCommand(pluginsListCmd)
+	pluginsCmd.AddCommand(pluginsInfoCmd)
+	rootCmd.AddCommand(pluginsCmd)
+}
+
+var pluginsCmd = &cobra.Command{
+	Use:   "plugins",
+	Short: "Manage azqr plugins",
+	Long:  "List, inspect, and manage azqr plugins for extending functionality",
+	Args:  cobra.NoArgs,
+}
+
+var pluginsListCmd = &cobra.Command{
+	Use:   "list",
+	Short: "List all registered plugins",
+	Long:  "List all registered plugins including built-in and external command plugins",
+	Args:  cobra.NoArgs,
+	Run: func(cmd *cobra.Command, args []string) {
+		registry := plugins.GetRegistry()
+		pluginList := registry.List()
+
+		if len(pluginList) == 0 {
+			fmt.Println("No plugins registered")
+			return
+		}
+
+		w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
+		_, _ = fmt.Fprintln(w, "NAME\tVERSION\tTYPE\tDESCRIPTION")
+		_, _ = fmt.Fprintln(w, "----\t-------\t----\t-----------")
+
+		for _, p := range pluginList {
+			pluginType := "yaml"
+			switch p.Metadata.Type {
+			case plugins.PluginTypeInternal:
+				pluginType = "internal"
+			case plugins.PluginTypeYaml:
+				pluginType = "yaml"
+			}
+			_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
+				p.Metadata.Name,
+				p.Metadata.Version,
+				pluginType,
+				p.Metadata.Description,
+			)
+		}
+
+		_ = w.Flush()
+	},
+}
+
+var pluginsInfoCmd = &cobra.Command{
+	Use:   "info <plugin-name>",
+	Short: "Show detailed information about a plugin",
+	Long:  "Show detailed information about a specific plugin including metadata and capabilities",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		pluginName := args[0]
+		registry := plugins.GetRegistry()
+
+		plugin, exists := registry.Get(pluginName)
+		if !exists {
+			fmt.Printf("Plugin '%s' not found\n", pluginName)
+			os.Exit(1)
+		}
+
+		fmt.Printf("Plugin: %s\n", plugin.Metadata.Name)
+		fmt.Printf("Version: %s\n", plugin.Metadata.Version)
+		fmt.Printf("Description: %s\n", plugin.Metadata.Description)
+
+		pluginType := "yaml"
+		switch plugin.Metadata.Type {
+		case plugins.PluginTypeInternal:
+			pluginType = "internal"
+		case plugins.PluginTypeYaml:
+			pluginType = "yaml"
+		}
+		fmt.Printf("Type: %s\n", pluginType)
+
+		if plugin.Metadata.Author != "" {
+			fmt.Printf("Author: %s\n", plugin.Metadata.Author)
+		}
+		if plugin.Metadata.License != "" {
+			fmt.Printf("License: %s\n", plugin.Metadata.License)
+		}
+		if plugin.Metadata.CommandPath != "" {
+			fmt.Printf("Command Path: %s\n", plugin.Metadata.CommandPath)
+		}
+
+		// Show YAML plugin recommendations
+		if len(plugin.YamlRecommendations) > 0 {
+			fmt.Printf("\nYAML Plugin Information:\n")
+			fmt.Printf("  Recommendations: %d\n", len(plugin.YamlRecommendations))
+			// Collect unique resource types
+			resourceTypeSet := make(map[string]bool)
+			for _, rec := range plugin.YamlRecommendations {
+				resourceTypeSet[rec.ResourceType] = true
+			}
+			resourceTypes := make([]string, 0, len(resourceTypeSet))
+			for rt := range resourceTypeSet {
+				resourceTypes = append(resourceTypes, rt)
+			}
+			fmt.Printf("  Resource Types: %v\n", resourceTypes)
+		}
+
+		// Show internal plugin scanner info
+		if plugin.InternalScanner != nil {
+			fmt.Printf("\nInternal Plugin Information:\n")
+			fmt.Printf("  Scanner available\n")
+		}
+	},
+}
diff --git a/cmd/azqr/commands/root.go b/cmd/azqr/commands/root.go
index ee92c91..9627649 100644
--- a/cmd/azqr/commands/root.go
+++ b/cmd/azqr/commands/root.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/Azure/azqr/internal/plugins"
 	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 )
@@ -33,5 +34,10 @@ func Execute() {
 
 	log.Logger = zerolog.New(output).With().Timestamp().Logger()
 
+	// Load all YAML plugins after logger is configured
+	if err := plugins.LoadAll(); err != nil {
+		log.Warn().Err(err).Msg("Failed to load some plugins")
+	}
+
 	cobra.CheckErr(rootCmd.Execute())
 }
diff --git a/cmd/azqr/commands/scan.go b/cmd/azqr/commands/scan.go
index 210dcad..29c78e9 100644
--- a/cmd/azqr/commands/scan.go
+++ b/cmd/azqr/commands/scan.go
@@ -8,6 +8,7 @@ package commands
 import (
 	"github.com/Azure/azqr/internal"
 	"github.com/Azure/azqr/internal/models"
+	"github.com/Azure/azqr/internal/plugins"
 
 	"github.com/spf13/cobra"
 )
@@ -19,6 +20,13 @@ func init() {
 	scanCmd.PersistentFlags().BoolP("defender", "d", true, "Scan Defender Status (default) (default true)")
 	scanCmd.PersistentFlags().BoolP("advisor", "a", true, "Scan Azure Advisor Recommendations (default) (default true)")
 	scanCmd.PersistentFlags().BoolP("costs", "c", true, "Scan Azure Costs (default) (default true)")
+	// ...existing code...
+	// Add flags for all internal plugins, default to false
+	for _, pluginName := range requireInternalPluginsList() {
+		desc := "Enable internal plugin: " + pluginName
+		scanCmd.PersistentFlags().Bool(pluginName, false, desc)
+	}
+
 	scanCmd.PersistentFlags().BoolP("arc", "", true, "Scan Azure Arc-enabled resources (default) (default true)")
 	scanCmd.PersistentFlags().BoolP("xlsx", "", true, "Create Excel report (default) (default true)")
 	scanCmd.PersistentFlags().BoolP("json", "", false, "Create JSON report files")
@@ -65,6 +73,13 @@ func scan(cmd *cobra.Command, scannerKeys []string) {
 	// load filters
 	filters := models.LoadFilters(filtersFile, scannerKeys)
 
+	// Read enabled internal plugin flags
+	enabledInternalPlugins := map[string]bool{}
+	for _, pluginName := range requireInternalPluginsList() {
+		val, _ := cmd.Flags().GetBool(pluginName)
+		enabledInternalPlugins[pluginName] = val
+	}
+
 	params := internal.ScanParams{
 		ManagementGroups:       managementGroups,
 		Subscriptions:          subscriptions,
@@ -83,8 +98,14 @@ func scan(cmd *cobra.Command, scannerKeys []string) {
 		ScannerKeys:            scannerKeys,
 		Filters:                filters,
 		UseAzqrRecommendations: useAzqr,
+		EnabledInternalPlugins: enabledInternalPlugins,
 	}
 
 	scanner := internal.Scanner{}
 	scanner.Scan(&params)
 }
+
+// requireInternalPluginsList returns the list of internal plugin names
+func requireInternalPluginsList() []string {
+	return plugins.ListInternalPlugins()
+}
diff --git a/cmd/azqr/main.go b/cmd/azqr/main.go
index d2b3bb2..0811a82 100644
--- a/cmd/azqr/main.go
+++ b/cmd/azqr/main.go
@@ -5,6 +5,10 @@ package main
 
 import (
 	"github.com/Azure/azqr/cmd/azqr/commands"
+
+	// Import internal plugins to register them
+	_ "github.com/Azure/azqr/internal/scanners/plugins/carbon"
+	_ "github.com/Azure/azqr/internal/scanners/plugins/openai"
 )
 
 func main() {
diff --git a/docs/content/en/docs/Plugins/_index.md b/docs/content/en/docs/Plugins/_index.md
new file mode 100644
index 0000000..a8f46be
--- /dev/null
+++ b/docs/content/en/docs/Plugins/_index.md
@@ -0,0 +1,388 @@
+---
+title: Plugins
+description: Documentation for creating and using YAML plugins in azqr
+weight: 1
+---
+
+## Overview
+
+YAML plugins provide a simple, declarative way to extend azqr with custom Azure Resource Graph (ARG) queries without writing Go code. This is ideal for:
+- Quick custom checks and validations
+- Organization-specific compliance rules
+- Temporary or experimental recommendations
+- Non-developers who want to extend azqr
+
+## Plugin Structure
+
+A YAML plugin consists of a single `.yaml` file containing plugin metadata and one or more Azure Resource Graph queries.
+
+### Basic Structure
+
+```yaml
+name: plugin-name
+version: 1.0.0
+description: Brief description of what this plugin does
+author: Plugin Author Name (optional)
+license: MIT (optional)
+
+queries:
+  - aprlGuid: unique-id-001
+    description: Short description of the recommendation
+    longDescription: |
+      Detailed description of the issue and why it matters.
+      Can be multiple lines.
+    recommendationControl: Category Name
+    recommendationImpact: Impact Level
+    recommendationResourceType: Microsoft.Service/resourceType
+    learnMoreLink:
+      - name: Documentation Title
+        url: https://learn.microsoft.com/...
+    query: |
+      resources
+      | where type =~ 'microsoft.service/resourcetype'
+      | where some_condition == true
+      | project id, name, resourceGroup, location
+```
+
+### Required Fields
+
+#### Plugin Level
+- **name**: Unique plugin identifier (string)
+- **version**: Semantic version (e.g., "1.0.0")
+- **description**: What the plugin does
+
+#### Query Level
+- **aprlGuid**: Unique identifier for the recommendation
+- **description**: Short recommendation title
+- **recommendationControl**: Category (see Categories section)
+- **recommendationImpact**: Impact level (High, Medium, Low)
+- **recommendationResourceType**: Azure resource type (e.g., "Microsoft.Storage/storageAccounts")
+- **query** OR **queryFile**: The KQL query to execute
+
+### Optional Fields
+
+#### Plugin Level
+- **author**: Plugin author name
+- **license**: License type (e.g., MIT, Apache-2.0)
+
+#### Query Level
+- **longDescription**: Detailed explanation
+- **learnMoreLink**: Array of documentation links
+- **recommendationTypeId**: Azure Policy or APRL recommendation ID
+- **recommendationMetadataState**: State (Active, Deprecated)
+- **potentialBenefits**: Benefits of implementing the recommendation
+- **pgVerified**: Whether verified by product group (boolean)
+- **automationAvailable**: Whether automation is available (boolean)
+- **tags**: Array of tags for categorization
+
+## Categories
+
+Valid values for `recommendationControl`:
+- **High Availability**: Availability and redundancy
+- **Security**: Security and access control
+- **Disaster Recovery**: Backup and recovery
+- **Scalability**: Scaling and performance
+- **Governance**: Compliance and governance
+- **Monitoring and Alerting**: Observability
+- **Business Continuity**: Business continuity planning
+- **Service Upgrade and Retirement**: Service lifecycle
+- **Other Best Practices**: General best practices (default)
+
+## Impact Levels
+
+Valid values for `recommendationImpact`:
+- **High**: Critical issues that should be addressed immediately
+- **Medium**: Important issues that should be addressed soon (default)
+- **Low**: Nice-to-have improvements
+
+## Query Format
+
+### Inline Query
+
+Include the KQL query directly in the YAML file:
+
+```yaml
+queries:
+  - aprlGuid: example-001
+    description: Example recommendation
+    recommendationControl: Security
+    recommendationImpact: High
+    recommendationResourceType: Microsoft.Storage/storageAccounts
+    query: |
+      resources
+      | where type =~ 'microsoft.storage/storageaccounts'
+      | where properties.supportsHttpsTrafficOnly == false
+      | project id, name, resourceGroup, location
+```
+
+### External Query File
+
+Reference an external `.kql` file:
+
+```yaml
+queries:
+  - aprlGuid: example-002
+    description: Example with external query
+    recommendationControl: Governance
+    recommendationImpact: Medium
+    recommendationResourceType: Microsoft.Network/publicIPAddresses
+    queryFile: ./kql/unused-public-ips.kql
+```
+
+The path is relative to the YAML file location. Create a `kql/` subdirectory next to your plugin YAML file.
+
+## Query Requirements
+
+Your Azure Resource Graph queries must:
+
+1. **Return resources**: Query the `resources` table
+2. **Project required fields**: Include at minimum:
+   - `id`: Resource ID
+   - `name`: Resource name
+   - `resourceGroup`: Resource group name
+   - `location`: Azure region
+
+3. **Filter appropriately**: Use `where` clauses to identify non-compliant resources
+4. **Use case-insensitive comparisons**: Use `=~` instead of `==` for type comparisons
+
+### Query Example
+
+```kql
+resources
+| where type =~ 'microsoft.network/networkinterfaces'
+| where properties.virtualMachine == "" or isnull(properties.virtualMachine)
+| where properties.privateEndpoint == "" or isnull(properties.privateEndpoint)
+| project id, name, resourceGroup, location,
+          tags,
+          sku = properties.ipConfigurations[0].properties.privateIPAllocationMethod
+```
+
+## Plugin Discovery
+
+YAML plugins are discovered from the following locations:
+
+1. **Current directory**: `./plugins/*.yaml`
+2. **User plugins directory**: `~/.azqr/plugins/*.yaml`
+3. **System plugins directory**: `/etc/azqr/plugins/*.yaml` (Linux/macOS)
+
+azqr searches recursively in these directories for any `.yaml` or `.yml` files.
+
+## Complete Example
+
+Here's a complete example plugin (`custom-checks.yaml`):
+
+```yaml
+name: example-custom-checks
+version: 1.0.0
+description: Example YAML plugin with custom Azure Resource Graph queries
+author: Azure Quick Review Team
+license: MIT
+
+queries:
+  # Check for unused network interfaces
+  - description: Network interfaces not attached to any VM
+    aprlGuid: yaml-001-unused-nics
+    recommendationTypeId: null
+    recommendationControl: Governance
+    recommendationImpact: Low
+    recommendationResourceType: Microsoft.Network/networkInterfaces
+    recommendationMetadataState: Active
+    longDescription: |
+      Network interfaces that are not attached to any virtual machine.
+      These resources incur costs and should be reviewed for cleanup.
+    potentialBenefits: Cost optimization and resource cleanup
+    pgVerified: false
+    automationAvailable: false
+    tags:
+      - cost-optimization
+      - cleanup
+    learnMoreLink:
+      - name: Network Interface Overview
+        url: "https://learn.microsoft.com/azure/virtual-network/virtual-network-network-interface"
+    query: |
+      resources
+      | where type =~ 'Microsoft.Network/networkInterfaces'
+      | where properties.virtualMachine == "" or isnull(properties.virtualMachine)
+      | where properties.privateEndpoint == "" or isnull(properties.privateEndpoint)
+      | project id, name, resourceGroup, location, tags
+
+  # Check for unused public IPs (from external file)
+  - description: Public IP addresses not associated with any resource
+    aprlGuid: yaml-002-unused-public-ips
+    recommendationControl: Governance
+    recommendationImpact: Medium
+    recommendationResourceType: Microsoft.Network/publicIPAddresses
+    longDescription: |
+      Public IP addresses that are not associated with any Azure resource.
+      These IPs cost money even when not in use.
+    learnMoreLink:
+      - name: Public IP Addresses
+        url: "https://learn.microsoft.com/azure/virtual-network/ip-services/public-ip-addresses"
+    queryFile: kql/unused-public-ips.kql
+
+  # Security check
+  - description: Storage accounts without secure transfer enabled
+    aprlGuid: yaml-003-storage-secure-transfer
+    recommendationControl: Security
+    recommendationImpact: High
+    recommendationResourceType: Microsoft.Storage/storageAccounts
+    longDescription: |
+      Storage accounts that do not have secure transfer (HTTPS) required.
+      This is a security risk as data can be transmitted over insecure connections.
+    potentialBenefits: Improved security and data protection
+    learnMoreLink:
+      - name: Require secure transfer
+        url: "https://learn.microsoft.com/azure/storage/common/storage-require-secure-transfer"
+    query: |
+      resources
+      | where type =~ 'Microsoft.Storage/storageAccounts'
+      | where properties.supportsHttpsTrafficOnly == false
+      | project id, name, resourceGroup, location,
+                sku = sku.name,
+                tier = sku.tier
+```
+
+## Usage
+
+Once you've created your YAML plugin:
+
+1. **Place the file** in one of the plugin directories
+2. **Run azqr scan** as normal:
+   ```bash
+   azqr scan
+   ```
+
+3. **View plugin info**:
+   ```bash
+   azqr plugins list
+   azqr plugins info <plugin-name>
+   ```
+
+The recommendations from your YAML plugin will be included in all outputs (Excel, CSV, JSON) alongside built-in recommendations.
+
+## Best Practices
+
+### 1. Use Descriptive Names
+```yaml
+name: org-security-checks
+description: Organization-specific security compliance checks
+```
+
+### 2. Group Related Checks
+Put related recommendations in the same plugin file:
+```yaml
+name: network-optimization
+queries:
+  - aprlGuid: net-001-unused-nics
+    description: Unused network interfaces
+    ...
+  - aprlGuid: net-002-unused-ips
+    description: Unused public IPs
+    ...
+```
+
+### 3. Version Your Plugins
+Follow semantic versioning:
+- **1.0.0**: Initial release
+- **1.1.0**: Add new queries
+- **2.0.0**: Breaking changes
+
+### 4. Provide Learn More Links
+Always include documentation links:
+```yaml
+learnMoreLink:
+  - name: Official Documentation
+    url: https://learn.microsoft.com/...
+  - name: Best Practices Guide
+    url: https://learn.microsoft.com/...
+```
+
+### 5. Test Your Queries
+Test queries in Azure Resource Graph Explorer first:
+- https://portal.azure.com/#view/HubsExtension/ArgQueryBlade
+
+### 6. Use External Files for Complex Queries
+For queries over ~10 lines, use external `.kql` files:
+```
+my-plugin/
+├── custom-checks.yaml
+└── kql/
+    ├── query1.kql
+    ├── query2.kql
+    └── query3.kql
+```
+
+### 7. Document Your Plugin
+Include comprehensive descriptions:
+```yaml
+longDescription: |
+  This check identifies resources that...
+  
+  Why it matters:
+  - Cost implications
+  - Security risks
+  - Performance impact
+  
+  How to fix:
+  1. Step one
+  2. Step two
+```
+
+## Troubleshooting
+
+### Plugin Not Discovered
+
+1. Check the file extension (`.yaml` or `.yml`)
+2. Verify the file is in a plugin directory
+3. Run with debug logging:
+   ```bash
+   azqr scan --debug
+   ```
+
+### Query Errors
+
+1. **Syntax errors**: Test the query in Azure Resource Graph Explorer
+2. **No results**: Verify the resource type filter
+3. **Permission errors**: Ensure you have Reader access to subscriptions
+
+### Invalid YAML
+
+Use a YAML validator to check syntax:
+```bash
+yamllint custom-checks.yaml
+```
+
+## Limitations
+
+1. **Query-based only**: YAML plugins can only use Azure Resource Graph queries, not ARM API calls
+3. **Subscription scope**: Queries run within subscription context
+4. **No custom logic**: Cannot include complex evaluation logic (use built-in plugins for that)
+
+## Migration from Graph Queries
+
+If you have existing ARG queries, convert them to YAML plugins:
+
+**Before** (separate .kql files):
+```kql
+resources
+| where type =~ 'microsoft.storage/storageaccounts'
+| where properties.supportsHttpsTrafficOnly == false
+```
+
+**After** (YAML plugin):
+```yaml
+name: my-checks
+version: 1.0.0
+description: My custom checks
+queries:
+  - aprlGuid: check-001
+    description: Storage accounts without HTTPS
+    recommendationControl: Security
+    recommendationImpact: High
+    recommendationResourceType: Microsoft.Storage/storageAccounts
+    query: |
+      resources
+      | where type =~ 'microsoft.storage/storageaccounts'
+      | where properties.supportsHttpsTrafficOnly == false
+      | project id, name, resourceGroup, location
+```
diff --git a/examples/plugins/yaml-example/README.md b/examples/plugins/yaml-example/README.md
new file mode 100644
index 0000000..3189018
--- /dev/null
+++ b/examples/plugins/yaml-example/README.md
@@ -0,0 +1,346 @@
+# YAML Plugin Example
+
+This example demonstrates how to create a YAML-based plugin for azqr that executes custom Azure Resource Graph queries.
+
+## Overview
+
+YAML plugins allow you to define custom Azure Resource Graph (ARG) queries without writing Go code. They are perfect for:
+- Custom governance checks
+- Organization-specific policies
+- Quick prototyping of new checks
+- Sharing queries across teams
+
+## File Structure
+
+```
+examples/plugins/yaml-example/
+├── custom-checks.yaml    # Main plugin configuration
+├── kql/                  # Optional: External KQL query files
+│   └── unused-public-ips.kql
+└── README.md            # This file
+```
+
+## Plugin Configuration
+
+### Basic Structure
+
+```yaml
+name: my-custom-plugin          # Unique plugin identifier
+version: 1.0.0                  # Semantic version
+description: My custom checks   # Brief description
+author: Your Name              # Optional
+license: MIT                   # Optional
+
+queries:                       # List of queries to execute
+  - description: Check description
+    aprlGuid: unique-id-001
+    # ... query configuration
+```
+
+### Query Configuration
+
+Each query requires:
+
+```yaml
+- description: What this query checks for
+  aprlGuid: unique-identifier  # Must be unique across all queries
+  recommendationControl: Category  # See categories below
+  recommendationImpact: High|Medium|Low
+  recommendationResourceType: Microsoft.Service/resourceType
+  recommendationMetadataState: Active|Preview|Deprecated
+  longDescription: |
+    Detailed explanation of the recommendation
+    and why it matters.
+  potentialBenefits: What you gain by following this
+  pgVerified: true|false  # Verified by product group
+  automationAvailable: true|false
+  tags:
+    - tag1
+    - tag2
+  learnMoreLink:
+    - name: Link Title
+      url: "https://learn.microsoft.com/..."
+  
+  # Option 1: Inline query
+  query: |
+    resources
+    | where type =~ 'Microsoft.Network/networkInterfaces'
+    | where properties.virtualMachine == ""
+    | project recommendationId="unique-id-001", name, id, tags
+  
+  # Option 2: External query file
+  queryFile: kql/my-query.kql
+```
+
+### Categories
+
+Use these for `recommendationControl`:
+- `HighAvailability` - Availability and redundancy
+- `Security` - Security and access control
+- `DisasterRecovery` - Backup and disaster recovery
+- `Monitoring` or `Alerting` - Monitoring and alerting
+- `Governance` - Resource management and governance
+- `Scalability` - Performance and scaling
+- `BusinessContinuity` - Business continuity
+- Other values default to `OtherBestPractices`
+
+### Impact Levels
+
+- `High` - Critical issues that should be addressed immediately
+- `Medium` - Important issues that should be planned
+- `Low` - Nice-to-have improvements
+
+## Query Requirements
+
+Your KQL query **must**:
+
+1. **Include `recommendationId` in project**: This links results to the recommendation
+   ```kql
+   | project recommendationId="your-unique-id", name, id, tags
+   ```
+
+2. **Return these standard fields**:
+   - `recommendationId` - Your query's aprlGuid
+   - `name` - Resource name
+   - `id` - Resource ID
+   - `tags` - Resource tags (optional)
+
+3. **Optionally include**:
+   - `param1`, `param2`, etc. - Additional context shown in reports
+   - `resourceGroup` - Explicitly specify resource group
+   - `location` - Explicitly specify location
+
+### Example Queries
+
+**Unused Network Interfaces:**
+```kql
+resources
+| where type =~ 'Microsoft.Network/networkInterfaces'
+| where properties.virtualMachine == "" or isnull(properties.virtualMachine)
+| project recommendationId="yaml-001", name, id, tags,
+          param1=strcat("Location: ", location)
+```
+
+**Storage Without Encryption:**
+```kql
+resources
+| where type =~ 'Microsoft.Storage/storageAccounts'
+| where properties.encryption.services.blob.enabled == false
+| project recommendationId="yaml-002", name, id, tags,
+          param1=strcat("SKU: ", sku.name)
+```
+
+## Installation
+
+### Option 1: User Plugin Directory (Recommended)
+
+```bash
+# Copy to user plugin directory
+mkdir -p ~/.azqr/plugins
+cp custom-checks.yaml ~/.azqr/plugins/
+cp -r kql ~/.azqr/plugins/
+
+# Verify plugin is loaded
+azqr plugins list
+```
+
+### Option 2: Local Plugin Directory
+
+```bash
+# Copy to local plugins directory
+mkdir -p ./plugins
+cp custom-checks.yaml ./plugins/
+cp -r kql ./plugins/
+
+# Run azqr from this directory
+azqr plugins list
+```
+
+### Option 3: Custom Plugin Directory
+
+```bash
+# Use AZQR_PLUGIN_DIR environment variable
+export AZQR_PLUGIN_DIR=/path/to/your/plugins
+azqr plugins list
+```
+
+## Usage
+
+Once installed, YAML plugins work like any other scanner:
+
+```bash
+# List all plugins (including YAML plugins)
+azqr plugins list
+
+# Show plugin details
+azqr plugins info example-custom-checks
+
+# Run scan with YAML plugin
+azqr scan --subscription-id <sub-id>
+
+# YAML plugin results are included in all output formats:
+# - Excel report (recommendations tab)
+# - CSV files
+# - JSON output
+```
+
+## Testing Your Plugin
+
+1. **Validate YAML syntax:**
+   ```bash
+   # Use any YAML validator
+   yamllint custom-checks.yaml
+   ```
+
+2. **Test query in Azure Portal:**
+   - Go to Azure Portal → Resource Graph Explorer
+   - Paste your query
+   - Verify it returns expected results
+
+3. **Load and inspect:**
+   ```bash
+   azqr plugins info your-plugin-name
+   ```
+
+4. **Run a test scan:**
+   ```bash
+   azqr scan --subscription-id <test-sub-id>
+   ```
+
+## Best Practices
+
+### Query Design
+
+1. **Be Specific**: Target exact resource types
+   ```kql
+   | where type =~ 'Microsoft.Network/networkInterfaces'
+   ```
+
+2. **Exclude Known Cases**: Filter out expected resources
+   ```kql
+   | where not(name endswith "-asr")  # Exclude Azure Site Recovery resources
+   ```
+
+3. **Handle Nulls**: Check for both empty strings and nulls
+   ```kql
+   | where properties.field == "" or isnull(properties.field)
+   ```
+
+4. **Add Context**: Include helpful parameters
+   ```kql
+   | project ... param1=strcat("Cost/month: $", properties.cost)
+   ```
+
+5. **Optimize Performance**: Use specific type filters early
+   ```kql
+   resources
+   | where type =~ 'Microsoft.Network/publicIPAddresses'  # Filter first
+   | where properties.ipConfiguration == ""                # Then check properties
+   ```
+
+### Plugin Organization
+
+1. **Group Related Checks**: Put related queries in one plugin
+2. **Use Descriptive Names**: Clear, meaningful plugin names
+3. **Version Properly**: Follow semantic versioning
+4. **Document Well**: Clear descriptions and learn more links
+5. **External vs Inline**: Use external files for complex queries
+
+### Naming Conventions
+
+1. **Plugin Names**: `lowercase-with-hyphens`
+2. **APR L GUIDs**: `category-number-description` (e.g., `yaml-001-unused-nics`)
+3. **KQL Files**: Match the check they implement
+
+## Troubleshooting
+
+### Plugin Not Loaded
+
+```bash
+# Check plugin directories
+echo $HOME/.azqr/plugins
+ls -la ~/.azqr/plugins/
+
+# Enable debug logging
+azqr scan --debug
+```
+
+### Query Fails
+
+1. **Test in Azure Portal**: Verify query works
+2. **Check Permissions**: Ensure Reader access on subscriptions
+3. **Review Logs**: Look for error messages
+4. **Validate YAML**: Ensure proper formatting
+
+### No Results
+
+1. **Verify Subscriptions**: Check you're scanning correct subscriptions
+2. **Test Query**: Run directly in Resource Graph Explorer
+3. **Check Filters**: Ensure filters aren't too restrictive
+
+## Examples
+
+See the included `custom-checks.yaml` for complete examples of:
+- Inline queries
+- External query files
+- Different recommendation categories
+- Various impact levels
+- Multiple parameters
+
+## Advanced Usage
+
+### Multiple Query Files
+
+```yaml
+queries:
+  - description: Check 1
+    queryFile: kql/check1.kql
+  - description: Check 2
+    queryFile: kql/check2.kql
+  - description: Check 3
+    queryFile: kql/subfolder/check3.kql
+```
+
+### Dynamic Queries
+
+Use KQL functions for reusability:
+
+```kql
+let excludedTags = dynamic(["DoNotDelete", "Production"]);
+resources
+| where type =~ 'Microsoft.Network/publicIPAddresses'
+| where not(tags has_any excludedTags)
+| project recommendationId="yaml-001", name, id, tags
+```
+
+### Cross-Resource Queries
+
+```kql
+resources
+| where type =~ 'Microsoft.Network/publicIPAddresses'
+| where properties.ipConfiguration == ""
+| join kind=leftanti (
+    resources
+    | where type =~ 'Microsoft.Network/natGateways'
+    | mvexpand publicIp = properties.publicIpAddresses
+    | project publicIpId = tostring(publicIp.id)
+) on $left.id == $right.publicIpId
+| project recommendationId="yaml-001", name, id, tags
+```
+
+## Contributing
+
+To share your YAML plugin with the community:
+
+1. Test thoroughly across multiple subscriptions
+2. Document the plugin and queries
+3. Create a pull request to the azqr repository
+4. Include in `examples/plugins/yaml-example/community/`
+
+## Learn More
+
+- [Azure Resource Graph Query Language](https://learn.microsoft.com/azure/governance/resource-graph/concepts/query-language)
+- [KQL Quick Reference](https://learn.microsoft.com/azure/data-explorer/kql-quick-reference)
+- [Resource Graph Sample Queries](https://learn.microsoft.com/azure/governance/resource-graph/samples/starter)
+- [APRL Recommendations](https://azure.github.io/Azure-Proactive-Resiliency-Library/)
+- [Plugin Development Guide](../../../docs/content/en/plugins.md)
diff --git a/examples/plugins/yaml-example/custom-checks.yaml b/examples/plugins/yaml-example/custom-checks.yaml
new file mode 100644
index 0000000..6733a1e
--- /dev/null
+++ b/examples/plugins/yaml-example/custom-checks.yaml
@@ -0,0 +1,85 @@
+name: example-custom-checks
+version: 1.0.0
+description: Example YAML plugin with custom Azure Resource Graph queries
+author: Azure Quick Review Team
+license: MIT
+
+queries:
+  # Example 1: Inline query for unused NICs
+  - description: Network interfaces not attached to any VM
+    aprlGuid: yaml-001-unused-nics
+    recommendationTypeId: null
+    recommendationControl: Governance
+    recommendationImpact: Low
+    recommendationResourceType: Microsoft.Network/networkInterfaces
+    recommendationMetadataState: Active
+    longDescription: |
+      Network interfaces that are not attached to any virtual machine.
+      These resources incur costs and should be reviewed for cleanup.
+    potentialBenefits: Cost optimization and resource cleanup
+    pgVerified: false
+    automationAvailable: false
+    tags:
+      - cost-optimization
+      - cleanup
+    learnMoreLink:
+      - name: Network Interface Overview
+        url: "https://learn.microsoft.com/azure/virtual-network/virtual-network-network-interface"
+    query: |
+      resources
+      | where type =~ 'Microsoft.Network/networkInterfaces'
+      | where properties.virtualMachine == "" or isnull(properties.virtualMachine)
+      | where properties.privateEndpoint == "" or isnull(properties.privateEndpoint)
+      | project recommendationId="yaml-001-unused-nics", name, id, tags, 
+                param1=strcat("Location: ", location),
+                param2=strcat("Resource Group: ", resourceGroup)
+
+  # Example 2: Query from external file
+  - description: Public IP addresses not associated with any resource
+    aprlGuid: yaml-002-unused-public-ips
+    recommendationTypeId: null
+    recommendationControl: Governance
+    recommendationImpact: Medium
+    recommendationResourceType: Microsoft.Network/publicIPAddresses
+    recommendationMetadataState: Active
+    longDescription: |
+      Public IP addresses that are not associated with any Azure resource.
+      These IPs cost money even when not in use.
+    potentialBenefits: Reduce costs by removing unused public IPs
+    pgVerified: false
+    automationAvailable: true
+    tags:
+      - cost-optimization
+      - security
+    learnMoreLink:
+      - name: Public IP Addresses
+        url: "https://learn.microsoft.com/azure/virtual-network/ip-services/public-ip-addresses"
+    queryFile: kql/unused-public-ips.kql
+
+  # Example 3: Security check
+  - description: Storage accounts without secure transfer enabled
+    aprlGuid: yaml-003-storage-secure-transfer
+    recommendationTypeId: null
+    recommendationControl: Security
+    recommendationImpact: High
+    recommendationResourceType: Microsoft.Storage/storageAccounts
+    recommendationMetadataState: Active
+    longDescription: |
+      Storage accounts that do not have secure transfer (HTTPS) required.
+      This is a security risk as data can be transmitted over insecure connections.
+    potentialBenefits: Improved security and data protection
+    pgVerified: true
+    automationAvailable: true
+    tags:
+      - security
+      - compliance
+    learnMoreLink:
+      - name: Require secure transfer
+        url: "https://learn.microsoft.com/azure/storage/common/storage-require-secure-transfer"
+    query: |
+      resources
+      | where type =~ 'Microsoft.Storage/storageAccounts'
+      | where properties.supportsHttpsTrafficOnly == false
+      | project recommendationId="yaml-003-storage-secure-transfer", name, id, tags,
+                param1=strcat("SKU: ", sku.name),
+                param2=strcat("Location: ", location)
diff --git a/examples/plugins/yaml-example/kql/unused-public-ips.kql b/examples/plugins/yaml-example/kql/unused-public-ips.kql
new file mode 100644
index 0000000..7651632
--- /dev/null
+++ b/examples/plugins/yaml-example/kql/unused-public-ips.kql
@@ -0,0 +1,9 @@
+// Azure Resource Graph Query
+// Find all public IP addresses that are not associated with any resource
+resources
+| where type =~ 'Microsoft.Network/publicIPAddresses'
+| where properties.ipConfiguration == "" or isnull(properties.ipConfiguration)
+| where properties.natGateway == "" or isnull(properties.natGateway)
+| project recommendationId="yaml-002-unused-public-ips", name, id, tags,
+          param1=strcat("SKU: ", sku.name),
+          param2=strcat("Allocation: ", properties.publicIPAllocationMethod)
diff --git a/go.mod b/go.mod
index f1742c5..b771412 100644
--- a/go.mod
+++ b/go.mod
@@ -13,6 +13,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v2 v2.1.0
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapplicationinsights v1.2.0
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0
+	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/carbonoptimization/armcarbonoptimization v1.0.0
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn v1.1.1
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v4 v4.2.1
@@ -56,6 +57,7 @@ require (
 	github.com/metoro-io/mcp-golang v0.14.0
 	github.com/rs/zerolog v1.34.0
 	github.com/spf13/cobra v1.10.1
+	github.com/stretchr/testify v1.11.1
 	github.com/xuri/excelize/v2 v2.10.0
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -65,6 +67,7 @@ require (
 	github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
 	github.com/bahlo/generic-list-go v0.2.0 // indirect
 	github.com/buger/jsonparser v1.1.1 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -74,6 +77,7 @@ require (
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pkg/errors v0.9.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/richardlehane/mscfb v1.0.4 // indirect
 	github.com/richardlehane/msoleps v1.0.4 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
diff --git a/go.sum b/go.sum
index 9b07d50..4f76832 100644
--- a/go.sum
+++ b/go.sum
@@ -18,6 +18,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapp
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/applicationinsights/armapplicationinsights v1.2.0/go.mod h1:S7Ss6Rm0nlKDRHKrO9eL2Be5EnX29Z09CNPWgK7o4+I=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/carbonoptimization/armcarbonoptimization v1.0.0 h1:3QWZJ2VQpmjuKVdxz9W1uGeuDfJVDbjqJuMD3fJL96g=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/carbonoptimization/armcarbonoptimization v1.0.0/go.mod h1:RKeTr4xYL46aSsglK/ejFymF4/TM4iN0V8gKscCpcT0=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn v1.1.1 h1:CtE6GCP9YEDF6DjpFxl7xQBqklqfyCC/xkBKUGa/IAc=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cdn/armcdn v1.1.1/go.mod h1:b9yk+8vyxSsBsiEjk9kzrwxgyn+7+J4HzDOYUPznES4=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 h1:ZMGAqCZov8+7iFUPWKVcTaLgNXUeTlz20sIuWkQWNfg=
diff --git a/internal/graph/graph_scanner.go b/internal/graph/graph_scanner.go
index 7ae2f2e..78ab0ba 100644
--- a/internal/graph/graph_scanner.go
+++ b/internal/graph/graph_scanner.go
@@ -32,6 +32,7 @@ type (
 		serviceScanners []models.IAzureScanner
 		filters         *models.Filters
 		subscriptions   map[string]string
+		externalQueries map[string]map[string]models.AprlRecommendation // External YAML plugin queries by resource type
 	}
 
 	ScanType string
@@ -53,9 +54,19 @@ func NewAprlScanner(serviceScanners []models.IAzureScanner, filters *models.Filt
 		serviceScanners: serviceScanners,
 		filters:         filters,
 		subscriptions:   subscriptions,
+		externalQueries: make(map[string]map[string]models.AprlRecommendation),
 	}
 }
 
+// RegisterExternalQuery adds an external YAML plugin query to the scanner
+func (a *AprlScanner) RegisterExternalQuery(resourceType string, recommendation models.AprlRecommendation) {
+	resourceType = strings.ToLower(resourceType)
+	if a.externalQueries[resourceType] == nil {
+		a.externalQueries[resourceType] = make(map[string]models.AprlRecommendation)
+	}
+	a.externalQueries[resourceType][recommendation.RecommendationID] = recommendation
+}
+
 // GetAprlRecommendations returns a map with all APRL recommendations
 func (a AprlScanner) GetAprlRecommendations() map[string]map[string]models.AprlRecommendation {
 	recommendations := map[string]map[string]models.AprlRecommendation{}
@@ -298,6 +309,8 @@ func (a AprlScanner) graphScan(ctx context.Context, graphClient *GraphQueryClien
 
 func (a AprlScanner) getGraphRules(service string, aprl map[string]map[string]models.AprlRecommendation) map[string]models.AprlRecommendation {
 	r := map[string]models.AprlRecommendation{}
+
+	// Add embedded APRL recommendations
 	if i, ok := aprl[strings.ToLower(service)]; ok {
 		for _, recommendation := range i {
 			if a.filters.Azqr.IsRecommendationExcluded(recommendation.RecommendationID) ||
@@ -311,5 +324,16 @@ func (a AprlScanner) getGraphRules(service string, aprl map[string]map[string]mo
 			r[recommendation.RecommendationID] = recommendation
 		}
 	}
+
+	// Add external YAML plugin queries
+	if external, ok := a.externalQueries[strings.ToLower(service)]; ok {
+		for id, recommendation := range external {
+			if a.filters.Azqr.IsRecommendationExcluded(recommendation.RecommendationID) {
+				continue
+			}
+			r[id] = recommendation
+		}
+	}
+
 	return r
 }
diff --git a/internal/plugins/interface.go b/internal/plugins/interface.go
new file mode 100644
index 0000000..6cc3b69
--- /dev/null
+++ b/internal/plugins/interface.go
@@ -0,0 +1,142 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"context"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/spf13/cobra"
+)
+
+// PluginType represents the type of plugin
+type PluginType int
+
+const (
+	// PluginTypeYaml represents a YAML-based plugin with graph queries
+	PluginTypeYaml PluginType = iota
+	// PluginTypeInternal represents an internal Go-based plugin
+	PluginTypeInternal
+)
+
+// PluginMetadata contains information about a plugin
+type PluginMetadata struct {
+	// Name is the unique identifier for the plugin (e.g., "myservice")
+	Name string
+	// Version is the semantic version of the plugin
+	Version string
+	// Description is a short description of what the plugin does
+	Description string
+	// Author is the plugin author/maintainer
+	Author string
+	// License is the plugin license (e.g., "MIT", "Apache-2.0")
+	License string
+	// Type indicates if this is a built-in or external plugin
+	Type PluginType
+	// Command is the full path to the external command (for external plugins only)
+	CommandPath string
+	// ColumnMetadata defines the columns and their filter types for the viewer
+	ColumnMetadata []ColumnMetadata
+}
+
+// Plugin represents a loaded plugin with its scanner and command
+type Plugin struct {
+	// Metadata contains information about the plugin
+	Metadata PluginMetadata
+	// InternalScanner is the internal plugin scanner (for internal plugins)
+	InternalScanner InternalPluginScanner
+	// YamlRecommendations holds APRL recommendations for YAML plugins
+	YamlRecommendations []models.AprlRecommendation
+	// Command is the Cobra command for this plugin (optional)
+	Command *cobra.Command
+}
+
+// PluginInitializer is an optional interface for plugins that need custom initialization
+type PluginInitializer interface {
+	// Initialize is called when the plugin is loaded, before any scanning
+	Initialize(ctx context.Context) error
+	// Cleanup is called when the plugin is unloaded or azqr exits
+	Cleanup() error
+}
+
+// YamlPluginQuery represents a single query definition in a YAML plugin
+type YamlPluginQuery struct {
+	// Description of what this query checks
+	Description string `yaml:"description"`
+	// AprlGuid is a unique identifier for the recommendation
+	AprlGuid string `yaml:"aprlGuid"`
+	// RecommendationTypeId from Azure Advisor (optional)
+	RecommendationTypeId *string `yaml:"recommendationTypeId"`
+	// RecommendationControl category (e.g., HighAvailability, Security)
+	RecommendationControl string `yaml:"recommendationControl"`
+	// RecommendationImpact level (High, Medium, Low)
+	RecommendationImpact string `yaml:"recommendationImpact"`
+	// RecommendationResourceType Azure resource type
+	RecommendationResourceType string `yaml:"recommendationResourceType"`
+	// RecommendationMetadataState (Active, Preview, Deprecated)
+	RecommendationMetadataState string `yaml:"recommendationMetadataState"`
+	// LongDescription detailed explanation
+	LongDescription string `yaml:"longDescription"`
+	// PotentialBenefits of implementing the recommendation
+	PotentialBenefits string `yaml:"potentialBenefits"`
+	// PgVerified indicates if verified by product group
+	PgVerified bool `yaml:"pgVerified"`
+	// AutomationAvailable indicates if automation is available
+	AutomationAvailable bool `yaml:"automationAvailable"`
+	// Tags for categorization
+	Tags []string `yaml:"tags"`
+	// LearnMoreLink for additional information
+	LearnMoreLink []struct {
+		Name string `yaml:"name"`
+		Url  string `yaml:"url"`
+	} `yaml:"learnMoreLink"`
+	// Query is the inline KQL query (optional if using external .kql file)
+	Query string `yaml:"query,omitempty"`
+	// QueryFile is the path to external .kql file (optional if using inline query)
+	QueryFile string `yaml:"queryFile,omitempty"`
+}
+
+// YamlPluginConfig represents the structure of a YAML plugin file
+type YamlPluginConfig struct {
+	// Name of the plugin
+	Name string `yaml:"name"`
+	// Version of the plugin
+	Version string `yaml:"version"`
+	// Description of the plugin
+	Description string `yaml:"description"`
+	// Author of the plugin
+	Author string `yaml:"author,omitempty"`
+	// License of the plugin
+	License string `yaml:"license,omitempty"`
+	// Queries list of graph queries to execute
+	Queries []YamlPluginQuery `yaml:"queries,omitempty"`
+}
+
+// FilterType represents the type of filter for a column
+type FilterType string
+
+const (
+	// FilterTypeNone indicates no filtering is available for this column
+	FilterTypeNone FilterType = "none"
+	// FilterTypeDropdown indicates a dropdown filter with distinct values (≤20 unique values)
+	FilterTypeDropdown FilterType = "dropdown"
+	// FilterTypeSearch indicates a search/text filter (>20 unique values)
+	FilterTypeSearch FilterType = "search"
+)
+
+// ColumnMetadata defines filtering behavior for a column in the viewer
+type ColumnMetadata struct {
+	Name       string     `json:"name"`       // Display name (e.g., "Latest Month Emissions")
+	DataKey    string     `json:"dataKey"`    // JSON data key (e.g., "latestMonthEmissions")
+	FilterType FilterType `json:"filterType"` // "none", "dropdown", or "search"
+}
+
+// ExternalPluginOutput represents the output from a plugin execution
+type ExternalPluginOutput struct {
+	Metadata    PluginMetadata `json:"metadata"`
+	SheetName   string         `json:"sheet_name"`      // Name for Excel sheet
+	Description string         `json:"description"`     // Description of data
+	Table       [][]string     `json:"table"`           // Headers + data rows
+	Error       string         `json:"error,omitempty"` // Error if failed
+}
diff --git a/internal/plugins/internal.go b/internal/plugins/internal.go
new file mode 100644
index 0000000..01c8468
--- /dev/null
+++ b/internal/plugins/internal.go
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"context"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+)
+
+// InternalPluginScanner defines the interface for internal plugins
+type InternalPluginScanner interface {
+	// Scan executes the plugin and returns table data
+	Scan(ctx context.Context, cred azcore.TokenCredential, subscriptions map[string]string, filters *models.Filters) (*ExternalPluginOutput, error)
+
+	// GetMetadata returns metadata about the plugin
+	GetMetadata() PluginMetadata
+}
+
+// internalPluginRegistry holds all registered internal plugins
+var internalPluginRegistry = make(map[string]InternalPluginScanner)
+
+// RegisterInternalPlugin registers an internal plugin
+func RegisterInternalPlugin(name string, scanner InternalPluginScanner) {
+	internalPluginRegistry[name] = scanner
+}
+
+// GetInternalPlugin retrieves a registered internal plugin
+func GetInternalPlugin(name string) (InternalPluginScanner, bool) {
+	scanner, exists := internalPluginRegistry[name]
+	return scanner, exists
+}
+
+// ListInternalPlugins returns all registered internal plugins
+func ListInternalPlugins() []string {
+	names := make([]string, 0, len(internalPluginRegistry))
+	for name := range internalPluginRegistry {
+		names = append(names, name)
+	}
+	return names
+}
+
+// discoverInternalPlugins converts internal plugins to Plugin objects
+func discoverInternalPlugins() []*Plugin {
+	plugins := make([]*Plugin, 0, len(internalPluginRegistry))
+
+	for _, scanner := range internalPluginRegistry {
+		metadata := scanner.GetMetadata()
+		metadata.Type = PluginTypeInternal
+
+		plugin := &Plugin{
+			Metadata:        metadata,
+			InternalScanner: scanner,
+		}
+
+		plugins = append(plugins, plugin)
+	}
+
+	return plugins
+}
diff --git a/internal/plugins/loader.go b/internal/plugins/loader.go
new file mode 100644
index 0000000..fbbe3fe
--- /dev/null
+++ b/internal/plugins/loader.go
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/rs/zerolog/log"
+)
+
+// getPluginDirs returns the directories to search for YAML plugins
+func getPluginDirs() []string {
+	home, _ := os.UserHomeDir()
+	return []string{
+		filepath.Join(home, ".azqr", "plugins"),
+		"./plugins",
+	}
+}
+
+// LoadAll discovers and loads all available plugins (internal and YAML)
+func LoadAll() error {
+	registry := GetRegistry()
+
+	// Discover internal plugins (registered at init time)
+	internalPlugins := discoverInternalPlugins()
+	for _, plugin := range internalPlugins {
+		if err := registry.Register(plugin); err != nil {
+			log.Warn().
+				Err(err).
+				Str("plugin", plugin.Metadata.Name).
+				Msg("Failed to register internal plugin")
+		}
+	}
+	log.Info().Int("count", len(internalPlugins)).Msg("Internal plugins discovered")
+
+	// Discover YAML plugins
+	yamlPlugins, err := discoverYamlPlugins(getPluginDirs())
+	if err != nil {
+		log.Warn().Err(err).Msg("Failed to discover YAML plugins")
+	} else {
+		for _, plugin := range yamlPlugins {
+			if err := registry.Register(plugin); err != nil {
+				log.Warn().
+					Err(err).
+					Str("plugin", plugin.Metadata.Name).
+					Msg("Failed to register YAML plugin")
+			}
+		}
+		log.Info().Int("count", len(yamlPlugins)).Msg("YAML plugins discovered")
+	}
+
+	return nil
+}
diff --git a/internal/plugins/registry.go b/internal/plugins/registry.go
new file mode 100644
index 0000000..0ba31ec
--- /dev/null
+++ b/internal/plugins/registry.go
@@ -0,0 +1,142 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"fmt"
+	"sort"
+	"sync"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/rs/zerolog/log"
+	"github.com/spf13/cobra"
+)
+
+// Registry manages all registered plugins
+type Registry struct {
+	mu      sync.RWMutex
+	plugins map[string]*Plugin
+}
+
+// globalRegistry is the singleton plugin registry
+var globalRegistry = &Registry{
+	plugins: make(map[string]*Plugin),
+}
+
+// GetRegistry returns the global plugin registry
+func GetRegistry() *Registry {
+	return globalRegistry
+}
+
+// Register registers a plugin with the registry
+func (r *Registry) Register(plugin *Plugin) error {
+	if plugin == nil {
+		return fmt.Errorf("cannot register nil plugin")
+	}
+	if plugin.Metadata.Name == "" {
+		return fmt.Errorf("plugin name cannot be empty")
+	}
+
+	r.mu.Lock()
+	defer r.mu.Unlock()
+
+	if existing, exists := r.plugins[plugin.Metadata.Name]; exists {
+		log.Warn().
+			Str("plugin", plugin.Metadata.Name).
+			Str("existing_version", existing.Metadata.Version).
+			Str("new_version", plugin.Metadata.Version).
+			Msg("Plugin already registered, replacing with new version")
+	}
+
+	r.plugins[plugin.Metadata.Name] = plugin
+
+	return nil
+}
+
+// Unregister removes a plugin from the registry
+func (r *Registry) Unregister(name string) error {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+
+	_, exists := r.plugins[name]
+	if !exists {
+		return fmt.Errorf("plugin %s not found", name)
+	}
+
+	// Remove from ScannerList
+	delete(models.ScannerList, name)
+
+	delete(r.plugins, name)
+
+	log.Info().Str("plugin", name).Msg("Plugin unregistered")
+	return nil
+}
+
+// Get retrieves a plugin by name
+func (r *Registry) Get(name string) (*Plugin, bool) {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	plugin, exists := r.plugins[name]
+	return plugin, exists
+}
+
+// List returns all registered plugins sorted by name
+func (r *Registry) List() []*Plugin {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	names := make([]string, 0, len(r.plugins))
+	for name := range r.plugins {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	plugins := make([]*Plugin, 0, len(names))
+	for _, name := range names {
+		plugins = append(plugins, r.plugins[name])
+	}
+
+	return plugins
+}
+
+// Count returns the number of registered plugins
+func (r *Registry) Count() int {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	return len(r.plugins)
+}
+
+// AttachCommands attaches all plugin commands to the parent command
+func (r *Registry) AttachCommands(parentCmd *cobra.Command) int {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+
+	attached := 0
+	for _, plugin := range r.plugins {
+		if plugin.Command != nil {
+			parentCmd.AddCommand(plugin.Command)
+			attached++
+			log.Debug().
+				Str("plugin", plugin.Metadata.Name).
+				Str("command", plugin.Command.Use).
+				Msg("Plugin command attached")
+		}
+	}
+
+	return attached
+}
+
+// pluginTypeString converts PluginType to string for logging
+func pluginTypeString(t PluginType) string {
+	switch t {
+	case PluginTypeYaml:
+		return "yaml"
+	case PluginTypeInternal:
+		return "internal"
+	default:
+		return "unknown"
+	}
+}
diff --git a/internal/plugins/yaml.go b/internal/plugins/yaml.go
new file mode 100644
index 0000000..40fd33e
--- /dev/null
+++ b/internal/plugins/yaml.go
@@ -0,0 +1,182 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/rs/zerolog/log"
+	"gopkg.in/yaml.v3"
+)
+
+// LoadYamlPlugin loads a YAML plugin from a file and converts queries to AprlRecommendation format
+func LoadYamlPlugin(filePath string) (*Plugin, []models.AprlRecommendation, error) {
+	data, err := os.ReadFile(filePath)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to read YAML plugin file: %w", err)
+	}
+
+	var config YamlPluginConfig
+	if err := yaml.Unmarshal(data, &config); err != nil {
+		return nil, nil, fmt.Errorf("failed to parse YAML plugin: %w", err)
+	}
+
+	// Validate plugin configuration
+	if config.Name == "" {
+		return nil, nil, fmt.Errorf("plugin name is required")
+	}
+	if config.Version == "" {
+		config.Version = "1.0.0"
+	}
+	if len(config.Queries) == 0 {
+		return nil, nil, fmt.Errorf("plugin must have at least one query")
+	}
+
+	// Load external query files if specified
+	baseDir := filepath.Dir(filePath)
+	for i := range config.Queries {
+		if config.Queries[i].QueryFile != "" {
+			queryPath := filepath.Join(baseDir, config.Queries[i].QueryFile)
+			queryData, err := os.ReadFile(queryPath)
+			if err != nil {
+				return nil, nil, fmt.Errorf("failed to read query file %s: %w", config.Queries[i].QueryFile, err)
+			}
+			config.Queries[i].Query = string(queryData)
+		}
+
+		// Validate query has either inline query or query file
+		if config.Queries[i].Query == "" {
+			return nil, nil, fmt.Errorf("query %s must have either 'query' or 'queryFile' specified", config.Queries[i].AprlGuid)
+		}
+
+		// Validate required fields
+		if config.Queries[i].AprlGuid == "" {
+			return nil, nil, fmt.Errorf("query missing required field 'aprlGuid'")
+		}
+		if config.Queries[i].Description == "" {
+			return nil, nil, fmt.Errorf("query %s missing required field 'description'", config.Queries[i].AprlGuid)
+		}
+	}
+
+	// Convert YamlPluginQuery to AprlRecommendation format
+	recommendations := make([]models.AprlRecommendation, 0, len(config.Queries))
+	for _, query := range config.Queries {
+		automationAvailable := "false"
+		if query.AutomationAvailable {
+			automationAvailable = "true"
+		}
+
+		recommendation := models.AprlRecommendation{
+			RecommendationID:    query.AprlGuid,
+			ResourceType:        query.RecommendationResourceType,
+			Recommendation:      query.Description,
+			Category:            query.RecommendationControl,
+			Impact:              query.RecommendationImpact,
+			LearnMoreLink:       query.LearnMoreLink,
+			GraphQuery:          query.Query,
+			Source:              config.Name,
+			MetadataState:       query.RecommendationMetadataState,
+			LongDescription:     query.LongDescription,
+			PotentialBenefits:   query.PotentialBenefits,
+			PgVerified:          query.PgVerified,
+			AutomationAvailable: automationAvailable,
+			Tags:                query.Tags,
+		}
+		recommendations = append(recommendations, recommendation)
+	}
+
+	plugin := &Plugin{
+		Metadata: PluginMetadata{
+			Name:        config.Name,
+			Version:     config.Version,
+			Description: config.Description,
+			Author:      config.Author,
+			License:     config.License,
+			Type:        PluginTypeYaml,
+			CommandPath: filePath,
+		},
+		YamlRecommendations: recommendations,
+	}
+
+	return plugin, recommendations, nil
+}
+
+// discoverYamlPlugins searches for YAML plugins in configured directories
+func discoverYamlPlugins(dirs []string) ([]*Plugin, error) {
+	plugins := make([]*Plugin, 0)
+	seen := make(map[string]bool)
+
+	for _, dir := range dirs {
+		// Check if directory exists
+		info, err := os.Stat(dir)
+		if err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return nil, fmt.Errorf("failed to stat plugin directory %s: %w", dir, err)
+		}
+
+		if !info.IsDir() {
+			continue
+		}
+
+		// Walk directory looking for .yaml or .yml files
+		err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return nil // Skip files we can't access
+			}
+
+			if info.IsDir() {
+				return nil
+			}
+
+			// Only process .yaml and .yml files
+			ext := filepath.Ext(path)
+			if ext != ".yaml" && ext != ".yml" {
+				return nil
+			}
+
+			// Try to load as plugin
+			plugin, recommendations, err := LoadYamlPlugin(path)
+			if err != nil {
+				log.Debug().
+					Err(err).
+					Str("path", path).
+					Msg("Skipping file - not a valid YAML plugin")
+				return nil
+			}
+
+			// Skip duplicates (first found wins)
+			if seen[plugin.Metadata.Name] {
+				log.Debug().
+					Str("plugin", plugin.Metadata.Name).
+					Str("path", path).
+					Msg("Skipping duplicate YAML plugin")
+				return nil
+			}
+			seen[plugin.Metadata.Name] = true
+
+			plugins = append(plugins, plugin)
+			log.Debug().
+				Str("plugin", plugin.Metadata.Name).
+				Str("path", path).
+				Int("queries", len(recommendations)).
+				Msg("Discovered YAML plugin")
+
+			return nil
+		})
+
+		if err != nil {
+			log.Warn().
+				Err(err).
+				Str("dir", dir).
+				Msg("Error walking plugin directory")
+		}
+	}
+
+	return plugins, nil
+}
diff --git a/internal/renderers/csv/csv.go b/internal/renderers/csv/csv.go
index 140238b..aedbc33 100644
--- a/internal/renderers/csv/csv.go
+++ b/internal/renderers/csv/csv.go
@@ -46,6 +46,14 @@ func CreateCsvReport(data *renderers.ReportData) {
 
 	records = data.ExcludedResourcesTable()
 	writeData(records, data.OutputFileName, "outofscope")
+
+	// Render external plugin results
+	for _, result := range data.PluginResults {
+		if len(result.Table) > 0 {
+			// Use plugin name for the CSV filename extension
+			writeData(result.Table, data.OutputFileName, fmt.Sprintf("plugin_%s", result.PluginName))
+		}
+	}
 }
 
 func writeData(data [][]string, fileName, extension string) {
diff --git a/internal/renderers/excel/excel.go b/internal/renderers/excel/excel.go
index ebea8d2..1f8b568 100644
--- a/internal/renderers/excel/excel.go
+++ b/internal/renderers/excel/excel.go
@@ -34,6 +34,7 @@ func CreateExcelReport(data *renderers.ReportData) {
 	renderDefender(f, data)
 	renderExcludedResources(f, data)
 	renderCosts(f, data)
+	renderExternalPlugins(f, data)
 
 	if err := f.SaveAs(filename); err != nil {
 		log.Fatal().Err(err).Msg("Failed to save Excel file")
@@ -198,3 +199,154 @@ func applyBlueStyle(f *excelize.File, sheet string, lastRow int, columns int) {
 		}
 	}
 }
+
+// renderExternalPlugins creates Excel sheets for external plugin results
+func renderExternalPlugins(f *excelize.File, data *renderers.ReportData) {
+	if len(data.PluginResults) == 0 {
+		return
+	}
+
+	// Create styles
+	headerStyle, err := f.NewStyle(&excelize.Style{
+		Font: &excelize.Font{
+			Bold: true,
+		},
+		Fill: excelize.Fill{
+			Type:    "pattern",
+			Color:   []string{"#CAEDFB"},
+			Pattern: 1,
+		},
+	})
+	if err != nil {
+		log.Error().Err(err).Msg("Failed to create header style")
+		return
+	}
+
+	blueStyle, err := f.NewStyle(&excelize.Style{
+		Fill: excelize.Fill{
+			Type:    "pattern",
+			Color:   []string{"#DDEBF7"},
+			Pattern: 1,
+		},
+		Alignment: &excelize.Alignment{
+			Vertical: "top",
+			WrapText: true,
+		},
+	})
+	if err != nil {
+		log.Error().Err(err).Msg("Failed to create blue style")
+		return
+	}
+
+	whiteStyle, err := f.NewStyle(&excelize.Style{
+		Alignment: &excelize.Alignment{
+			Vertical: "top",
+			WrapText: true,
+		},
+	})
+	if err != nil {
+		log.Error().Err(err).Msg("Failed to create white style")
+		return
+	}
+
+	for _, result := range data.PluginResults {
+		// Use the plugin-specified sheet name
+		sheetName := result.SheetName
+
+		// Create the sheet
+		_, err := f.NewSheet(sheetName)
+		if err != nil {
+			log.Error().Err(err).Msgf("Failed to create sheet for plugin %s", result.PluginName)
+			continue
+		}
+
+		// Render the table data starting at row 4 (like other renderers)
+		if len(result.Table) == 0 {
+			// No data to render
+			continue
+		}
+
+		currentRow := 4
+
+		// Write all rows (headers and data)
+		for rowIdx, row := range result.Table {
+			for colIdx, cellValue := range row {
+				cell, err := excelize.CoordinatesToCellName(colIdx+1, currentRow+rowIdx)
+				if err != nil {
+					log.Error().Err(err).Msgf("Failed to get cell coordinates for plugin %s", result.PluginName)
+					continue
+				}
+				err = f.SetCellValue(sheetName, cell, cellValue)
+				if err != nil {
+					log.Error().Err(err).Msgf("Failed to set cell value for plugin %s", result.PluginName)
+				}
+			}
+		}
+
+		// Apply header style to first row of table
+		if len(result.Table) > 0 {
+			numColumns := len(result.Table[0])
+			numRows := len(result.Table)
+
+			for colIdx := 1; colIdx <= numColumns; colIdx++ {
+				cell, err := excelize.CoordinatesToCellName(colIdx, currentRow)
+				if err != nil {
+					continue
+				}
+				err = f.SetCellStyle(sheetName, cell, cell, headerStyle)
+				if err != nil {
+					log.Error().Err(err).Msgf("Failed to set header style for plugin %s", result.PluginName)
+				}
+			}
+
+			// Apply alternating row colors to data rows
+			for rowIdx := 1; rowIdx < numRows; rowIdx++ { // Start from 1 to skip header
+				style := blueStyle
+				if rowIdx%2 == 0 {
+					style = whiteStyle
+				}
+				for colIdx := 1; colIdx <= numColumns; colIdx++ {
+					cell, err := excelize.CoordinatesToCellName(colIdx, currentRow+rowIdx)
+					if err != nil {
+						continue
+					}
+					err = f.SetCellStyle(sheetName, cell, cell, style)
+					if err != nil {
+						log.Error().Err(err).Msgf("Failed to set cell style for plugin %s", result.PluginName)
+					}
+				}
+			}
+
+			// Autofit columns
+			_ = autofit(f, sheetName)
+
+			// Add autofilter
+			lastCell, err := excelize.CoordinatesToCellName(numColumns, currentRow+numRows-1)
+			if err != nil {
+				log.Error().Err(err).Msgf("Failed to get last cell for plugin %s", result.PluginName)
+			} else {
+				err = f.AutoFilter(sheetName, fmt.Sprintf("A4:%s", lastCell), nil)
+				if err != nil {
+					log.Error().Err(err).Msgf("Failed to set autofilter for plugin %s", result.PluginName)
+				}
+			}
+
+			// Add logo
+			logo := embeded.GetTemplates("azqr.png")
+			opt := &excelize.GraphicOptions{
+				ScaleX:      1,
+				ScaleY:      1,
+				Positioning: "absolute",
+				AltText:     "azqr logo",
+			}
+			pic := &excelize.Picture{
+				Extension: ".png",
+				File:      logo,
+				Format:    opt,
+			}
+			if err := f.AddPictureFromBytes(sheetName, "A1", pic); err != nil {
+				log.Error().Err(err).Msgf("Failed to add logo for plugin %s", result.PluginName)
+			}
+		}
+	}
+}
diff --git a/internal/renderers/json/json.go b/internal/renderers/json/json.go
index 40fa8f6..49329f9 100644
--- a/internal/renderers/json/json.go
+++ b/internal/renderers/json/json.go
@@ -31,6 +31,20 @@ func CreateJsonReport(data *renderers.ReportData) {
 		"outOfScope":              convertToJSON(data.ExcludedResourcesTable()),
 	}
 
+	// Add external plugin results
+	if len(data.PluginResults) > 0 {
+		plugins := make(map[string]interface{})
+		for _, result := range data.PluginResults {
+			pluginData := map[string]interface{}{
+				"description": result.Description,
+				"sheetName":   result.SheetName,
+				"data":        convertToJSON(result.Table),
+			}
+			plugins[result.PluginName] = pluginData
+		}
+		consolidatedReport["externalPlugins"] = plugins
+	}
+
 	// Write consolidated JSON to single file
 	writeData(consolidatedReport, filename)
 }
diff --git a/internal/renderers/report_data.go b/internal/renderers/report_data.go
index 1d10746..00685b2 100644
--- a/internal/renderers/report_data.go
+++ b/internal/renderers/report_data.go
@@ -26,6 +26,15 @@ type (
 		Resources               []*models.Resource
 		ExludedResources        []*models.Resource
 		ResourceTypeCount       []models.ResourceTypeCount
+		PluginResults           []PluginResult // Results from external plugins
+	}
+
+	// PluginResult represents data from an external plugin
+	PluginResult struct {
+		PluginName  string     // Name of the plugin
+		SheetName   string     // Name for Excel sheet
+		Description string     // Description of the data
+		Table       [][]string // Table data (first row is headers)
 	}
 
 	ResourceTypeCountResults struct {
diff --git a/internal/scanner.go b/internal/scanner.go
index 47c0520..bb8c03b 100644
--- a/internal/scanner.go
+++ b/internal/scanner.go
@@ -12,6 +12,7 @@ import (
 	"github.com/Azure/azqr/internal/az"
 	"github.com/Azure/azqr/internal/graph"
 	"github.com/Azure/azqr/internal/models"
+	"github.com/Azure/azqr/internal/plugins"
 	"github.com/Azure/azqr/internal/renderers"
 	"github.com/Azure/azqr/internal/renderers/csv"
 	"github.com/Azure/azqr/internal/renderers/excel"
@@ -115,6 +116,7 @@ type (
 		Filters                *models.Filters
 		UseAzqrRecommendations bool
 		UseAprlRecommendations bool
+		EnabledInternalPlugins map[string]bool
 	}
 
 	Scanner struct{}
@@ -236,6 +238,21 @@ func (sc Scanner) Scan(params *ScanParams) string {
 	}
 
 	aprlScanner := graph.NewAprlScanner(serviceScanners, filters, subscriptions)
+
+	// Register YAML plugin recommendations with the APRL scanner
+	yamlPluginRegistry := plugins.GetRegistry()
+	for _, plugin := range yamlPluginRegistry.List() {
+		if len(plugin.YamlRecommendations) > 0 {
+			log.Info().
+				Str("plugin", plugin.Metadata.Name).
+				Int("queries", len(plugin.YamlRecommendations)).
+				Msg("Registering YAML plugin queries with APRL scanner")
+			for _, rec := range plugin.YamlRecommendations {
+				aprlScanner.RegisterExternalQuery(rec.ResourceType, rec)
+			}
+		}
+	}
+
 	reportData.Recommendations, _ = aprlScanner.ListRecommendations()
 
 	resourceTypes := resourceScanner.GetCountPerResourceType(ctx, cred, subscriptions, filters)
@@ -261,10 +278,83 @@ func (sc Scanner) Scan(params *ScanParams) string {
 		}
 	}
 
-	// get the APRL scan results
+	// Get plugin scanners - they will be executed separately
+	pluginRegistry := plugins.GetRegistry()
+	registeredPlugins := pluginRegistry.List()
+	log.Info().Msgf("Found %d registered plugins", len(registeredPlugins))
+	var internalPluginScanners []plugins.InternalPluginScanner
+	for _, plugin := range registeredPlugins {
+		if plugin.InternalScanner != nil {
+			log.Info().
+				Str("plugin", plugin.Metadata.Name).
+				Str("type", "internal").
+				Str("version", plugin.Metadata.Version).
+				Msg("Internal plugin ready for execution")
+			internalPluginScanners = append(internalPluginScanners, plugin.InternalScanner)
+		}
+	}
+
+	// get the APRL scan results (built-in APRL recommendations only)
 	aprlScanner = graph.NewAprlScanner(filteredServiceScanners, filters, subscriptions)
+
+	// Register YAML plugin recommendations with the APRL scanner
+	for _, plugin := range yamlPluginRegistry.List() {
+		if len(plugin.YamlRecommendations) > 0 {
+			for _, rec := range plugin.YamlRecommendations {
+				aprlScanner.RegisterExternalQuery(rec.ResourceType, rec)
+			}
+		}
+	}
+
 	reportData.Aprl = aprlScanner.Scan(ctx, cred)
 
+	// Execute internal plugin scanners (only if enabled)
+	if len(internalPluginScanners) > 0 {
+		for _, internalScanner := range internalPluginScanners {
+			pluginName := internalScanner.GetMetadata().Name
+			// Check if plugin is enabled (default: false)
+			enabled := false
+			if params.EnabledInternalPlugins != nil {
+				enabled = params.EnabledInternalPlugins[pluginName]
+			}
+
+			if !enabled {
+				log.Info().
+					Str("plugin", pluginName).
+					Msg("Internal plugin skipped (not enabled)")
+				continue
+			}
+
+			log.Info().
+				Str("plugin", pluginName).
+				Str("type", "internal").
+				Str("version", internalScanner.GetMetadata().Version).
+				Msg("Executing internal plugin")
+
+			output, err := internalScanner.Scan(ctx, cred, subscriptions, filters)
+			if err != nil {
+				log.Error().
+					Err(err).
+					Str("plugin", pluginName).
+					Msg("Internal plugin execution failed")
+				continue
+			}
+
+			// Add internal plugin results to report data
+			reportData.PluginResults = append(reportData.PluginResults, renderers.PluginResult{
+				PluginName:  pluginName,
+				SheetName:   output.SheetName,
+				Description: output.Description,
+				Table:       output.Table,
+			})
+
+			log.Info().
+				Str("plugin", pluginName).
+				Int("rows", len(output.Table)-1).
+				Msg("Internal plugin completed")
+		}
+	}
+
 	// get the count of resources per resource type
 	reportData.ResourceTypeCount = resourceScanner.GetCountPerResourceTypeAndSubscription(ctx, cred, subscriptions, reportData.Recommendations, filters)
 
@@ -322,7 +412,6 @@ func (sc Scanner) Scan(params *ScanParams) string {
 				PublicIPs:           pips,
 			}
 
-			// scan each resource group
 			ch := make(chan []models.AzqrServiceResult, len(filteredServiceScanners))
 
 			for _, s := range filteredServiceScanners {
@@ -448,3 +537,4 @@ func (sc Scanner) generateOutputFileName(outputName string) string {
 	}
 	return outputFile
 }
+
diff --git a/internal/scanners/plugins/carbon/emissions.go b/internal/scanners/plugins/carbon/emissions.go
new file mode 100644
index 0000000..a135d45
--- /dev/null
+++ b/internal/scanners/plugins/carbon/emissions.go
@@ -0,0 +1,217 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package carbon
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/Azure/azqr/internal/plugins"
+	"github.com/Azure/azqr/internal/throttling"
+	"github.com/Azure/azqr/internal/to"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+	"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/carbonoptimization/armcarbonoptimization"
+	"github.com/rs/zerolog/log"
+)
+
+// EmissionsScanner is an internal plugin that scans carbon emissions
+type EmissionsScanner struct{}
+
+// NewEmissionsScanner creates a new carbon emissions scanner
+func NewEmissionsScanner() *EmissionsScanner {
+	return &EmissionsScanner{}
+}
+
+// GetMetadata returns plugin metadata
+func (s *EmissionsScanner) GetMetadata() plugins.PluginMetadata {
+	return plugins.PluginMetadata{
+		Name:        "carbon-emissions",
+		Version:     "1.0.0",
+		Description: "Analyzes carbon emissions by Azure resource type",
+		Author:      "Azure Quick Review Team",
+		License:     "MIT",
+		Type:        plugins.PluginTypeInternal,
+		ColumnMetadata: []plugins.ColumnMetadata{
+			{Name: "Period From", DataKey: "periodFrom", FilterType: plugins.FilterTypeSearch},
+			{Name: "Period To", DataKey: "periodTo", FilterType: plugins.FilterTypeSearch},
+			{Name: "Resource Type", DataKey: "resourceType", FilterType: plugins.FilterTypeDropdown},
+			{Name: "Latest Month Emissions", DataKey: "latestMonthEmissions", FilterType: plugins.FilterTypeNone},
+			{Name: "Previous Month Emissions", DataKey: "previousMonthEmissions", FilterType: plugins.FilterTypeNone},
+			{Name: "Month-over-Month Change Ratio", DataKey: "monthOverMonthChangeRatio", FilterType: plugins.FilterTypeNone},
+			{Name: "Monthly Change Value", DataKey: "monthlyChangeValue", FilterType: plugins.FilterTypeNone},
+			{Name: "Unit", DataKey: "unit", FilterType: plugins.FilterTypeNone},
+		},
+	}
+}
+
+// aggregatedEmissions holds the aggregated emission values for a resource type
+type aggregatedEmissions struct {
+	latestMonth        float64
+	previousMonth      float64
+	monthlyChangeValue float64
+}
+
+// Scan executes the plugin and returns table data
+func (s *EmissionsScanner) Scan(ctx context.Context, cred azcore.TokenCredential, subscriptions map[string]string, filters *models.Filters) (*plugins.ExternalPluginOutput, error) {
+	log.Info().Msg("Scanning carbon emissions across subscriptions")
+
+	now := time.Now().UTC()
+	firstOfCurrentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
+	toTime := firstOfCurrentMonth.AddDate(0, -1, 0)
+	fromTime := toTime
+
+	// Initialize client options
+	clientOptions := &arm.ClientOptions{
+		ClientOptions: policy.ClientOptions{},
+	}
+
+	// Initialize the client factory
+	clientFactory, err := armcarbonoptimization.NewClientFactory(cred, clientOptions)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create carbon optimization client factory: %w", err)
+	}
+
+	// Build subscription list
+	subscriptionList := make([]*string, 0, len(subscriptions))
+	for subID := range subscriptions {
+		subscriptionList = append(subscriptionList, to.Ptr(subID))
+	}
+
+	// Process subscriptions in batches of 100 to avoid API limits
+	const batchSize = 100
+	aggregatedResults := make(map[string]*aggregatedEmissions)
+
+	for i := 0; i < len(subscriptionList); i += batchSize {
+		end := i + batchSize
+		if end > len(subscriptionList) {
+			end = len(subscriptionList)
+		}
+		batch := subscriptionList[i:end]
+
+		log.Info().Msgf("Processing carbon emissions for batch %d-%d of %d subscriptions", i+1, end, len(subscriptionList))
+
+		// Create the carbon query filter for ResourceType category
+		queryFilter := &armcarbonoptimization.ItemDetailsQueryFilter{
+			ReportType:       to.Ptr(armcarbonoptimization.ReportTypeEnumItemDetailsReport),
+			SubscriptionList: batch,
+			CarbonScopeList: []*armcarbonoptimization.EmissionScopeEnum{
+				to.Ptr(armcarbonoptimization.EmissionScopeEnumScope1),
+				to.Ptr(armcarbonoptimization.EmissionScopeEnumScope2),
+				to.Ptr(armcarbonoptimization.EmissionScopeEnumScope3),
+			},
+			DateRange: &armcarbonoptimization.DateRange{
+				Start: to.Ptr(fromTime),
+				End:   to.Ptr(toTime),
+			},
+			CategoryType:  to.Ptr(armcarbonoptimization.CategoryTypeEnumResourceType),
+			OrderBy:       to.Ptr(armcarbonoptimization.OrderByColumnEnumLatestMonthEmissions),
+			SortDirection: to.Ptr(armcarbonoptimization.SortDirectionEnumDesc),
+			PageSize:      to.Ptr[int32](1000),
+		}
+
+		// Wait for rate limiter
+		<-throttling.ARMLimiter
+
+		// Execute the request
+		resp, err := clientFactory.NewCarbonServiceClient().QueryCarbonEmissionReports(ctx, queryFilter, nil)
+		if err != nil {
+			// Carbon API might not be available in all subscriptions
+			log.Info().Err(err).Msgf("Carbon emissions data not available for batch %d-%d", i+1, end)
+			continue
+		}
+
+		// Parse the response data
+		if resp.Value != nil {
+			log.Info().Msgf("Received %d carbon emission items in response", len(resp.Value))
+			for _, item := range resp.Value {
+				// Cast to CarbonEmissionItemDetailData
+				if detailData, ok := item.(*armcarbonoptimization.CarbonEmissionItemDetailData); ok {
+					if detailData.ItemName != nil && detailData.LatestMonthEmissions != nil {
+						resourceType := *detailData.ItemName
+
+						// Check if the item should be filtered
+						if filters.Azqr.IsResourceTypeExcluded(resourceType) {
+							continue
+						}
+
+						// Initialize aggregation entry if it doesn't exist
+						if aggregatedResults[resourceType] == nil {
+							aggregatedResults[resourceType] = &aggregatedEmissions{}
+						}
+
+						agg := aggregatedResults[resourceType]
+
+						// Aggregate latest month emissions
+						agg.latestMonth += *detailData.LatestMonthEmissions
+
+						// Add optional fields
+						if detailData.PreviousMonthEmissions != nil {
+							agg.previousMonth += *detailData.PreviousMonthEmissions
+						}
+						if detailData.MonthlyEmissionsChangeValue != nil {
+							agg.monthlyChangeValue += *detailData.MonthlyEmissionsChangeValue
+						}
+					}
+				}
+			}
+		}
+	}
+
+	// Initialize table with headers
+	table := [][]string{
+		{"Period From", "Period To", "Resource Type", "Latest Month Emissions", "Previous Month Emissions", "Month-over-Month Change Ratio", "Monthly Change Value", "Unit"},
+	}
+
+	// Convert aggregated results to table rows
+	for resourceType, agg := range aggregatedResults {
+		row := []string{
+			fromTime.Format("2006-01-02"),
+			toTime.Format("2006-01-02"),
+			resourceType,
+			fmt.Sprintf("%.2f", agg.latestMonth),
+		}
+
+		// Add optional fields
+		if agg.previousMonth > 0 {
+			row = append(row, fmt.Sprintf("%.2f", agg.previousMonth))
+		} else {
+			row = append(row, "")
+		}
+
+		if agg.previousMonth != 0 {
+			avgRatio := (agg.latestMonth - agg.previousMonth) / agg.previousMonth
+			row = append(row, fmt.Sprintf("%.2f%%", avgRatio*100))
+		} else {
+			row = append(row, "")
+		}
+
+		if agg.monthlyChangeValue != 0 {
+			row = append(row, fmt.Sprintf("%.2f", agg.monthlyChangeValue))
+		} else {
+			row = append(row, "")
+		}
+
+		row = append(row, "kgCO2e") // kilograms of CO2 equivalent
+
+		table = append(table, row)
+	}
+
+	log.Info().Msgf("Carbon emissions scan completed with %d resource types", len(aggregatedResults))
+
+	return &plugins.ExternalPluginOutput{
+		Metadata:    s.GetMetadata(),
+		SheetName:   "Carbon Emissions",
+		Description: "Analysis of carbon emissions by Azure resource type for the previous month",
+		Table:       table,
+	}, nil
+}
+
+// init registers the plugin automatically
+func init() {
+	plugins.RegisterInternalPlugin("carbon-emissions", NewEmissionsScanner())
+}
diff --git a/internal/scanners/plugins/openai/throttling.go b/internal/scanners/plugins/openai/throttling.go
new file mode 100644
index 0000000..0ec8232
--- /dev/null
+++ b/internal/scanners/plugins/openai/throttling.go
@@ -0,0 +1,258 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package openai
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/Azure/azqr/internal/plugins"
+	"github.com/Azure/azqr/internal/throttling"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
+	"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices"
+	"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/monitor/armmonitor"
+)
+
+// ThrottlingScanner is an internal plugin that monitors OpenAI throttling
+type ThrottlingScanner struct{}
+
+// NewThrottlingScanner creates a new OpenAI throttling scanner
+func NewThrottlingScanner() *ThrottlingScanner {
+	return &ThrottlingScanner{}
+}
+
+// GetMetadata returns plugin metadata
+func (s *ThrottlingScanner) GetMetadata() plugins.PluginMetadata {
+	return plugins.PluginMetadata{
+		Name:        "openai-throttling",
+		Version:     "1.0.0",
+		Description: "Checks OpenAI/Cognitive Services accounts for 429 throttling errors",
+		Author:      "Azure Quick Review Team",
+		License:     "MIT",
+		Type:        plugins.PluginTypeInternal,
+		ColumnMetadata: []plugins.ColumnMetadata{
+			{Name: "Subscription", DataKey: "subscription", FilterType: plugins.FilterTypeSearch},
+			{Name: "Resource Group", DataKey: "resourceGroup", FilterType: plugins.FilterTypeSearch},
+			{Name: "Account Name", DataKey: "accountName", FilterType: plugins.FilterTypeSearch},
+			{Name: "Kind", DataKey: "kind", FilterType: plugins.FilterTypeDropdown},
+			{Name: "SKU", DataKey: "sku", FilterType: plugins.FilterTypeDropdown},
+			{Name: "Hour", DataKey: "hour", FilterType: plugins.FilterTypeSearch},
+			{Name: "Model Name", DataKey: "modelName", FilterType: plugins.FilterTypeDropdown},
+			{Name: "Status Code", DataKey: "statusCode", FilterType: plugins.FilterTypeDropdown},
+			{Name: "Request Count", DataKey: "requestCount", FilterType: plugins.FilterTypeNone},
+		},
+	}
+}
+
+// Scan executes the plugin and returns table data
+func (s *ThrottlingScanner) Scan(ctx context.Context, cred azcore.TokenCredential, subscriptions map[string]string, filters *models.Filters) (*plugins.ExternalPluginOutput, error) {
+	// Initialize table with headers
+	table := [][]string{
+		{"Subscription", "Resource Group", "Account Name", "Kind", "SKU", "Hour", "Model Name", "Status Code", "Request Count"},
+	}
+
+	// Scan all subscriptions
+	for subID, subName := range subscriptions {
+		results, err := s.scanSubscription(ctx, cred, subID, subName)
+		if err != nil {
+			// Skip subscriptions with errors but don't fail the entire scan
+			continue
+		}
+		table = append(table, results...)
+	}
+
+	return &plugins.ExternalPluginOutput{
+		Metadata:    s.GetMetadata(),
+		SheetName:   "OpenAI Throttling",
+		Description: "Analysis of throttling errors for OpenAI/Cognitive Services accounts by hour, model, and status code",
+		Table:       table,
+	}, nil
+}
+
+// scanSubscription scans a single subscription for OpenAI accounts
+func (s *ThrottlingScanner) scanSubscription(ctx context.Context, cred azcore.TokenCredential, subscriptionID, subscriptionName string) ([][]string, error) {
+	clientOptions := &policy.ClientOptions{}
+
+	// Create Cognitive Services client
+	cognitiveClient, err := armcognitiveservices.NewAccountsClient(subscriptionID, cred, &arm.ClientOptions{
+		ClientOptions: *clientOptions,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to create cognitive services client: %w", err)
+	}
+
+	// Create Monitor metrics client
+	metricsClient, err := armmonitor.NewMetricsClient(subscriptionID, cred, &arm.ClientOptions{
+		ClientOptions: *clientOptions,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to create metrics client: %w", err)
+	}
+
+	// List all Cognitive Services accounts
+	pager := cognitiveClient.NewListPager(nil)
+	var results [][]string
+
+	for pager.More() {
+		// Wait for rate limiter
+		<-throttling.ARMLimiter
+
+		page, err := pager.NextPage(ctx)
+		if err != nil {
+			return nil, fmt.Errorf("failed to list accounts: %w", err)
+		}
+
+		for _, account := range page.Value {
+			if account.ID == nil || account.Name == nil {
+				continue
+			}
+
+			// Only check OpenAI accounts
+			if account.Kind == nil || !strings.Contains(strings.ToLower(*account.Kind), "openai") {
+				continue
+			}
+
+			// Get metrics for the last 7 days
+			hourlyMetrics, err := s.getMetricsWithStatusCodeSplit(ctx, metricsClient, *account.ID, 167)
+			if err != nil {
+				// Skip accounts with metrics errors but don't fail the entire scan
+				continue
+			}
+
+			// Extract resource group from ID
+			resourceGroup := extractResourceGroup(*account.ID)
+
+			// Get SKU name
+			skuName := "Unknown"
+			if account.SKU != nil && account.SKU.Name != nil {
+				skuName = string(*account.SKU.Name)
+			}
+
+			kind := "Unknown"
+			if account.Kind != nil {
+				kind = *account.Kind
+			}
+
+			// Create one row per hour per model per status code
+			for hourKey, models := range hourlyMetrics {
+				for modelName, statusCodes := range models {
+					for statusCode, count := range statusCodes {
+						results = append(results, []string{
+							subscriptionName,
+							resourceGroup,
+							*account.Name,
+							kind,
+							skuName,
+							hourKey,
+							modelName,
+							statusCode,
+							fmt.Sprintf("%.0f", count),
+						})
+					}
+				}
+			}
+		}
+	}
+
+	return results, nil
+}
+
+// getMetricsWithStatusCodeSplit queries Azure Monitor for request metrics split by status code and model
+func (s *ThrottlingScanner) getMetricsWithStatusCodeSplit(ctx context.Context, client *armmonitor.MetricsClient, resourceID string, hours int) (map[string]map[string]map[string]float64, error) {
+	endTime := time.Now().UTC()
+	startTime := endTime.Add(-time.Duration(hours) * time.Hour)
+	timespan := fmt.Sprintf("%s/%s",
+		startTime.Format("2006-01-02T15:04:05Z"),
+		endTime.Format("2006-01-02T15:04:05Z"))
+
+	metricNames := "AzureOpenAIRequests"
+	interval := "PT1H"
+
+	// Use wildcard filter to get all status codes and model names
+	filter := "StatusCode eq '*' and ModelName eq '*'"
+
+	result, err := client.List(ctx, resourceID, &armmonitor.MetricsClientListOptions{
+		Timespan:    &timespan,
+		Interval:    &interval,
+		Metricnames: &metricNames,
+		Filter:      &filter,
+		Aggregation: to.Ptr("Count"),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to get metrics: %w", err)
+	}
+
+	// Aggregate by hour, model name, and status code: map[hour]map[modelName]map[statusCode]count
+	hourlyData := make(map[string]map[string]map[string]float64)
+
+	for _, metric := range result.Value {
+		if metric.Timeseries == nil {
+			continue
+		}
+		for _, timeseries := range metric.Timeseries {
+			// Extract status code and model name from metadata
+			statusCode := "Unknown"
+			modelName := "Unknown"
+			if len(timeseries.Metadatavalues) > 0 {
+				for _, meta := range timeseries.Metadatavalues {
+					if meta.Name != nil && meta.Value != nil {
+						if meta.Name.Value != nil {
+							if strings.EqualFold(*meta.Name.Value, "StatusCode") {
+								statusCode = *meta.Value
+							} else if strings.EqualFold(*meta.Name.Value, "ModelName") {
+								modelName = *meta.Value
+							}
+						}
+					}
+				}
+			}
+
+			// Process each data point (hourly)
+			if timeseries.Data != nil {
+				for _, data := range timeseries.Data {
+					if data.TimeStamp != nil {
+						// Format hour as readable string
+						hourKey := data.TimeStamp.Format("2006-01-02 15:00")
+
+						if hourlyData[hourKey] == nil {
+							hourlyData[hourKey] = make(map[string]map[string]float64)
+						}
+						if hourlyData[hourKey][modelName] == nil {
+							hourlyData[hourKey][modelName] = make(map[string]float64)
+						}
+						// Use Count field (which we requested via Aggregation)
+						if data.Count != nil {
+							hourlyData[hourKey][modelName][statusCode] += *data.Count
+						}
+					}
+				}
+			}
+		}
+	}
+
+	return hourlyData, nil
+}
+
+// extractResourceGroup extracts the resource group name from an Azure resource ID
+func extractResourceGroup(resourceID string) string {
+	parts := strings.Split(resourceID, "/")
+	for i, part := range parts {
+		if strings.EqualFold(part, "resourceGroups") && i+1 < len(parts) {
+			return parts[i+1]
+		}
+	}
+	return "Unknown"
+}
+
+// init registers this plugin with the internal plugin registry
+func init() {
+	scanner := NewThrottlingScanner()
+	plugins.RegisterInternalPlugin("openai-throttling", scanner)
+}
diff --git a/internal/viewer/datastore.go b/internal/viewer/datastore.go
index 4c5311e..28583ae 100644
--- a/internal/viewer/datastore.go
+++ b/internal/viewer/datastore.go
@@ -12,6 +12,8 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+
+	"github.com/Azure/azqr/internal/plugins"
 )
 
 // Dataset constants as produced by the JSON renderer.
@@ -29,9 +31,21 @@ const (
 	DataSetOutOfScope              = "outOfScope"
 )
 
+// PluginDataset represents a plugin's data with metadata from the plugin registry
+type PluginDataset struct {
+	Name        string              `json:"name"`
+	DisplayName string              `json:"displayName"`
+	Description string              `json:"description"`
+	Version     string              `json:"version"`
+	Author      string              `json:"author,omitempty"`
+	Columns     []map[string]string `json:"columns"` // {"name": "...", "filterType": "..."}
+	Data        []map[string]string `json:"-"`       // Not exposed in metadata API
+}
+
 // DataStore holds all report datasets in memory.
 type DataStore struct {
-	Data map[string][]map[string]string
+	Data    map[string][]map[string]string
+	Plugins []PluginDataset
 }
 
 // LoadDataStore loads a consolidated azqr JSON or Excel report.
@@ -77,8 +91,57 @@ func loadJSONDataStore(path string) (*DataStore, error) {
 		return nil, fmt.Errorf("unmarshal json: %w", err)
 	}
 
-	ds := &DataStore{Data: map[string][]map[string]string{}}
+	ds := &DataStore{Data: map[string][]map[string]string{}, Plugins: []PluginDataset{}}
 	for k, v := range raw {
+		// Handle externalPlugins object specially
+		if k == "externalPlugins" {
+			pluginsObj, ok := v.(map[string]interface{})
+			if ok {
+				for pluginName, pluginDataRaw := range pluginsObj {
+					pluginObj, ok := pluginDataRaw.(map[string]interface{})
+					if !ok {
+						continue
+					}
+					// Extract the data array from the plugin object
+					dataArray, ok := pluginObj["data"].([]interface{})
+					if !ok {
+						continue
+					}
+					// Convert to records
+					records := make([]map[string]string, 0, len(dataArray))
+					for _, row := range dataArray {
+						obj, ok := row.(map[string]interface{})
+						if !ok {
+							continue
+						}
+						rec := map[string]string{}
+						for key, val := range obj {
+							rec[key] = toString(val)
+						}
+						records = append(records, rec)
+					}
+					// Extract column metadata if present
+					var columns []map[string]string
+					if colMeta, ok := pluginObj["columnMetadata"].([]interface{}); ok {
+						for _, cm := range colMeta {
+							if colMap, ok := cm.(map[string]interface{}); ok {
+								columns = append(columns, map[string]string{
+									"name":       toString(colMap["name"]),
+									"filterType": toString(colMap["filterType"]),
+								})
+							}
+						}
+					}
+					// Create plugin dataset
+					plugin := ds.createPluginDataset(pluginName, pluginObj, records, columns)
+					ds.Plugins = append(ds.Plugins, plugin)
+					// Store in Data map with plugin_ prefix
+					ds.Data["plugin_"+pluginName] = records
+				}
+			}
+			continue
+		}
+
 		arr, ok := v.([]interface{})
 		if !ok {
 			continue
@@ -95,7 +158,18 @@ func loadJSONDataStore(path string) (*DataStore, error) {
 			}
 			records = append(records, rec)
 		}
-		ds.Data[k] = records
+
+		// Check if this is a plugin dataset (starts with "plugin_")
+		if strings.HasPrefix(k, "plugin_") {
+			pluginName := strings.TrimPrefix(k, "plugin_")
+			// Create minimal plugin dataset without metadata from JSON
+			plugin := ds.createPluginDataset(pluginName, nil, records, nil)
+			ds.Plugins = append(ds.Plugins, plugin)
+			// Also store in Data map for compatibility
+			ds.Data[k] = records
+		} else {
+			ds.Data[k] = records
+		}
 	}
 	return ds, nil
 }
@@ -202,6 +276,12 @@ func (ds *DataStore) Summary() map[string]interface{} {
 		}
 	}
 
+	// Count plugin results
+	pluginCounts := make(map[string]int)
+	for _, plugin := range ds.Plugins {
+		pluginCounts[plugin.Name] = len(plugin.Data)
+	}
+
 	return map[string]interface{}{
 		"recommendationsTotal":          len(recs),
 		"recommendationsImplemented":    implemented,
@@ -218,6 +298,7 @@ func (ds *DataStore) Summary() map[string]interface{} {
 		"costItems":                     len(costs),
 		"totalCost":                     totalCost,
 		"outOfScopeCount":               len(outOfScope),
+		"pluginCounts":                  pluginCounts,
 	}
 }
 
@@ -485,3 +566,111 @@ func toString(v interface{}) string {
 		return string(b)
 	}
 }
+
+// createPluginDataset creates a plugin dataset from metadata and data
+func (ds *DataStore) createPluginDataset(pluginName string, pluginObj map[string]interface{}, records []map[string]string, columns []map[string]string) PluginDataset {
+	// Default values
+	description := fmt.Sprintf("Results from %s plugin", toDisplayName(pluginName))
+	version := ""
+	author := ""
+
+	// Try to get metadata from plugin registry first
+	if plugin, exists := plugins.GetInternalPlugin(pluginName); exists {
+		metadata := plugin.GetMetadata()
+		description = metadata.Description
+		version = metadata.Version
+		author = metadata.Author
+
+		// Use column metadata from plugin registry if available
+		if len(metadata.ColumnMetadata) > 0 {
+			columns = make([]map[string]string, 0, len(metadata.ColumnMetadata))
+			for _, col := range metadata.ColumnMetadata {
+				// Use DataKey if provided, otherwise fall back to Name
+				dataKey := col.DataKey
+				if dataKey == "" {
+					dataKey = col.Name
+				}
+				columns = append(columns, map[string]string{
+					"name":       col.Name,
+					"dataKey":    dataKey,
+					"filterType": string(col.FilterType),
+				})
+			}
+		}
+	} else {
+		// Fall back to metadata from plugin object if plugin not in registry
+		if pluginObj != nil {
+			if desc, ok := pluginObj["description"].(string); ok && desc != "" {
+				description = desc
+			}
+			if vers, ok := pluginObj["version"].(string); ok {
+				version = vers
+			}
+			if auth, ok := pluginObj["author"].(string); ok {
+				author = auth
+			}
+		}
+
+		// If no column metadata from registry or object, infer from data
+		if len(columns) == 0 && len(records) > 0 {
+			for key := range records[0] {
+				columns = append(columns, map[string]string{
+					"name":       key,
+					"filterType": inferFilterType(key, records),
+				})
+			}
+		}
+	}
+
+	return PluginDataset{
+		Name:        pluginName,
+		DisplayName: toDisplayName(pluginName),
+		Description: description,
+		Version:     version,
+		Author:      author,
+		Columns:     columns,
+		Data:        records,
+	}
+}
+
+// inferFilterType determines the appropriate filter type for a column
+func inferFilterType(columnName string, records []map[string]string) string {
+	// Keywords that suggest no filtering needed
+	noFilterKeywords := []string{"description", "url", "link", "id", "guid"}
+	lowerName := strings.ToLower(columnName)
+	for _, keyword := range noFilterKeywords {
+		if strings.Contains(lowerName, keyword) {
+			return "none"
+		}
+	}
+
+	// Count unique values
+	uniqueValues := make(map[string]bool)
+	for _, rec := range records {
+		if val, ok := rec[columnName]; ok && val != "" {
+			uniqueValues[val] = true
+			// If more than 20 unique values, use search instead of dropdown
+			if len(uniqueValues) > 20 {
+				return "search"
+			}
+		}
+	}
+
+	// If few unique values, use dropdown; otherwise search
+	if len(uniqueValues) <= 20 && len(uniqueValues) > 1 {
+		return "dropdown"
+	} else if len(uniqueValues) > 1 {
+		return "search"
+	}
+
+	return "none"
+}
+
+// toDisplayName converts a plugin name to a display-friendly format
+func toDisplayName(name string) string {
+	// Replace hyphens and underscores with spaces
+	name = strings.ReplaceAll(name, "-", " ")
+	name = strings.ReplaceAll(name, "_", " ")
+
+	return name
+}
diff --git a/internal/viewer/excel_parser.go b/internal/viewer/excel_parser.go
index 0f6abe2..71dd3ba 100644
--- a/internal/viewer/excel_parser.go
+++ b/internal/viewer/excel_parser.go
@@ -8,6 +8,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/Azure/azqr/internal/plugins"
 	"github.com/xuri/excelize/v2"
 )
 
@@ -23,7 +24,7 @@ func ExcelToDataStore(path string) (*DataStore, error) {
 		_ = f.Close()
 	}()
 
-	ds := &DataStore{Data: map[string][]map[string]string{}}
+	ds := &DataStore{Data: map[string][]map[string]string{}, Plugins: []PluginDataset{}}
 
 	// Get all sheet names
 	sheets := f.GetSheetList()
@@ -31,8 +32,15 @@ func ExcelToDataStore(path string) (*DataStore, error) {
 	for _, sheetName := range sheets {
 		// Map sheet names to dataset names (matching azqr output conventions)
 		datasetName := mapSheetToDataset(sheetName)
+
+		// If not a known dataset, check if it's a plugin sheet
 		if datasetName == "" {
-			continue // Skip unknown sheets
+			// Try to detect plugin sheets (e.g., "Carbon Emissions", "OpenAI Throttling")
+			if isPluginSheet(sheetName) {
+				datasetName = "plugin_" + normalizePluginName(sheetName)
+			} else {
+				continue // Skip unknown sheets
+			}
 		}
 
 		// Get all rows from the sheet
@@ -70,6 +78,13 @@ func ExcelToDataStore(path string) (*DataStore, error) {
 			continue // No valid headers found
 		}
 
+		// For plugin sheets, try to get header mapping from plugin registry
+		var headerToDataKey map[string]string
+		if strings.HasPrefix(datasetName, "plugin_") {
+			pluginName := strings.TrimPrefix(datasetName, "plugin_")
+			headerToDataKey = getPluginHeaderMapping(pluginName)
+		}
+
 		// Process data rows (skip header row and any empty rows before it)
 		records := make([]map[string]string, 0, len(rows)-headerRowIndex-1)
 		for i := headerRowIndex + 1; i < len(rows); i++ {
@@ -87,9 +102,21 @@ func ExcelToDataStore(path string) (*DataStore, error) {
 					cellValue = strings.TrimSpace(row[j])
 				}
 
-				// Clean up header name (remove spaces, make consistent with JSON output)
-				cleanHeader := cleanHeaderName(header)
-				record[cleanHeader] = cellValue
+				// For plugin sheets, use the header mapping from plugin metadata
+				var dataKey string
+				if headerToDataKey != nil {
+					if mappedKey, exists := headerToDataKey[header]; exists {
+						dataKey = mappedKey
+					} else {
+						// Fallback to cleanHeaderName if no mapping found
+						dataKey = cleanHeaderName(header)
+					}
+				} else {
+					// For non-plugin sheets, use cleanHeaderName
+					dataKey = cleanHeaderName(header)
+				}
+
+				record[dataKey] = cellValue
 			}
 
 			// Only add non-empty records
@@ -100,6 +127,13 @@ func ExcelToDataStore(path string) (*DataStore, error) {
 
 		if len(records) > 0 {
 			ds.Data[datasetName] = records
+
+			// If this is a plugin dataset, also create plugin metadata
+			if strings.HasPrefix(datasetName, "plugin_") {
+				pluginName := strings.TrimPrefix(datasetName, "plugin_")
+				pluginDataset := ds.createPluginDataset(pluginName, nil, records, nil)
+				ds.Plugins = append(ds.Plugins, pluginDataset)
+			}
 		}
 	}
 
@@ -271,3 +305,65 @@ func cleanHeaderName(header string) string {
 
 	return result
 }
+
+// isPluginSheet determines if a sheet name is a plugin sheet by checking the plugin registry.
+func isPluginSheet(sheetName string) bool {
+	clean := strings.ToLower(strings.TrimSpace(sheetName))
+
+	// Known non-plugin patterns that should never be considered plugins
+	nonPluginPatterns := []string{
+		"sheet", "cover", "summary", "overview", "dashboard", "readme",
+	}
+
+	for _, pattern := range nonPluginPatterns {
+		if strings.Contains(clean, pattern) {
+			return false
+		}
+	}
+
+	// Normalize the sheet name to match plugin naming convention
+	normalizedName := normalizePluginName(sheetName)
+
+	// Check if a plugin with this name exists in the registry
+	if _, exists := plugins.GetInternalPlugin(normalizedName); exists {
+		return true
+	}
+
+	return false
+}
+
+// getPluginHeaderMapping returns a mapping from Excel header names to dataKeys
+// by consulting the plugin registry metadata
+func getPluginHeaderMapping(pluginName string) map[string]string {
+	plugin, exists := plugins.GetInternalPlugin(pluginName)
+	if !exists {
+		return nil
+	}
+
+	metadata := plugin.GetMetadata()
+	if len(metadata.ColumnMetadata) == 0 {
+		return nil
+	}
+
+	// Create mapping from column Name (display name) to DataKey
+	mapping := make(map[string]string)
+	for _, col := range metadata.ColumnMetadata {
+		dataKey := col.DataKey
+		if dataKey == "" {
+			dataKey = col.Name
+		}
+		mapping[col.Name] = dataKey
+	}
+
+	return mapping
+}
+
+// normalizePluginName converts a plugin sheet name to a normalized plugin identifier.
+// Example: "Carbon Emissions" -> "carbon-emissions", "OpenAI Throttling" -> "openai-throttling"
+func normalizePluginName(sheetName string) string {
+	// Convert to lowercase and replace spaces with hyphens
+	normalized := strings.ToLower(strings.TrimSpace(sheetName))
+	normalized = strings.ReplaceAll(normalized, " ", "-")
+	normalized = strings.ReplaceAll(normalized, "_", "-")
+	return normalized
+}
diff --git a/internal/viewer/server.go b/internal/viewer/server.go
index d0d4b79..694fd2c 100644
--- a/internal/viewer/server.go
+++ b/internal/viewer/server.go
@@ -9,6 +9,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"net/url"
 	"path"
 	"strings"
 	"time"
@@ -24,6 +25,32 @@ func NewHandler(ds *DataStore) http.Handler {
 	mux.HandleFunc("/api/datasets", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, ds.ListDataSets()) })
 	mux.HandleFunc("/api/summary", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, ds.Summary()) })
 	mux.HandleFunc("/api/analytics", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, ds.Analytics()) })
+	mux.HandleFunc("/api/plugins", func(w http.ResponseWriter, r *http.Request) {
+		// Return plugin metadata (without data)
+		writeJSON(w, ds.Plugins)
+	})
+	mux.HandleFunc("/api/plugin/", func(w http.ResponseWriter, r *http.Request) {
+		pluginName := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/plugin/"), "/")
+		if pluginName == "" {
+			http.Error(w, "plugin name required", http.StatusBadRequest)
+			return
+		}
+		// Find plugin and filter data
+		var plugin *PluginDataset
+		for i := range ds.Plugins {
+			if ds.Plugins[i].Name == pluginName {
+				plugin = &ds.Plugins[i]
+				break
+			}
+		}
+		if plugin == nil {
+			http.Error(w, "plugin not found", http.StatusNotFound)
+			return
+		}
+		// Filter plugin data using query parameters
+		filtered := filterPluginData(plugin.Data, r.URL.Query())
+		writeJSON(w, filtered)
+	})
 	mux.HandleFunc("/api/data/", func(w http.ResponseWriter, r *http.Request) {
 		dataset := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/data/"), "/")
 		if dataset == "" {
@@ -69,10 +96,51 @@ func StartServer(ctx context.Context, addr string, ds *DataStore) error {
 	return srv.ListenAndServe()
 }
 
-func writeJSON(w http.ResponseWriter, v interface{}) {
+// writeJSON writes JSON to the response
+func writeJSON(w http.ResponseWriter, v any) {
 	w.Header().Set("Content-Type", "application/json")
 	_ = json.NewEncoder(w).Encode(v)
 }
+
+// filterPluginData filters plugin data based on query parameters
+func filterPluginData(data []map[string]string, query url.Values) []map[string]string {
+	if len(query) == 0 {
+		return data
+	}
+
+	filtered := make([]map[string]string, 0)
+	for _, record := range data {
+		matches := true
+		for key, values := range query {
+			if len(values) == 0 {
+				continue
+			}
+			// Check if record value matches any of the filter values
+			recordValue, exists := record[key]
+			if !exists {
+				matches = false
+				break
+			}
+			// For search filters, check if the value contains the search term
+			found := false
+			for _, filterValue := range values {
+				if strings.Contains(strings.ToLower(recordValue), strings.ToLower(filterValue)) {
+					found = true
+					break
+				}
+			}
+			if !found {
+				matches = false
+				break
+			}
+		}
+		if matches {
+			filtered = append(filtered, record)
+		}
+	}
+
+	return filtered
+}
 func serveStatic(w http.ResponseWriter, name string) {
 	data, err := staticFS.ReadFile(name)
 	if err != nil {
diff --git a/internal/viewer/static/app.js b/internal/viewer/static/app.js
index d7e2b5a..1fd9646 100644
--- a/internal/viewer/static/app.js
+++ b/internal/viewer/static/app.js
@@ -13,6 +13,8 @@ const routeLabels = {
     'costs': 'Cost Analysis',
     'outOfScope': 'Out of Scope'
 };
+let pluginRoutes = [];
+let pluginMetadata = {};
 const menuStructure = [
     { label: 'Overview', route: 'dashboard', children: [] },
     {
@@ -64,6 +66,9 @@ function navigate() {
     if (routes.includes(h)) {
         updateBrand(h);
         showDataset(h);
+    } else if (pluginRoutes.includes(h)) {
+        updateBrand(h);
+        showPlugin(h);
     } else {
         console.log('Unknown route:', h, 'redirecting to dashboard');
         updateBrand('dashboard');
@@ -113,6 +118,32 @@ async function initNav() {
         }
     });
 
+    // Load plugins and add plugin menu if plugins exist
+    try {
+        const plugins = await fetchJSON('/api/plugins');
+        if (plugins && plugins.length > 0) {
+            pluginRoutes = plugins.map(p => `plugin-${p.name}`);
+            plugins.forEach(p => {
+                pluginMetadata[`plugin-${p.name}`] = p;
+                routeLabels[`plugin-${p.name}`] = p.displayName || p.name;
+            });
+
+            html += `<li class="nav-item dropdown">
+                <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
+                    <i class="bi bi-plugin me-1"></i> Plugins
+                </a>
+                <ul class="dropdown-menu">`;
+            plugins.forEach(p => {
+                html += `<li><a class="dropdown-item" href="#plugin-${p.name}" onclick="closeMenu()">
+                    <i class="bi bi-puzzle me-1"></i> ${p.displayName || p.name}
+                </a></li>`;
+            });
+            html += `</ul></li>`;
+        }
+    } catch (error) {
+        console.warn('No plugins available or error loading plugins:', error);
+    }
+
     nav.innerHTML = html;
 }
 
@@ -163,8 +194,9 @@ async function showDashboard() {
         'advisorCount', 'azurePolicyCount', 'costItems', 'defenderCount', 'defenderRecommendationsCount',
         'impactedCount', 'inventoryCount', 'outOfScopeCount', 'azurePolicyNonCompliant',
         'recommendationsImplemented', 'recommendationsNotImplemented', 'recommendationsTotal',
-        'resourceTypeCount', 'totalCost', 'arcSQLCount'
+        'resourceTypeCount', 'totalCost', 'arcSQLCount', 'pluginCounts'
     ]);
+
     Object.entries(summary).forEach(([k, v]) => {
         if (exclude.has(k)) return;
         const target = routes.find(r => k.toLowerCase().includes(r.toLowerCase()));
@@ -374,6 +406,24 @@ async function showDataset(name) {
         document.getElementById('data-table').innerHTML = '<tbody><tr><td class="text-center text-danger py-4"><i class="bi bi-exclamation-triangle me-2"></i>Error loading data</td></tr></tbody>';
     }
 }
+
+async function showPlugin(routeName) {
+    console.log('showPlugin called with:', routeName);
+    document.getElementById('dashboard').style.display = 'none';
+    document.getElementById('dataset-view').style.display = 'block';
+
+    const pluginName = routeName.replace('plugin-', '');
+    const plugin = pluginMetadata[routeName];
+
+    try {
+        const data = await fetchJSON(`/api/plugin/${pluginName}`);
+        console.log('Plugin data fetched:', data.length, 'rows');
+        renderPluginTable(data, plugin);
+    } catch (error) {
+        console.error('Error fetching plugin data:', error);
+        document.getElementById('data-table').innerHTML = '<tbody><tr><td class="text-center text-danger py-4"><i class="bi bi-exclamation-triangle me-2"></i>Error loading plugin data</td></tr></tbody>';
+    }
+}
 function renderTable(rows, datasetName) {
     const table = document.getElementById('data-table');
     if (rows.length === 0) {
@@ -602,6 +652,84 @@ function renderTable(rows, datasetName) {
     }, 100);
 }
 
+function renderPluginTable(rows, plugin) {
+    const table = document.getElementById('data-table');
+    if (rows.length === 0) {
+        table.innerHTML = '<tbody><tr><td class="text-center text-muted py-4"><i class="bi bi-inbox me-2"></i>No data available</td></tr></tbody>';
+        return;
+    }
+
+    const columns = plugin.columns || [];
+    const readableHeaders = columns.map(c => c.name);
+
+    // Build table with filter headers based on metadata
+    let html = '<thead class="table-dark sticky-top">';
+    html += '<tr>' + readableHeaders.map(h => `<th scope="col" class="fw-semibold">${h}</th>`).join('') + '</tr>';
+
+    // Add filter row with dynamic filter types
+    html += '<tr class="table-secondary">';
+    html += columns.map((col, index) => {
+        const filterType = col.filterType || 'none';
+        if (filterType === 'none') {
+            return `<th class="p-1"></th>`;
+        } else if (filterType === 'dropdown') {
+            // Get unique values for dropdown - use dataKey if available
+            const dataKey = col.dataKey || col.name;
+            const valueMap = new Map();
+            rows.forEach(r => {
+                const val = r[dataKey];
+                if (val && val.trim()) {
+                    const lowerKey = val.toLowerCase();
+                    if (!valueMap.has(lowerKey)) {
+                        valueMap.set(lowerKey, val);
+                    }
+                }
+            });
+            const uniqueValues = Array.from(valueMap.values()).sort((a, b) =>
+                a.toLowerCase().localeCompare(b.toLowerCase())
+            );
+
+            return `<th class="p-1">
+                <select class="form-select form-select-sm table-filter" data-column="${index}">
+                    <option value="">All</option>
+                    ${uniqueValues.map(v => `<option value="${escapeHTML(v)}">${escapeHTML(v)}</option>`).join('')}
+                </select>
+            </th>`;
+        } else { // search
+            return `<th class="p-1">
+                <input type="text" class="form-control form-control-sm table-filter" data-column="${index}" placeholder="Filter...">
+            </th>`;
+        }
+    }).join('');
+    html += '</tr></thead>';
+
+    // Build table body
+    html += '<tbody>';
+    rows.forEach((r, rowIndex) => {
+        html += `<tr class="filterable-row ${rowIndex % 2 === 0 ? 'table-light' : ''}">`;
+        html += columns.map(col => {
+            // Use dataKey if available, otherwise fall back to name
+            const dataKey = col.dataKey || col.name;
+            const value = r[dataKey] || '';
+            return `<td><span class="text-break">${escapeHTML(value)}</span></td>`;
+        }).join('');
+        html += '</tr>';
+    });
+    html += '</tbody>';
+
+    table.innerHTML = html;
+    console.log('Plugin table rendered with', rows.length, 'rows');
+
+    // Add filtering functionality
+    setTimeout(() => {
+        try {
+            addTableFilters();
+        } catch (error) {
+            console.warn('Filter setup failed:', error);
+        }
+    }, 100);
+}
+
 function addTableFilters() {
     const filters = document.querySelectorAll('.table-filter');
     console.log('Setting up', filters.length, 'table filters');



Test patch:
diff --git a/internal/plugins/registry_test.go b/internal/plugins/registry_test.go
new file mode 100644
index 0000000..0e7a267
--- /dev/null
+++ b/internal/plugins/registry_test.go
@@ -0,0 +1,283 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"testing"
+
+	"github.com/Azure/azqr/internal/models"
+	"github.com/spf13/cobra"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestGetRegistry(t *testing.T) {
+	registry := GetRegistry()
+	assert.NotNil(t, registry)
+	assert.NotNil(t, registry.plugins)
+}
+
+func TestRegistryRegister(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	// Initialize models.ScannerList
+	models.ScannerList = make(map[string][]models.IAzureScanner)
+
+	plugin := &Plugin{
+		Metadata: PluginMetadata{
+			Name:        "test1",
+			Version:     "1.0.0",
+			Description: "Test Plugin 1",
+			Type:        PluginTypeYaml,
+		},
+	}
+
+	err := registry.Register(plugin)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, registry.Count())
+
+	// Verify plugin was registered
+	retrieved, exists := registry.Get("test1")
+	assert.True(t, exists)
+	assert.Equal(t, "test1", retrieved.Metadata.Name)
+	assert.Equal(t, "1.0.0", retrieved.Metadata.Version)
+}
+
+func TestRegistryRegisterNilPlugin(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	err := registry.Register(nil)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "cannot register nil plugin")
+}
+
+func TestRegistryRegisterEmptyName(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	plugin := &Plugin{
+		Metadata: PluginMetadata{
+			Name: "",
+		},
+	}
+
+	err := registry.Register(plugin)
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "plugin name cannot be empty")
+}
+
+func TestRegistryRegisterDuplicate(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	plugin1 := &Plugin{
+		Metadata: PluginMetadata{
+			Name:    "test",
+			Version: "1.0.0",
+		},
+		YamlRecommendations: []models.AprlRecommendation{},
+	}
+
+	plugin2 := &Plugin{
+		Metadata: PluginMetadata{
+			Name:    "test",
+			Version: "2.0.0",
+		},
+		YamlRecommendations: []models.AprlRecommendation{},
+	}
+
+	err := registry.Register(plugin1)
+	assert.NoError(t, err)
+
+	// Registering duplicate should replace
+	err = registry.Register(plugin2)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, registry.Count())
+
+	retrieved, exists := registry.Get("test")
+	assert.True(t, exists)
+	assert.Equal(t, "2.0.0", retrieved.Metadata.Version)
+}
+
+func TestRegistryUnregister(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	plugin := &Plugin{
+		Metadata: PluginMetadata{
+			Name:    "test",
+			Version: "1.0.0",
+		},
+		YamlRecommendations: []models.AprlRecommendation{},
+	}
+
+	_ = registry.Register(plugin)
+	assert.Equal(t, 1, registry.Count())
+
+	err := registry.Unregister("test")
+	assert.NoError(t, err)
+	assert.Equal(t, 0, registry.Count())
+
+	_, exists := registry.Get("test")
+	assert.False(t, exists)
+}
+
+func TestRegistryUnregisterNonExistent(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	err := registry.Unregister("nonexistent")
+	assert.Error(t, err)
+	assert.Contains(t, err.Error(), "plugin nonexistent not found")
+}
+
+func TestRegistryGet(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	plugin := &Plugin{
+		Metadata: PluginMetadata{
+			Name:    "test",
+			Version: "1.0.0",
+		},
+	}
+
+	_ = registry.Register(plugin)
+
+	retrieved, exists := registry.Get("test")
+	assert.True(t, exists)
+	assert.Equal(t, "test", retrieved.Metadata.Name)
+
+	_, exists = registry.Get("nonexistent")
+	assert.False(t, exists)
+}
+
+func TestRegistryList(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	// Register multiple plugins
+	plugins := []*Plugin{
+		{Metadata: PluginMetadata{Name: "zebra", Version: "1.0.0"}},
+		{Metadata: PluginMetadata{Name: "alpha", Version: "1.0.0"}},
+		{Metadata: PluginMetadata{Name: "beta", Version: "1.0.0"}},
+	}
+
+	for _, p := range plugins {
+		_ = registry.Register(p)
+	}
+
+	list := registry.List()
+	assert.Equal(t, 3, len(list))
+
+	// Verify alphabetical order
+	assert.Equal(t, "alpha", list[0].Metadata.Name)
+	assert.Equal(t, "beta", list[1].Metadata.Name)
+	assert.Equal(t, "zebra", list[2].Metadata.Name)
+}
+
+func TestRegistryListEmpty(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	list := registry.List()
+	assert.NotNil(t, list)
+	assert.Equal(t, 0, len(list))
+}
+
+func TestRegistryCount(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	assert.Equal(t, 0, registry.Count())
+
+	plugin1 := &Plugin{Metadata: PluginMetadata{Name: "test1", Version: "1.0.0"}}
+	plugin2 := &Plugin{Metadata: PluginMetadata{Name: "test2", Version: "1.0.0"}}
+
+	_ = registry.Register(plugin1)
+	assert.Equal(t, 1, registry.Count())
+
+	_ = registry.Register(plugin2)
+	assert.Equal(t, 2, registry.Count())
+
+	_ = registry.Unregister("test1")
+	assert.Equal(t, 1, registry.Count())
+}
+
+func TestAttachCommands(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	rootCmd := &cobra.Command{Use: "root"}
+
+	plugin1 := &Plugin{
+		Metadata: PluginMetadata{Name: "test1"},
+		Command:  &cobra.Command{Use: "test1"},
+	}
+	plugin2 := &Plugin{
+		Metadata: PluginMetadata{Name: "test2"},
+		Command:  &cobra.Command{Use: "test2"},
+	}
+	plugin3 := &Plugin{
+		Metadata: PluginMetadata{Name: "test3"},
+		Command:  nil, // No command
+	}
+
+	_ = registry.Register(plugin1)
+	_ = registry.Register(plugin2)
+	_ = registry.Register(plugin3)
+
+	attached := registry.AttachCommands(rootCmd)
+	assert.Equal(t, 2, attached) // Only 2 plugins have commands
+
+	// Verify commands were attached
+	assert.True(t, rootCmd.HasSubCommands())
+	subcommands := rootCmd.Commands()
+	assert.Equal(t, 2, len(subcommands))
+}
+
+func TestPluginTypeString(t *testing.T) {
+	assert.Equal(t, "yaml", pluginTypeString(PluginTypeYaml))
+	assert.Equal(t, "unknown", pluginTypeString(PluginType(999)))
+}
+
+func TestConcurrentAccess(t *testing.T) {
+	registry := &Registry{
+		plugins: make(map[string]*Plugin),
+	}
+
+	// Test concurrent registration
+	done := make(chan bool)
+	for i := 0; i < 10; i++ {
+		go func(id int) {
+			plugin := &Plugin{
+				Metadata: PluginMetadata{
+					Name:    string(rune('a' + id)),
+					Version: "1.0.0",
+				},
+			}
+			_ = registry.Register(plugin)
+			done <- true
+		}(i)
+	}
+
+	// Wait for all goroutines
+	for i := 0; i < 10; i++ {
+		<-done
+	}
+
+	assert.Equal(t, 10, registry.Count())
+}
diff --git a/internal/plugins/yaml_test.go b/internal/plugins/yaml_test.go
new file mode 100644
index 0000000..52edb55
--- /dev/null
+++ b/internal/plugins/yaml_test.go
@@ -0,0 +1,410 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+package plugins
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// TestLoadYamlPluginValid tests loading a valid YAML plugin
+func TestLoadYamlPluginValid(t *testing.T) {
+	// Create a temporary YAML plugin file
+	tmpDir := t.TempDir()
+	yamlPath := filepath.Join(tmpDir, "test-plugin.yaml")
+
+	yamlContent := `---
+name: test-plugin
+version: 1.0.0
+description: Test YAML plugin
+author: Test Author
+queries:
+  - aprlGuid: test-guid-001
+    description: Test recommendation
+    longDescription: Test long description
+    recommendationControl: High Availability
+    recommendationImpact: High
+    learnMoreLink:
+      - name: Test Doc
+        url: https://example.com/test
+    recommendationResourceType: Microsoft.Test/resources
+    query: |
+      resources
+      | where type == "microsoft.test/resources"
+      | project id, name, resourceGroup, location
+`
+
+	if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil {
+		t.Fatalf("Failed to create test YAML file: %v", err)
+	}
+
+	// Load the plugin
+	plugin, recommendations, err := LoadYamlPlugin(yamlPath)
+	if err != nil {
+		t.Fatalf("LoadYamlPlugin failed: %v", err)
+	}
+
+	// Verify plugin metadata
+	if plugin.Metadata.Name != "test-plugin" {
+		t.Errorf("Expected name 'test-plugin', got '%s'", plugin.Metadata.Name)
+	}
+	if plugin.Metadata.Version != "1.0.0" {
+		t.Errorf("Expected version '1.0.0', got '%s'", plugin.Metadata.Version)
+	}
+	if plugin.Metadata.Description != "Test YAML plugin" {
+		t.Errorf("Expected description 'Test YAML plugin', got '%s'", plugin.Metadata.Description)
+	}
+	if plugin.Metadata.Type != PluginTypeYaml {
+		t.Errorf("Expected type PluginTypeYaml, got %v", plugin.Metadata.Type)
+	}
+
+	// Verify recommendations are correctly converted
+	if len(recommendations) != 1 {
+		t.Errorf("Expected 1 recommendation, got %d", len(recommendations))
+	}
+	if len(plugin.YamlRecommendations) != 1 {
+		t.Errorf("Expected 1 YAML recommendation in plugin, got %d", len(plugin.YamlRecommendations))
+	}
+
+	// Verify recommendation details
+	rec := recommendations[0]
+	if rec.RecommendationID != "test-guid-001" {
+		t.Errorf("Expected APRL GUID 'test-guid-001', got '%s'", rec.RecommendationID)
+	}
+	if rec.Recommendation != "Test recommendation" {
+		t.Errorf("Expected description 'Test recommendation', got '%s'", rec.Recommendation)
+	}
+	if rec.ResourceType != "Microsoft.Test/resources" {
+		t.Errorf("Expected resource type 'Microsoft.Test/resources', got '%s'", rec.ResourceType)
+	}
+}
+
+// TestLoadYamlPluginInvalidFile tests loading from a non-existent file
+func TestLoadYamlPluginInvalidFile(t *testing.T) {
+	_, _, err := LoadYamlPlugin("/nonexistent/path/plugin.yaml")
+	if err == nil {
+		t.Error("Expected error for non-existent file, got nil")
+	}
+}
+
+// TestLoadYamlPluginInvalidYaml tests loading invalid YAML
+func TestLoadYamlPluginInvalidYaml(t *testing.T) {
+	tmpDir := t.TempDir()
+	yamlPath := filepath.Join(tmpDir, "invalid.yaml")
+
+	invalidYaml := `name: test
+version: 1.0.0
+queries:
+  - this is not valid yaml structure
+    missing proper formatting
+`
+
+	if err := os.WriteFile(yamlPath, []byte(invalidYaml), 0644); err != nil {
+		t.Fatalf("Failed to create test YAML file: %v", err)
+	}
+
+	_, _, err := LoadYamlPlugin(yamlPath)
+	if err == nil {
+		t.Error("Expected error for invalid YAML, got nil")
+	}
+}
+
+// TestLoadYamlPluginMissingRequired tests loading YAML missing required fields
+func TestLoadYamlPluginMissingRequired(t *testing.T) {
+	tmpDir := t.TempDir()
+	yamlPath := filepath.Join(tmpDir, "missing.yaml")
+
+	missingFields := `description: Missing name and version
+queries:
+  - aprlGuid: test-guid
+    query: resources
+`
+
+	if err := os.WriteFile(yamlPath, []byte(missingFields), 0644); err != nil {
+		t.Fatalf("Failed to create test YAML file: %v", err)
+	}
+
+	_, _, err := LoadYamlPlugin(yamlPath)
+	if err == nil {
+		t.Error("Expected error for missing required fields, got nil")
+	}
+}
+
+// TestLoadYamlPluginExternalKqlFile tests loading a plugin with external KQL file
+func TestLoadYamlPluginExternalKqlFile(t *testing.T) {
+	tmpDir := t.TempDir()
+	kqlDir := filepath.Join(tmpDir, "kql")
+	if err := os.MkdirAll(kqlDir, 0755); err != nil {
+		t.Fatalf("Failed to create kql directory: %v", err)
+	}
+
+	// Create external KQL file
+	kqlPath := filepath.Join(kqlDir, "test-query.kql")
+	kqlContent := `resources
+| where type == "microsoft.test/resources"
+| where tags["environment"] == "production"
+| project id, name, resourceGroup, location`
+
+	if err := os.WriteFile(kqlPath, []byte(kqlContent), 0644); err != nil {
+		t.Fatalf("Failed to create KQL file: %v", err)
+	}
+
+	// Create YAML plugin referencing external KQL
+	yamlPath := filepath.Join(tmpDir, "plugin.yaml")
+	yamlContent := `name: test-external-kql
+version: 1.0.0
+description: Test plugin with external KQL
+queries:
+  - aprlGuid: test-guid-external
+    description: Test with external KQL
+    recommendationControl: Security
+    recommendationImpact: Medium
+    learnMoreLink:
+      - name: Example Doc
+        url: https://example.com
+    recommendationResourceType: Microsoft.Test/resources
+    queryFile: ./kql/test-query.kql
+`
+
+	if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil {
+		t.Fatalf("Failed to create YAML file: %v", err)
+	}
+
+	// Load the plugin
+	_, recommendations, err := LoadYamlPlugin(yamlPath)
+	if err != nil {
+		t.Fatalf("LoadYamlPlugin failed: %v", err)
+	}
+
+	// Verify the query was loaded from external file
+	if len(recommendations) != 1 {
+		t.Fatalf("Expected 1 recommendation, got %d", len(recommendations))
+	}
+
+	rec := recommendations[0]
+	if !containsString(rec.GraphQuery, "tags[\"environment\"]") {
+		t.Error("Expected query to contain content from external KQL file")
+	}
+}
+
+// TestLoadYamlPluginMultipleQueries tests loading a plugin with multiple queries
+func TestLoadYamlPluginMultipleQueries(t *testing.T) {
+	tmpDir := t.TempDir()
+	yamlPath := filepath.Join(tmpDir, "multi.yaml")
+
+	yamlContent := `name: multi-query-plugin
+version: 1.0.0
+description: Plugin with multiple queries
+queries:
+  - aprlGuid: query-001
+    description: First query
+    recommendationControl: Security
+    recommendationImpact: High
+    recommendationResourceType: Microsoft.Compute/virtualMachines
+    query: resources | where type == "microsoft.compute/virtualmachines"
+  
+  - aprlGuid: query-002
+    description: Second query
+    recommendationControl: Cost Optimization
+    recommendationImpact: Medium
+    recommendationResourceType: Microsoft.Storage/storageAccounts
+    query: resources | where type == "microsoft.storage/storageaccounts"
+  
+  - aprlGuid: query-003
+    description: Third query
+    recommendationControl: Operational Excellence
+    recommendationImpact: Low
+    recommendationResourceType: Microsoft.Network/publicIPAddresses
+    query: resources | where type == "microsoft.network/publicipaddresses"
+`
+
+	if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil {
+		t.Fatalf("Failed to create YAML file: %v", err)
+	}
+
+	_, recommendations, err := LoadYamlPlugin(yamlPath)
+	if err != nil {
+		t.Fatalf("LoadYamlPlugin failed: %v", err)
+	}
+
+	if len(recommendations) != 3 {
+		t.Errorf("Expected 3 recommendations, got %d", len(recommendations))
+	}
+
+	// Verify each recommendation
+	expectedGuids := []string{"query-001", "query-002", "query-003"}
+	for i, expectedGuid := range expectedGuids {
+		if recommendations[i].RecommendationID != expectedGuid {
+			t.Errorf("Recommendation %d: expected GUID '%s', got '%s'", i, expectedGuid, recommendations[i].RecommendationID)
+		}
+	}
+}
+
+// TestDiscoverYamlPluginsEmpty tests discovery in empty directories
+func TestDiscoverYamlPluginsEmpty(t *testing.T) {
+	tmpDir := t.TempDir()
+	plugins, err := discoverYamlPlugins([]string{tmpDir})
+
+	if err != nil {
+		t.Fatalf("discoverYamlPlugins failed: %v", err)
+	}
+
+	if len(plugins) != 0 {
+		t.Errorf("Expected 0 plugins, got %d", len(plugins))
+	}
+}
+
+// TestDiscoverYamlPluginsMultiple tests discovering multiple YAML plugins
+func TestDiscoverYamlPluginsMultiple(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Create multiple YAML plugins
+	plugins := []struct {
+		name     string
+		filename string
+	}{
+		{"plugin-one", "plugin-one.yaml"},
+		{"plugin-two", "plugin-two.yaml"},
+		{"plugin-three", "custom-checks.yaml"},
+	}
+
+	for _, p := range plugins {
+		yamlPath := filepath.Join(tmpDir, p.filename)
+		yamlContent := `name: ` + p.name + `
+version: 1.0.0
+description: Test plugin ` + p.name + `
+queries:
+  - aprlGuid: test-guid
+    description: Test
+    recommendationControl: Security
+    recommendationImpact: High
+    recommendationResourceType: Microsoft.Test/resources
+    query: resources
+`
+		if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil {
+			t.Fatalf("Failed to create YAML file: %v", err)
+		}
+	}
+
+	// Discover plugins
+	discovered, err := discoverYamlPlugins([]string{tmpDir})
+	if err != nil {
+		t.Fatalf("discoverYamlPlugins failed: %v", err)
+	}
+
+	if len(discovered) != 3 {
+		t.Errorf("Expected 3 plugins, got %d", len(discovered))
+	}
+
+	// Verify all plugins were discovered
+	foundNames := make(map[string]bool)
+	for _, plugin := range discovered {
+		foundNames[plugin.Metadata.Name] = true
+	}
+
+	for _, p := range plugins {
+		if !foundNames[p.name] {
+			t.Errorf("Plugin '%s' was not discovered", p.name)
+		}
+	}
+}
+
+// TestDiscoverYamlPluginsIgnoresNonYaml tests that non-YAML files are ignored
+func TestDiscoverYamlPluginsIgnoresNonYaml(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Create various files
+	files := map[string]string{
+		"plugin.yaml": validPluginYaml(),
+		"readme.md":   "# Plugin Documentation",
+		"config.json": `{"setting": "value"}`,
+		"script.sh":   "#!/bin/bash\necho test",
+		"data.txt":    "some text data",
+	}
+
+	for filename, content := range files {
+		path := filepath.Join(tmpDir, filename)
+		if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+			t.Fatalf("Failed to create file %s: %v", filename, err)
+		}
+	}
+
+	discovered, err := discoverYamlPlugins([]string{tmpDir})
+	if err != nil {
+		t.Fatalf("discoverYamlPlugins failed: %v", err)
+	}
+
+	// Should only discover the one YAML plugin
+	if len(discovered) != 1 {
+		t.Errorf("Expected 1 plugin, got %d", len(discovered))
+	}
+}
+
+// TestDiscoverYamlPluginsDuplicates tests that duplicate plugins are handled correctly
+func TestDiscoverYamlPluginsDuplicates(t *testing.T) {
+	tmpDir := t.TempDir()
+	subDir := filepath.Join(tmpDir, "subdir")
+	if err := os.MkdirAll(subDir, 0755); err != nil {
+		t.Fatalf("Failed to create subdirectory: %v", err)
+	}
+
+	// Create same plugin in two locations
+	yamlContent := validPluginYaml()
+	path1 := filepath.Join(tmpDir, "plugin.yaml")
+	path2 := filepath.Join(subDir, "plugin.yaml")
+
+	if err := os.WriteFile(path1, []byte(yamlContent), 0644); err != nil {
+		t.Fatalf("Failed to create first YAML file: %v", err)
+	}
+	if err := os.WriteFile(path2, []byte(yamlContent), 0644); err != nil {
+		t.Fatalf("Failed to create second YAML file: %v", err)
+	}
+
+	discovered, err := discoverYamlPlugins([]string{tmpDir})
+	if err != nil {
+		t.Fatalf("discoverYamlPlugins failed: %v", err)
+	}
+
+	// Should only get one plugin (first found wins)
+	if len(discovered) != 1 {
+		t.Errorf("Expected 1 plugin (duplicate removed), got %d", len(discovered))
+	}
+}
+
+// Helper functions
+
+func validPluginYaml() string {
+	return `name: test-plugin
+version: 1.0.0
+description: Test YAML plugin
+author: Test Author
+queries:
+  - aprlGuid: test-guid
+    description: Test recommendation
+    longDescription: Test long description
+    recommendationControl: Security
+    recommendationImpact: High
+    learnMoreLink:
+      - name: Test Doc
+        url: https://example.com/test
+    recommendationResourceType: Microsoft.Test/resources
+    query: resources | where type == "microsoft.test/resources"
+`
+}
+
+func containsString(s, substr string) bool {
+	return len(s) > 0 && len(substr) > 0 && (s == substr || len(s) >= len(substr) &&
+		(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
+			len(s) > len(substr) && findSubstring(s, substr)))
+}
+
+func findSubstring(s, substr string) bool {
+	for i := 0; i <= len(s)-len(substr); i++ {
+		if s[i:i+len(substr)] == substr {
+			return true
+		}
+	}
+	return false
+}