Library Extensions

Build library extensions that compile into the main binary: API routes, database schemas, background jobs, and providers.

Library extensions compile directly into your main binary. They implement the Extension trait to add capabilities: HTTP routes, database schemas, background jobs, and providers. At runtime, the framework discovers these extensions and calls their lifecycle hooks to wire everything together.

When to Use Library Extensions

Choose a library extension when you need:

  • Shared database connections - Access the same connection pool as the runtime
  • Coordinated lifecycle - Schemas migrate together, routes mount together
  • Single binary deployment - Everything in one executable
  • Internal APIs - Endpoints that serve the web frontend

Choose an MCP server (standalone binary) when you need:

  • Independent scaling - Run multiple instances of the tool server
  • Isolation - Separate process with its own resource limits
  • External tool access - AI agents calling your tools via MCP protocol

Choose a CLI extension (standalone binary) when you need:

  • Shell scripting - Commands that agents invoke via subprocess
  • External integrations - Tools that connect to third-party services
  • Utility commands - One-off operations like data migration

The Extension Trait

Library extensions implement a single Extension trait with 30+ methods. Each method returns data describing a capability. The runtime calls these methods at startup to discover what your extension provides.

use systemprompt::extension::prelude::*;
use systemprompt::traits::Job;
use std::sync::Arc;

#[derive(Debug, Default)]
pub struct MyExtension;

impl Extension for MyExtension {
    fn metadata(&self) -> ExtensionMetadata {
        ExtensionMetadata {
            id: "my-extension",
            name: "My Extension",
            version: env!("CARGO_PKG_VERSION"),
        }
    }

    fn schemas(&self) -> Vec<SchemaDefinition> {
        vec![
            SchemaDefinition::inline("my_tables", include_str!("../schema/001_tables.sql")),
        ]
    }

    fn router(&self, ctx: &dyn ExtensionContext) -> Option<ExtensionRouter> {
        let db = ctx.database();
        let pool = db.as_any().downcast_ref::<Database>()?.pool()?;
        let router = crate::api::router(pool);
        Some(ExtensionRouter::new(router, "/api/v1/my-extension"))
    }

    fn jobs(&self) -> Vec<Arc<dyn Job>> {
        vec![Arc::new(crate::jobs::MyJob)]
    }

    fn priority(&self) -> u32 {
        50
    }

    fn dependencies(&self) -> Vec<&'static str> {
        vec!["users"]
    }
}

register_extension!(MyExtension);

All trait methods have sensible defaults. You implement only the hooks your extension needs. For the complete reference, see Extension Trait Reference.

Extension Points

Library extensions can provide:

Hook Purpose
schemas() Database table definitions
migrations() Schema migration SQL
router() HTTP API routes
jobs() Background tasks
config_prefix() Configuration namespace
llm_providers() LLM implementations
tool_providers() MCP tool implementations
page_data_providers() Template data for pages
component_renderers() HTML fragment generators
content_data_providers() Content enrichment
template_data_extenders() Final template modifications
page_prerenderers() Static page generators
frontmatter_processors() Frontmatter parsing
rss_feed_providers() RSS feed generation
sitemap_providers() Sitemap generation
roles() RBAC role definitions
required_assets() CSS/JS asset declarations

Project Structure

extensions/my-extension/
├── Cargo.toml
├── schema/                 # SQL migrations
│   ├── 001_tables.sql
│   └── 002_indexes.sql
└── src/
    ├── lib.rs              # Public exports
    ├── extension.rs        # Extension trait implementation
    ├── api/
    │   ├── mod.rs          # Router definition
    │   └── handlers/       # HTTP handlers
    ├── models/             # Data types
    ├── repository/         # Data access layer
    ├── services/           # Business logic
    └── jobs/               # Background tasks

Database Schemas

Return SQL definitions from the schemas() method. The runtime executes these during database initialization, ordered by migration_weight().

pub const SCHEMA_TABLES: &str = include_str!("../schema/001_tables.sql");
pub const SCHEMA_INDEXES: &str = include_str!("../schema/002_indexes.sql");

fn schemas(&self) -> Vec<SchemaDefinition> {
    vec![
        SchemaDefinition::inline("my_tables", SCHEMA_TABLES),
        SchemaDefinition::inline("my_indexes", SCHEMA_INDEXES),
    ]
}

fn migration_weight(&self) -> u32 {
    50  // Lower = runs earlier
}

Schema SQL example:

-- schema/001_tables.sql
CREATE TABLE IF NOT EXISTS my_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_my_items_name ON my_items(name);

Use CREATE TABLE IF NOT EXISTS and similar idempotent patterns. Schemas may execute multiple times across restarts.

HTTP Routes

Return an Axum router from the router() method. The runtime mounts it at the path you specify.

fn router(&self, ctx: &dyn ExtensionContext) -> Option<ExtensionRouter> {
    let db_handle = ctx.database();
    let db = db_handle.as_any().downcast_ref::<Database>()?;
    let pool = db.pool()?;

    let router = Router::new()
        .route("/items", get(list_items).post(create_item))
        .route("/items/:id", get(get_item).put(update_item).delete(delete_item))
        .with_state(AppState { pool });

    Some(ExtensionRouter::new(router, "/api/v1/my-extension"))
}

Handler example:

use axum::{extract::{Path, State}, Json};

async fn get_item(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<Item>, AppError> {
    let item = sqlx::query_as!(Item, "SELECT * FROM my_items WHERE id = $1", id)
        .fetch_one(state.pool.as_ref())
        .await?;
    Ok(Json(item))
}

Background Jobs

Return job implementations from the jobs() method. Jobs implement the Job trait and register for scheduling.

fn jobs(&self) -> Vec<Arc<dyn Job>> {
    vec![
        Arc::new(CleanupJob),
        Arc::new(SyncJob),
    ]
}

See Job Extension for implementation details.

Dependencies

Declare dependencies on other extensions:

fn dependencies(&self) -> Vec<&'static str> {
    vec!["users", "oauth"]
}

The runtime validates dependencies exist and detects circular references. Extensions load in priority order (lower priority values load first).

Registration

After implementing the Extension trait, register with the register_extension! macro:

register_extension!(MyExtension);

Then reference your extension in the template's src/lib.rs to prevent linker stripping:

pub use my_extension_crate as my_extension;

pub fn __force_extension_link() {
    let _ = core::hint::black_box(&web::WebExtension::PREFIX);
    let _ = core::hint::black_box(&my_extension::MyExtension::PREFIX);
}

See Registration for details.

Cargo Configuration

[package]
name = "my-extension"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["rlib"]

[dependencies]
systemprompt = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
sqlx = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }

Use workspace dependencies from the root Cargo.toml to ensure version consistency across all extensions.

Building

# Build all workspace members
cargo build

# Build release
cargo build --release

# Build specific extension
cargo build -p my-extension