Build Your First DVP CMS Plugin in 30 Minutes

Beginner 30 minutes Prerequisites: Basic Python knowledge

DVP CMS is a truth distillation system for AI-generated content. Plugins are evidence suppliers—they verify facts, pull live data, and make content more trustworthy over time. Learn more →

This guide will walk you through creating a working DVP CMS plugin from scratch.

What is a Plugin?

A DVP CMS plugin is a Python class that:

Plugins let you extend DVP CMS without modifying core code.

Plugin Architecture

Project Structure

A DVP CMS plugin is organized as a standard Python package:

my-plugin/
├── pyproject.toml     # Package metadata and entry point
├── src/
│   └── my_plugin/
│       ├── __init__.py    # Package exports
│       └── plugin.py      # Your plugin code
└── tests/
    └── test_plugin.py     # Tests

Hook Types

DVP CMS hooks receive a HookContext for tenant isolation. All hooks follow a consistent pattern: ctx is ALWAYS first.

Filter Hooks - Transform and return data (ctx first, must return value):

def dvp_before_content_create(
    self,
    ctx: HookContext,              # ctx ALWAYS first
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """All hooks: ctx first. Filter hooks MUST return value."""
    content['modified'] = True
    return content  # Filter hooks must return (never None!)

Action Hooks - React without returning (ctx first, no return):

def dvp_content_published(
    self,
    ctx: HookContext,              # ctx ALWAYS first
    content_id: str,               # Note: str, not UUID
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,
) -> None:
    """All hooks: ctx first. Action hooks need no return."""
    print(f"Content {content_id} was published!")
    # No return needed

Step 1: Project Setup

Create Directory Structure

mkdir -p my-first-plugin/src/my_first_plugin my-first-plugin/tests
cd my-first-plugin

Required Files

You'll create these files:

Step 2: Create Plugin Files

2.1 Create pyproject.toml

This defines your plugin package and entry point:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "my-first-plugin"
version = "1.0.0"
description = "My first DVP CMS plugin that adds a timestamp to content"
readme = "README.md"
requires-python = ">=3.12"
authors = [
    { name = "Your Name", email = "you@example.com" },
]
dependencies = [
    "dvp-cms>=1.0.0",
]

[project.entry-points."dvp_cms.plugins"]
my-first-plugin = "my_first_plugin:MyFirstPlugin"

[tool.hatch.build.targets.wheel]
packages = ["src/my_first_plugin"]

Key sections:

2.2 Create src/my_first_plugin/__init__.py

This exports your plugin class:

"""My First Plugin for DVP CMS."""

from my_first_plugin.plugin import MyFirstPlugin

__all__ = ["MyFirstPlugin"]
__version__ = "1.0.0"

Step 3: Implement Your Plugin

3.1 Create src/my_first_plugin/plugin.py

Here's the full plugin:

"""
My First Plugin for DVP CMS

Adds a timestamp to all content before saving.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from uuid import UUID

from dvp_cms.plugins.base import Plugin
from dvp_cms.plugins.hookspec import ContentLifecycleHooks

if TYPE_CHECKING:
    from dvp_cms.plugins.context import HookContext


class MyFirstPlugin(Plugin, ContentLifecycleHooks):
    """
    Add timestamps to content.

    This plugin demonstrates the basics of DVP CMS plugin development.

    Example:
        >>> from dvp_cms.plugins.registry import PluginRegistry
        >>> from my_first_plugin import MyFirstPlugin
        >>>
        >>> registry = PluginRegistry()
        >>> plugin = MyFirstPlugin()
        >>> registry.register(plugin)
        >>> registry.enable("my-first-plugin")
    """

    # Plugin metadata
    name = "my-first-plugin"
    version = "1.0.0"
    description = "Add timestamps to content"
    author = "Your Name"

    def __init__(self) -> None:
        """Initialize the plugin."""
        super().__init__()
        self.content_count = 0

    def dvp_before_content_create(
        self,
        ctx: HookContext,
        content: dict[str, Any],
        metadata: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Add timestamp before content is created.

        This is a FILTER hook:
        - ctx is ALWAYS first (consistent across all hooks)
        - MUST return the modified content (never None!)

        Args:
            ctx: Hook context with tenant information (always first)
            content: Content data being created
            metadata: Optional metadata

        Returns:
            Modified content with timestamp added
        """
        # Add timestamp (UTC)
        content['created_at_plugin'] = datetime.now(timezone.utc).isoformat()
        content['processed_by'] = self.name

        # Increment counter
        self.content_count += 1

        # IMPORTANT: Filter hooks MUST return the modified content
        return content

    def dvp_content_published(
        self,
        ctx: HookContext,
        content_id: str,
        content: dict[str, Any],
        metadata: dict[str, Any] | None = None,
    ) -> None:
        """React when content is published.

        This is an ACTION hook:
        - ctx is ALWAYS first (consistent across all hooks)
        - No return needed

        Args:
            ctx: Hook context with tenant information (always first)
            content_id: ID of the published content (str, not UUID)
            content: The published content
            metadata: Optional metadata
        """
        print(f"[{ctx.tenant_slug}] Content {content_id} published!")

3.2 Understanding the Code

Key Patterns:

from dvp_cms.plugins.base import Plugin
from dvp_cms.plugins.hookspec import ContentLifecycleHooks
class MyFirstPlugin(Plugin, ContentLifecycleHooks):
name = "my-first-plugin"
version = "1.0.0"
description = "Add timestamps to content"
author = "Your Name"
def __init__(self) -> None:
    super().__init__()
    self.content_count = 0
def dvp_before_content_create(
    self,
    ctx: HookContext,         # ctx ALWAYS first
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:

Step 4: Test Your Plugin

4.1 Create Test File

Create tests/test_plugin.py:

"""Tests for My First Plugin."""

from datetime import datetime
from uuid import UUID

import pytest
from dvp_cms.plugins.context import HookContext

from my_first_plugin import MyFirstPlugin

# Canonical test tenant
TENANT_ID = UUID("11111111-1111-1111-1111-111111111111")


@pytest.fixture
def hook_context() -> HookContext:
    """Create a test hook context."""
    return HookContext(
        tenant_id=TENANT_ID,
        tenant_slug="test-tenant",
        tenant_plan="enterprise",
    )


class TestMyFirstPlugin:
    """Test suite for MyFirstPlugin."""

    def test_plugin_metadata(self) -> None:
        """Test plugin has correct metadata."""
        plugin = MyFirstPlugin()

        assert plugin.name == "my-first-plugin"
        assert plugin.version == "1.0.0"
        assert plugin.content_count == 0

    def test_adds_timestamp(self, hook_context: HookContext) -> None:
        """Test filter hook adds timestamp to content.

        All hooks: ctx is always the first parameter.
        """
        plugin = MyFirstPlugin()

        content = {
            'title': 'Test Article',
            'body': 'This is a test.'
        }

        # All hooks: ctx first
        result = plugin.dvp_before_content_create(hook_context, content)

        assert result is not None  # Filter hooks must return
        assert 'created_at_plugin' in result
        assert 'processed_by' in result
        assert result['processed_by'] == 'my-first-plugin'
        assert plugin.content_count == 1

    def test_preserves_existing_fields(
        self, hook_context: HookContext
    ) -> None:
        """Test plugin doesn't delete existing fields."""
        plugin = MyFirstPlugin()

        content = {
            'title': 'Test',
            'body': 'Content',
            'custom_field': 'value'
        }

        # All hooks: ctx first
        result = plugin.dvp_before_content_create(hook_context, content)

        assert result is not None
        assert result['title'] == 'Test'
        assert result['custom_field'] == 'value'
        assert 'created_at_plugin' in result

4.2 Run Tests

pytest tests/ -v

Expected output:

tests/test_plugin.py::TestMyFirstPlugin::test_plugin_metadata PASSED
tests/test_plugin.py::TestMyFirstPlugin::test_adds_timestamp PASSED
tests/test_plugin.py::TestMyFirstPlugin::test_preserves_existing_fields PASSED

3 passed in 0.05s

Step 5: Install and Use

5.1 Install Your Plugin

Install in development mode:

# From your plugin directory
uv pip install -e .

DVP CMS automatically discovers plugins via entry points. Once installed, your plugin is available to the registry.

5.2 Example Usage

Here's how your plugin would be used in DVP CMS:

"""Example of using My First Plugin."""

import asyncio
from uuid import UUID

from dvp_cms.plugins.context import HookContext
from dvp_cms.plugins.registry import PluginRegistry

from my_first_plugin import MyFirstPlugin


async def main() -> None:
    # Create registry and register plugin
    registry = PluginRegistry()
    plugin = MyFirstPlugin()
    registry.register(plugin)
    registry.enable("my-first-plugin")

    print("Plugin registered and enabled")

    # Create test context (in production, this comes from DVP CMS)
    ctx = HookContext(
        tenant_id=UUID("11111111-1111-1111-1111-111111111111"),
        tenant_slug="demo-tenant",
        tenant_plan="pro",
    )

    # Create some content
    content = {
        'title': 'My First Article',
        'body': 'This is my first article using my plugin!'
    }

    print(f"\nBefore plugin: {content}")

    # All hooks: ctx first
    modified_content = plugin.dvp_before_content_create(ctx, content)

    print(f"\nAfter plugin: {modified_content}")
    print(f"\nTimestamp added: {modified_content['created_at_plugin']}")


if __name__ == "__main__":
    asyncio.run(main())

5.3 Run the Example

python example_usage.py

Congratulations!

You've created your first DVP CMS plugin!

What You Learned

Next Steps

1. Explore More Hooks

Try other hooks from ContentLifecycleHooks:

# ACTION hook (ctx first, no return)
def dvp_content_created(
    self,
    ctx: HookContext,
    content_id: str,
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,
) -> None:
    """Called AFTER content is created."""
    print(f"[{ctx.tenant_slug}] Content {content_id} created!")

# ACTION hook (ctx first, no return)
def dvp_content_unpublished(
    self,
    ctx: HookContext,
    content_id: str,
    reason: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> None:
    """Called when content is unpublished."""
    print(f"[{ctx.tenant_slug}] Content {content_id} unpublished")

2. Add Tenant-Scoped Configuration

Make your plugin configurable per tenant:

def dvp_before_content_create(
    self,
    ctx: HookContext,
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """All hooks: ctx first. Filter hooks must return."""
    # Get tenant-specific config
    config = self.get_config(str(ctx.tenant_id))
    timestamp_format = config.get("timestamp_format", "%Y-%m-%d %H:%M:%S")

    content['created_at_plugin'] = datetime.now(timezone.utc).strftime(
        timestamp_format
    )
    return content  # Must return!

3. Study Example Plugins

DVP CMS includes example plugins demonstrating various patterns:

Check your DVP CMS installation's examples directory for reference implementations.

4. Read More Guides

5. Build Something Useful

Ideas for your next plugin:

Common Mistakes to Avoid

Mistake 1: Forgetting to Return Content

WRONG - No return!
def dvp_before_content_create(self, ctx, content, metadata=None):
    content['field'] = 'value'
    # Forgot to return - will raise HookError!
CORRECT
def dvp_before_content_create(self, ctx, content, metadata=None):
    content['field'] = 'value'
    return content  # Filter hooks MUST return!

Mistake 2: Not Calling super().__init__()

WRONG
def __init__(self) -> None:
    self.my_var = 123
    # Forgot super().__init__()!
CORRECT
def __init__(self) -> None:
    super().__init__()  # Always call first!
    self.my_var = 123

Mistake 3: Filter Hook Returning None

WRONG - Return type allows None
def dvp_before_content_create(self, ctx, content, metadata=None) -> dict | None:
    # Wrong! Filter hooks must ALWAYS return a value, never None
    if not content.get('title'):
        return None  # This will raise HookError!
    return content
CORRECT - Always return a value
def dvp_before_content_create(
    self,
    ctx: HookContext,         # ctx ALWAYS first
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:  # Never dict | None!
    if not content.get('title'):
        raise ValueError("Title required")
    return content

Mistake 4: Missing metadata Parameter

WRONG - Missing metadata parameter
def dvp_content_published(self, ctx, content_id, content):
    # Missing metadata parameter!
    pass
CORRECT - Include all parameters from hookspec.py
def dvp_content_published(
    self,
    ctx: HookContext,
    content_id: str,
    content: dict[str, Any],
    metadata: dict[str, Any] | None = None,  # Include optional params!
) -> None:
    pass

Note: Hooks can be sync (def) or async (async def) - the system handles both automatically.

Tip: Check the Plugin Development Guide for correct hook signatures.

Troubleshooting

Plugin Not Loading?

  1. Check pyproject.toml entry point is correct
  2. Verify name attribute matches the entry point name
  3. Ensure the plugin is installed: uv pip install -e .
  4. Check Python version is 3.12+

Tests Failing?

  1. Run pytest -v to see detailed errors
  2. Check you're returning content from filter hooks (never None!)
  3. For filter hooks: ensure content is first parameter, ctx second
  4. Verify HookContext is being passed correctly

Hook Not Running?

  1. Check hook name exactly matches hookspec.py
  2. Ensure plugin is enabled: registry.enable("plugin-name")
  3. Check parameter order: all hooks have ctx as first parameter
  4. Verify hook signature matches protocol

Resources