This playbook walks you through creating a ContentDataProvider that enriches content items with additional data before rendering.

When It Runs

ContentDataProvider runs during contents_to_json(), early in the rendering pipeline:

Database Query
     ↓
═══════════════════════════════════════
ContentDataProvider::enrich_content()  ← You are here
═══════════════════════════════════════
     ↓
PageDataProvider::provide_page_data()
     ↓
ComponentRenderer::render()
     ↓
Template rendering

This is the right place to:

  • Add computed fields to content items
  • Fetch related content from the database
  • Add children for index pages
  • Enrich with external data

Prerequisites

  • Existing extension crate in extensions/
  • Understanding of what data needs to be added to content
  • Source name(s) you want to target

Step 1: Define Your Provider Struct

Create a new file in your extension's enrichers/ directory:

use systemprompt::extension::prelude::{ContentDataContext, ContentDataProvider};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;

pub struct DocsContentDataProvider;

impl DocsContentDataProvider {
    pub fn new() -> Self {
        Self
    }
}

Step 2: Implement the Trait

Implement ContentDataProvider:

#[async_trait]
impl ContentDataProvider for DocsContentDataProvider {
    fn provider_id(&self) -> &'static str {
        "docs-content-enricher"
    }

    fn applies_to_sources(&self) -> Vec<String> {
        vec!["documentation".to_string()]
    }

    async fn enrich_content(
        &self,
        ctx: &ContentDataContext<'_>,
        item: &mut serde_json::Value,
    ) -> Result<()> {
        Ok(())
    }
}

Step 3: Add Children for Index Pages

For documentation index pages, add child pages:

async fn enrich_content(
    &self,
    ctx: &ContentDataContext<'_>,
    item: &mut serde_json::Value,
) -> Result<()> {
    let kind = item.get("kind").and_then(|v| v.as_str()).unwrap_or("");

    if kind == "docs-index" {
        let children = self.fetch_children(ctx).await?;
        if let Some(obj) = item.as_object_mut() {
            obj.insert("children".to_string(), json!(children));
        }
    }

    Ok(())
}

async fn fetch_children(
    &self,
    ctx: &ContentDataContext<'_>,
) -> Result<Vec<serde_json::Value>> {
    let pool = ctx.db_pool();
    let source_name = ctx.source_name();
    let slug = ctx.slug();

    let prefix = if slug.is_empty() {
        String::new()
    } else {
        format!("{}/", slug)
    };

    let children = sqlx::query!(
        r#"
        SELECT slug, title, description, kind
        FROM markdown_content
        WHERE source_id = $1
          AND slug LIKE $2
          AND slug != $3
        ORDER BY title
        "#,
        source_name,
        format!("{}%", prefix),
        slug
    )
    .fetch_all(pool.as_ref())
    .await?;

    Ok(children
        .into_iter()
        .map(|row| json!({
            "slug": row.slug,
            "title": row.title,
            "description": row.description,
            "kind": row.kind,
        }))
        .collect())
}

Fetch and add related posts:

async fn enrich_content(
    &self,
    ctx: &ContentDataContext<'_>,
    item: &mut serde_json::Value,
) -> Result<()> {
    let related = self.fetch_related(ctx, item).await?;

    if let Some(obj) = item.as_object_mut() {
        obj.insert("related_posts".to_string(), json!(related));
    }

    Ok(())
}

async fn fetch_related(
    &self,
    ctx: &ContentDataContext<'_>,
    item: &serde_json::Value,
) -> Result<Vec<serde_json::Value>> {
    let pool = ctx.db_pool();
    let current_slug = item.get("slug").and_then(|v| v.as_str()).unwrap_or("");

    let related = sqlx::query!(
        r#"
        SELECT slug, title, description
        FROM markdown_content
        WHERE source_id = $1
          AND slug != $2
        ORDER BY published_at DESC
        LIMIT 3
        "#,
        ctx.source_name(),
        current_slug
    )
    .fetch_all(pool.as_ref())
    .await?;

    Ok(related
        .into_iter()
        .map(|row| json!({
            "slug": row.slug,
            "title": row.title,
            "description": row.description,
        }))
        .collect())
}

