Applies to: All crates in extensions/
Core Principle
Extensions implement the unified Extension trait. Use trait-based polymorphism, not inherent methods.
Directory Structure
extensions/{name}/
├── Cargo.toml # Crate manifest
├── schema/ # SQL migrations (if database)
│ ├── 001_first_table.sql
│ └── 002_second_table.sql
└── src/
├── lib.rs # Public exports
├── extension.rs # Extension trait impl
├── config.rs # ExtensionConfig impl (optional)
├── error.rs # ExtensionError impl
├── models/ # Domain types
├── repository/ # Data access
├── services/ # Business logic
├── api/ # HTTP routes
└── jobs/ # Background tasks
Reference: See
extensions/web/for a complete working example.
Required Structure
-
Cargo.tomlexists with correct dependencies -
src/lib.rsexports public API -
src/extension.rsimplementsExtensiontrait -
src/config.rsimplementsExtensionConfigtrait (if config needed) -
src/error.rsimplementsExtensionErrortrait -
schema/directory with numbered SQL migrations (if using database)
Cargo.toml
- Package name follows
systemprompt-{name}-extensionpattern - Core dependencies via git:
systemprompt-modelssystemprompt-identifierssystemprompt-traitssystemprompt-core-database
- No forbidden dependencies (see boundaries)
-
[lints] workspace = truefor shared lint config
Extension Trait Implementation
Extensions must implement the unified Extension trait:
use systemprompt_traits::{Extension, ExtensionContext, ExtensionMetadata, SchemaDefinition};
use std::sync::Arc;
#[derive(Debug, Default, Clone)]
pub struct MyExtension;
impl Extension for MyExtension {
fn metadata(&self) -> ExtensionMetadata {
ExtensionMetadata {
id: "my_extension",
name: "My Extension",
version: env!("CARGO_PKG_VERSION"),
priority: 100,
dependencies: vec![],
}
}
fn schemas(&self) -> Vec<SchemaDefinition> {
vec![
SchemaDefinition::inline("table", include_str!("../schema/001_table.sql")),
]
}
fn router(&self, ctx: &ExtensionContext) -> Option<Router> {
let pool = ctx.database().postgres_pool()?;
Some(api::router(pool))
}
fn jobs(&self) -> Vec<Arc<dyn Job>> {
vec![Arc::new(MyJob)]
}
fn page_prerenderers(&self) -> Vec<Arc<dyn PagePrerenderer>> {
vec![Arc::new(MyPagePrerenderer)]
}
fn page_data_providers(&self) -> Vec<Arc<dyn PageDataProvider>> {
vec![Arc::new(MyDataProvider)]
}
fn component_renderers(&self) -> Vec<Arc<dyn ComponentRenderer>> {
vec![Arc::new(MyComponent)]
}
}
register_extension!(MyExtension);
Checklist
- Implements
Extensiontrait (NOT just inherent methods) -
metadata()returns unique ID, name, version -
schemas()returns list ofSchemaDefinition(if using database) -
router()returnsOption<Router>viaExtensionContext -
jobs()returns list ofArc<dyn Job>(if background tasks) -
page_prerenderers()returns list ofArc<dyn PagePrerenderer>(if rendering pages) -
page_data_providers()returns list ofArc<dyn PageDataProvider>(if providing page data) -
component_renderers()returns list ofArc<dyn ComponentRenderer>(if rendering components) - Single
register_extension!call
ExtensionConfig Trait Implementation (If Config Needed)
Extensions with configuration implement ExtensionConfig using the type-state pattern:
use serde::Deserialize;
use std::path::{Path, PathBuf};
use systemprompt::extension::typed::{ExtensionConfig, ExtensionConfigErrors};
use url::Url;
#[derive(Debug, Deserialize)]
pub struct MyConfigRaw {
pub data_path: String,
pub api_url: String,
}
#[derive(Debug, Clone)]
pub struct MyConfigValidated {
data_path: PathBuf,
api_url: Url,
}
impl ExtensionConfig for MyExtension {
type Raw = MyConfigRaw;
type Validated = MyConfigValidated;
const PREFIX: &'static str = "my_extension";
fn validate(raw: Self::Raw, base_path: &Path) -> Result<Self::Validated, ExtensionConfigErrors> {
let mut errors = ExtensionConfigErrors::new(Self::PREFIX);
let path = base_path.join(&raw.data_path);
if !path.exists() {
errors.push_with_path("data_path", "Path does not exist", &path);
}
let url = Url::parse(&raw.api_url)
.map_err(|e| errors.push("api_url", e.to_string()))
.unwrap_or_else(|_| Url::parse("https://invalid").unwrap());
errors.into_result(MyConfigValidated {
data_path: path.canonicalize().unwrap_or(path),
api_url: url,
})
}
}
register_config_extension!(MyExtension);
Checklist
-
Rawtype has#[derive(Deserialize)]withStringfor paths/URLs -
Validatedtype hasPathBuf,Url, typed IDs (NODeserialize) -
validate()consumesRawand producesValidated - All paths validated to exist (for enabled features)
- All URLs parsed and scheme validated
- All errors collected (not just first failure)
-
register_config_extension!call added - Config stored in profile under
extensions.{PREFIX}
ExtensionError Trait Implementation
Error types must implement ExtensionError for consistent handling:
use systemprompt_traits::ExtensionError;
use thiserror::Error;
use axum::http::StatusCode;
#[derive(Error, Debug)]
pub enum MyExtensionError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Database: {0}")]
Database(#[from] sqlx::Error),
#[error("Validation: {0}")]
Validation(String),
}
impl ExtensionError for MyExtensionError {
fn code(&self) -> &'static str {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Database(_) => "DATABASE_ERROR",
Self::Validation(_) => "VALIDATION_ERROR",
}
}
fn status(&self) -> StatusCode {
match self {
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Validation(_) => StatusCode::BAD_REQUEST,
}
}
fn is_retryable(&self) -> bool {
matches!(self, Self::Database(_))
}
}
Checklist
- Uses
thiserrorfor error derivation - Implements
ExtensionErrortrait -
code()returns machine-readable error code -
status()returns appropriate HTTP status -
is_retryable()indicates transient errors - Implements
From<sqlx::Error>for database errors
Repository Quality
- All queries use SQLX macros (
query!,query_as!,query_scalar!) - No runtime query strings (
sqlx::query()without!) - No business logic in repositories
- Typed IDs used (not raw strings)
- Pool is
Arc<PgPool> - Column casts for typed IDs:
id as "id: ContentId" - Uses
COLUMNSconstant for DRY queries
impl Content {
pub const COLUMNS: &'static str = r#"
id as "id: ContentId", slug, title, description, body
"#;
}
impl ContentRepository {
pub async fn get_by_id(&self, id: &ContentId) -> Result<Option<Content>> {
let query = format!("SELECT {} FROM content WHERE id = $1", Content::COLUMNS);
sqlx::query_as::<_, Content>(&query)
.bind(id.as_str())
.fetch_optional(&*self.pool)
.await
}
}
Extending Core Entities
When extensions need to add custom metadata to entities (like content), use extension-owned tables rather than modifying core tables.
Pattern 1: Companion Table (1-to-1)
For adding metadata fields to an existing entity:
-- schema/009_content_metadata.sql
CREATE TABLE IF NOT EXISTS content_custom_metadata (
id TEXT PRIMARY KEY,
content_id TEXT NOT NULL UNIQUE,
custom_field_1 JSONB NOT NULL DEFAULT '[]'::jsonb,
custom_field_2 JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_content_metadata_content
FOREIGN KEY (content_id) REFERENCES markdown_content(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_content_custom_metadata_content_id
ON content_custom_metadata(content_id);
Key elements:
UNIQUEconstraint oncontent_idenforces 1-to-1 relationshipON DELETE CASCADEmaintains referential integrity- Extension owns the table lifecycle
Pattern 2: Junction Table (Many-to-Many)
For linking entities together:
-- schema/010_content_tags.sql
CREATE TABLE IF NOT EXISTS content_tags (
content_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (content_id, tag_id),
FOREIGN KEY (content_id) REFERENCES markdown_content(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
Pattern 3: ALTER TABLE (Extension-Owned Tables Only)
When the extension owns the base table, use ALTER TABLE:
-- schema/009_content_related_metadata.sql
ALTER TABLE markdown_content
ADD COLUMN IF NOT EXISTS after_reading_this JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS related_playbooks JSONB NOT NULL DEFAULT '[]'::jsonb;
Use this pattern when:
- Your extension owns the base table (e.g.,
markdown_contentin web extension) - Fields are tightly coupled to the entity
- You control the ingestion pipeline
Checklist
- Never modify core-owned tables directly
- Use foreign keys with
ON DELETE CASCADE - Use
UNIQUEconstraint for 1-to-1 relationships - Number schema files sequentially (001, 002, etc.)
- Register all schemas in
Extension::schemas() - Create indexes for foreign key columns
Service Quality
- Repositories injected via constructor
- No direct SQL in services
- Errors mapped to domain error types
- Structured logging with
tracing - Business logic contained in services, not handlers
API Quality
- Handlers follow: extract -> delegate -> respond
- No business logic in handlers
- No direct repository access from handlers
- Service called for all operations
- Proper error conversion using
ExtensionError::status() - Typed request/response models
Job Quality (if applicable)
- Implements
Jobtrait fromsystemprompt_traits -
name()returns unique job identifier -
description()returns human-readable description -
schedule()returns valid cron expression (default) -
execute()usesctx.db_pool::<PgPool>()? - Registered via
Extension::jobs()method - Uses services for business logic
use systemprompt_traits::{Job, JobContext, JobResult};
#[derive(Debug, Clone, Copy, Default)]
pub struct MyJob;
#[async_trait::async_trait]
impl Job for MyJob {
fn name(&self) -> &'static str { "my_job" }
fn description(&self) -> &'static str { "Does something" }
fn schedule(&self) -> &'static str { "0 0 * * * *" }
async fn execute(&self, ctx: &JobContext) -> anyhow::Result<JobResult> {
let pool = ctx.db_pool::<PgPool>()
.ok_or_else(|| anyhow::anyhow!("DB not available"))?;
Ok(JobResult::success())
}
}
Jobs are configured in YAML (schedule override):
scheduler:
jobs:
- extension: my_extension
job: my_job
schedule: "0 */15 * * * *"
enabled: true
Page Prerenderer (if rendering pages)
Extensions can own and render pages by implementing PagePrerenderer:
use std::path::PathBuf;
use anyhow::Result;
use async_trait::async_trait;
use systemprompt_provider_contracts::{PagePrepareContext, PagePrerenderer, PageRenderSpec};
const PAGE_TYPE: &str = "docs-index";
const TEMPLATE_NAME: &str = "docs-index";
const OUTPUT_FILE: &str = "docs/index.html";
#[derive(Debug, Clone, Copy, Default)]
pub struct DocsIndexPrerenderer;
#[async_trait]
impl PagePrerenderer for DocsIndexPrerenderer {
fn page_type(&self) -> &str {
PAGE_TYPE
}
fn priority(&self) -> u32 {
100 // Lower = earlier execution
}
async fn prepare(&self, ctx: &PagePrepareContext<'_>) -> Result<Option<PageRenderSpec>> {
let base_data = serde_json::json!({
"site": ctx.web_config,
"page_title": "Documentation"
});
Ok(Some(PageRenderSpec::new(
TEMPLATE_NAME,
base_data,
PathBuf::from(OUTPUT_FILE),
)))
}
}
Checklist
- Implements
PagePrerenderertrait -
page_type()returns unique page identifier -
priority()returns render order (100 is default) -
prepare()returnsPageRenderSpecwith template, data, output path - Return
Ok(None)to skip rendering (feature disabled, template missing) - Registered via
Extension::page_prerenderers()method - Template exists in
services/web/templates/
Page Data Provider (if providing data to pages)
Extensions can provide data to pages without owning the prerender:
use anyhow::Result;
use async_trait::async_trait;
use serde_json::Value;
use systemprompt_template_provider::{PageContext, PageDataProvider};
#[derive(Debug, Clone, Copy, Default)]
pub struct MyDataProvider;
#[async_trait]
impl PageDataProvider for MyDataProvider {
fn provider_id(&self) -> &str {
"my-data"
}
fn applies_to_pages(&self) -> Vec<String> {
vec!["homepage".to_string(), "docs-index".to_string()]
}
async fn provide_page_data(&self, ctx: &PageContext<'_>) -> Result<Value> {
Ok(serde_json::json!({
"my_field": "value",
"nested": { "data": 123 }
}))
}
}
Checklist
- Implements
PageDataProvidertrait -
provider_id()returns unique provider identifier -
applies_to_pages()returns list of page types this provider serves -
provide_page_data()returns JSON data to merge into page context - Registered via
Extension::page_data_providers()method - Data is merged recursively with base page data
Component Renderer (if rendering HTML fragments)
Extensions can render HTML fragments for pages:
use anyhow::Result;
use async_trait::async_trait;
use systemprompt_template_provider::{ComponentContext, ComponentRenderer, RenderedComponent};
#[derive(Debug, Clone, Copy, Default)]
pub struct HeroComponent;
#[async_trait]
impl ComponentRenderer for HeroComponent {
fn component_id(&self) -> &str {
"hero-section"
}
fn variable_name(&self) -> &str {
"HERO_HTML"
}
fn applies_to(&self) -> Vec<String> {
vec!["homepage".to_string()]
}
async fn render(&self, ctx: &ComponentContext<'_>) -> Result<RenderedComponent> {
let html = format!(
r#"<section class="hero"><h1>{}</h1></section>"#,
ctx.web_config.branding.display_sitename
);
Ok(RenderedComponent::new("HERO_HTML", html))
}
}
Checklist
- Implements
ComponentRenderertrait -
component_id()returns unique component identifier -
variable_name()returns template variable name (e.g.,HERO_HTML) -
applies_to()returns list of page types this component serves -
render()returnsRenderedComponentwith variable name and HTML - Registered via
Extension::component_renderers()method - HTML is inserted into page data under the variable name
Model Quality
- All IDs use typed wrappers from
systemprompt_identifiers - No
Stringfor domain identifiers -
DateTime<Utc>for timestamps - Builders for types with 3+ fields
- Derive ordering:
Debug, Clone, PartialEq, Eq, Serialize, Deserialize
Boundary Rules
- No entry layer imports (
systemprompt-core-api) - No app layer imports (
systemprompt-core-scheduler) - No direct imports of core domain crates
- Only
shared/andinfra/dependencies from core - Other extensions imported via public API only
- Extension lives in
extensions/, notservices/
Idiomatic Rust
- Iterator chains over imperative loops
-
?operator for error propagation - No unnecessary
.clone() -
impl Into<T>for flexible APIs - Combinators (
map,and_then,ok_or) over match - Unified
Extensiontrait (not multiple separate traits) -
COLUMNSconstant for SQL (not repeated strings)
Code Quality
- File length <= 300 lines
- Function length <= 75 lines
- Cognitive complexity <= 15
- Function parameters <= 5
- No
unsafe - No
unwrap()/panic!() - No inline comments (
//) - No TODO/FIXME/HACK
-
cargo clippy -p {crate} -- -D warningspasses -
cargo fmt -p {crate} -- --checkpasses
Quick Reference
| Task | Command |
|---|---|
| Build | cargo build -p systemprompt-{name}-extension |
| Test | cargo test -p systemprompt-{name}-extension |
| Lint | cargo clippy -p systemprompt-{name}-extension -- -D warnings |
| Format | cargo fmt -p systemprompt-{name}-extension -- --check |
Reference Implementations
| Concept | Location |
|---|---|
| Extension trait | extensions/web/src/extension.rs |
| ExtensionError | extensions/web/src/error.rs |
| Repository | extensions/web/src/repository/ |
| Service | extensions/web/src/services/ |
| API | extensions/web/src/api/ |
| Jobs | extensions/web/src/jobs/ |