Extension System
Build custom extensions on the SystemPrompt core
On this page
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:
- Add the dependency -
systemprompt = "0.x"in yourCargo.toml - Create your extension - Implement traits with your business logic
- Compile your binary - You own the executable
- 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
- Building Extensions Guide - Step-by-step tutorial
- Building MCP Servers - MCP server extensions
- Crate Reference - Core crate documentation