Step 5: Target Specific Sources

Use applies_to_sources() to only run for specific content sources:

fn applies_to_sources(&self) -> Vec<String> {
    vec!["documentation".to_string(), "guides".to_string()]
}

Return an empty vector to run for ALL sources:

fn applies_to_sources(&self) -> Vec<String> {
    vec![]
}

Step 6: Register in Extension

Add to your extension's content_data_providers():

impl Extension for WebExtension {
    fn content_data_providers(&self) -> Vec<Arc<dyn ContentDataProvider>> {
        vec![
            Arc::new(DocsContentDataProvider::new()),
        ]
    }
}

Step 7: Export from Module

Update enrichers/mod.rs:

mod docs;

pub use docs::DocsContentDataProvider;

Step 8: Use Enriched Data in Templates

The enriched data is available to PageDataProviders and ComponentRenderers:

async fn provide_page_data(&self, ctx: &PageContext<'_>) -> Result<Value> {
    let item = ctx.content_item()?;

    let children = item.get("children").cloned().unwrap_or(json!([]));

    Ok(json!({
        "CHILDREN": children,
    }))
}

Or in templates via Handlebars:

{{#if children}}
<nav class="children">
    {{#each children}}
    <a href="/{{this.slug}}">{{this.title}}</a>
    {{/each}}
</nav>
{{/if}}

Step 9: Test

Run the publish pipeline:

systemprompt infra jobs run publish_pipeline

Check generated pages for enriched data.

Complete Example

use systemprompt::extension::prelude::{ContentDataContext, ContentDataProvider};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::json;

pub struct DocsContentDataProvider;

impl DocsContentDataProvider {
    pub fn new() -> Self {
        Self
    }

    async fn fetch_children(
        &self,
        ctx: &ContentDataContext<'_>,
    ) -> Result<Vec<serde_json::Value>> {
        let pool = ctx.db_pool();
        let source_name = ctx.source_name();
        let slug = ctx.slug();

        let prefix = if slug.is_empty() {
            String::new()
        } else {
            format!("{}/", slug)
        };

        let children = sqlx::query!(
            r#"
            SELECT slug, title, description, kind
            FROM markdown_content
            WHERE source_id = $1
              AND slug LIKE $2
              AND slug != $3
            ORDER BY title
            "#,
            source_name,
            format!("{}%", prefix),
            slug
        )
        .fetch_all(pool.as_ref())
        .await?;

        Ok(children
            .into_iter()
            .map(|row| json!({
                "slug": row.slug,
                "title": row.title,
                "description": row.description,
                "kind": row.kind,
            }))
            .collect())
    }
}

#[async_trait]
impl ContentDataProvider for DocsContentDataProvider {
    fn provider_id(&self) -> &'static str {
        "docs-content-enricher"
    }

    fn applies_to_sources(&self) -> Vec<String> {
        vec!["documentation".to_string()]
    }

    async fn enrich_content(
        &self,
        ctx: &ContentDataContext<'_>,
        item: &mut serde_json::Value,
    ) -> Result<()> {
        let kind = item.get("kind").and_then(|v| v.as_str()).unwrap_or("");

        if kind == "docs-index" {
            let children = self.fetch_children(ctx).await?;
            if let Some(obj) = item.as_object_mut() {
                obj.insert("children".to_string(), json!(children));
            }
        }

        Ok(())
    }
}

Checklist

  • Created provider struct
  • Implemented ContentDataProvider trait
  • Set correct provider_id()
  • Configured applies_to_sources() targeting
  • Implemented enrich_content() logic
  • Added database queries for enrichment
  • Registered in extension
  • Exported from module
  • Verified enriched data available in providers
  • Tested with publish pipeline