Extension System

Build custom extensions on the SystemPrompt core

SystemPrompt's extension system lets you add functionality without modifying core code. Extensions are discovered at compile time and registered via the inventory crate.

Ownership Model

SystemPrompt is a library, not a platform:

  1. Add the dependency - systemprompt = "0.x" in your Cargo.toml
  2. Create your extension - Implement traits with your business logic
  3. Compile your binary - You own the executable
  4. Deploy anywhere - No runtime dependencies on us

Your extensions never leave your machine. The compiled binary contains your code alongside SystemPrompt's infrastructure. We never see your source code or your business logic.

Why Rust? If your extension compiles, it runs. Rust's type system catches errors at compile time—no runtime reflection, no dynamic loading, no surprises in production.

Extension Traits

Trait Purpose
Extension Base trait - ID, name, version, dependencies
SchemaExtension Database table definitions
ApiExtension HTTP route handlers
ConfigExtensionTyped Configuration validation
JobExtension Background job definitions
ProviderExtension Custom LLM/tool providers

Basic Extension

Every extension implements the base Extension trait:

use systemprompt_extension::*;

pub struct MyExtension;

impl Extension for MyExtension {
    fn id(&self) -> &'static str {
        "my-extension"
    }

    fn name(&self) -> &'static str {
        "My Extension"
    }

    fn version(&self) -> &'static str {
        env!("CARGO_PKG_VERSION")
    }

    fn dependencies(&self) -> Vec<&'static str> {
        vec![]  // List extension IDs this depends on
    }
}

// Register the extension
register_extension!(MyExtension);

Schema Extension

Add database tables with SchemaExtension:

impl SchemaExtension for MyExtension {
    fn schemas(&self) -> Vec<SchemaDefinition> {
        vec![
            SchemaDefinition::new(
                "my_table",
                include_str!("../schema/my_table.sql")
            )
        ]
    }
}

register_schema_extension!(MyExtension);

The SQL file:

-- schema/my_table.sql
CREATE TABLE IF NOT EXISTS my_table (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_my_table_tenant ON my_table(tenant_id);

API Extension

Add HTTP routes with ApiExtension:

use axum::{Router, routing::get, Json};

impl ApiExtension for MyExtension {
    fn router(&self, ctx: &ExtensionContext) -> Option<Router> {
        let ctx = ctx.clone();

        Some(Router::new()
            .route("/my-endpoint", get(handler))
            .with_state(ctx))
    }

    fn prefix(&self) -> Option<&'static str> {
        Some("/api/v1/my-extension")
    }
}

async fn handler() -> Json<serde_json::Value> {
    Json(serde_json::json!({ "status": "ok" }))
}

register_api_extension!(MyExtension);

Config Extension

Validate configuration at startup:

use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct MyConfig {
    pub api_key: String,
    pub max_items: usize,
}

impl ConfigExtensionTyped for MyExtension {
    type Config = MyConfig;

    fn config_key(&self) -> &'static str {
        "my_extension"
    }

    fn validate(&self, config: &Self::Config) -> Result<(), ConfigError> {
        if config.max_items == 0 {
            return Err(ConfigError::validation("max_items must be > 0"));
        }
        Ok(())
    }
}

register_config_extension!(MyExtension);

Configuration in services/config/my_extension.yaml:

api_key: "sk-..."
max_items: 100

Job Extension

Add background jobs:

use systemprompt_extension::{Job, JobContext};

pub struct CleanupJob;

#[async_trait]
impl Job for CleanupJob {
    fn id(&self) -> &'static str {
        "my_extension:cleanup"
    }

    fn name(&self) -> &'static str {
        "Cleanup old records"
    }

    async fn execute(&self, ctx: &JobContext) -> Result<()> {
        // Job logic here
        Ok(())
    }
}

impl JobExtension for MyExtension {
    fn jobs(&self) -> Vec<Arc<dyn Job>> {
        vec![Arc::new(CleanupJob)]
    }
}

register_job_extension!(MyExtension);

Schedule the job in services/scheduler/jobs.yaml:

jobs:
  cleanup:
    cron: "0 0 * * *"
    task: "my_extension:cleanup"

Provider Extension

Add custom LLM or tool providers:

use systemprompt_provider_contracts::{LlmProvider, LlmRequest, LlmResponse};

pub struct MyLlmProvider;

#[async_trait]
impl LlmProvider for MyLlmProvider {
    fn id(&self) -> &'static str {
        "my-llm"
    }

    async fn complete(&self, request: LlmRequest) -> Result<LlmResponse> {
        // Call your LLM API
        todo!()
    }
}

impl ProviderExtension for MyExtension {
    fn llm_providers(&self) -> Vec<Arc<dyn LlmProvider>> {
        vec![Arc::new(MyLlmProvider)]
    }
}

register_provider_extension!(MyExtension);

Extension Context

Extensions receive an ExtensionContext with access to:

pub struct ExtensionContext {
    pub config: Arc<Config>,
    pub database: Arc<DatabasePool>,
    pub events: Arc<EventBus>,
    // ... other shared resources
}

Use it in your handlers:

async fn handler(
    State(ctx): State<ExtensionContext>,
) -> Result<Json<MyResponse>, AppError> {
    let pool = &ctx.database;

    let items = sqlx::query_as!(MyItem, "SELECT * FROM my_table")
        .fetch_all(pool.as_ref())
        .await?;

    Ok(Json(MyResponse { items }))
}

Project Structure

Recommended extension structure:

extensions/my-extension/
├── Cargo.toml
├── schema/
│   └── my_table.sql
└── src/
    ├── lib.rs          # Extension registration
    ├── config.rs       # Config types
    ├── models.rs       # Domain types
    ├── repository.rs   # Data access
    ├── services.rs     # Business logic
    ├── handlers.rs     # HTTP handlers
    └── jobs.rs         # Background jobs

Testing Extensions

#[cfg(test)]
mod tests {
    use super::*;
    use systemprompt_extension::testing::*;

    #[tokio::test]
    async fn test_extension() {
        let ctx = TestContext::new().await;

        // Run migrations
        MyExtension.migrate(&ctx.database).await.unwrap();

        // Test your extension
        // ...
    }
}

Next Steps