Building Extensions
Step-by-step guide to building custom SystemPrompt extensions
On this page
This guide walks through creating a custom SystemPrompt extension from scratch.
Prerequisites
- Rust 1.75+
- SystemPrompt project (from template or custom)
- Basic understanding of the extension system
Project Setup
Create your extension in the extensions/ directory:
cd your-project/extensions
cargo new --lib my-extension
Update Cargo.toml:
[package]
name = "my-extension"
version = "0.1.0"
edition = "2021"
[dependencies]
systemprompt-extension = { path = "../../core/crates/shared/extension" }
systemprompt-models = { path = "../../core/crates/shared/models" }
systemprompt-database = { path = "../../core/crates/infra/database" }
axum = "0.8"
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
Step 1: Define the Extension
Create src/lib.rs:
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 other extension IDs if needed
}
}
// Register with the runtime
register_extension!(MyExtension);
Step 2: Add Database Schema
Create schema/items.sql:
CREATE TABLE IF NOT EXISTS my_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_my_items_tenant ON my_items(tenant_id);
Implement SchemaExtension:
impl SchemaExtension for MyExtension {
fn schemas(&self) -> Vec<SchemaDefinition> {
vec![
SchemaDefinition::new(
"my_items",
include_str!("../schema/items.sql")
)
]
}
}
register_schema_extension!(MyExtension);
Step 3: Create Models
Create src/models.rs:
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use systemprompt_identifiers::{TenantId, new_id};
new_id!(ItemId);
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Item {
pub id: ItemId,
pub tenant_id: TenantId,
pub name: String,
pub description: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateItem {
pub name: String,
pub description: Option<String>,
}
Step 4: Create Repository
Create src/repository.rs:
use crate::models::{Item, ItemId, CreateItem};
use systemprompt_identifiers::TenantId;
use sqlx::PgPool;
use std::sync::Arc;
pub struct ItemRepository {
pool: Arc<PgPool>,
}
impl ItemRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
pub async fn find_by_id(
&self,
tenant_id: TenantId,
id: ItemId,
) -> Result<Option<Item>, sqlx::Error> {
sqlx::query_as!(Item,
r#"SELECT * FROM my_items
WHERE tenant_id = $1 AND id = $2"#,
tenant_id.as_uuid(),
id.as_uuid()
)
.fetch_optional(&*self.pool)
.await
}
pub async fn list(
&self,
tenant_id: TenantId,
) -> Result<Vec<Item>, sqlx::Error> {
sqlx::query_as!(Item,
r#"SELECT * FROM my_items
WHERE tenant_id = $1
ORDER BY created_at DESC"#,
tenant_id.as_uuid()
)
.fetch_all(&*self.pool)
.await
}
pub async fn create(
&self,
tenant_id: TenantId,
input: CreateItem,
) -> Result<Item, sqlx::Error> {
sqlx::query_as!(Item,
r#"INSERT INTO my_items (tenant_id, name, description)
VALUES ($1, $2, $3)
RETURNING *"#,
tenant_id.as_uuid(),
input.name,
input.description
)
.fetch_one(&*self.pool)
.await
}
}
Step 5: Add API Routes
Create src/handlers.rs:
use crate::{models::CreateItem, repository::ItemRepository};
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use systemprompt_extension::ExtensionContext;
use systemprompt_identifiers::TenantId;
use uuid::Uuid;
pub async fn list_items(
State(ctx): State<ExtensionContext>,
Path(tenant_id): Path<Uuid>,
) -> Result<Json<Vec<crate::models::Item>>, StatusCode> {
let repo = ItemRepository::new(ctx.database.clone());
let items = repo
.list(TenantId::from(tenant_id))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(items))
}
pub async fn create_item(
State(ctx): State<ExtensionContext>,
Path(tenant_id): Path<Uuid>,
Json(input): Json<CreateItem>,
) -> Result<Json<crate::models::Item>, StatusCode> {
let repo = ItemRepository::new(ctx.database.clone());
let item = repo
.create(TenantId::from(tenant_id), input)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(item))
}
Implement ApiExtension:
use axum::{Router, routing::{get, post}};
impl ApiExtension for MyExtension {
fn router(&self, ctx: &ExtensionContext) -> Option<Router> {
Some(Router::new()
.route("/tenants/:tenant_id/items", get(handlers::list_items))
.route("/tenants/:tenant_id/items", post(handlers::create_item))
.with_state(ctx.clone()))
}
fn prefix(&self) -> Option<&'static str> {
Some("/api/v1/my-extension")
}
}
register_api_extension!(MyExtension);
Step 6: Add to Build
Update your main Cargo.toml to include the extension:
[dependencies]
my-extension = { path = "extensions/my-extension" }
Update src/main.rs to use the extension:
// The extension registers itself via inventory
// Just ensure the crate is linked
use my_extension as _;
Step 7: Test
Build and run:
just build
systemprompt infra db migrate
just start
Test your endpoints:
# Create an item
curl -X POST http://localhost:3000/api/v1/my-extension/tenants/YOUR_TENANT_ID/items \
-H "Content-Type: application/json" \
-d '{"name": "Test Item", "description": "A test"}'
# List items
curl http://localhost:3000/api/v1/my-extension/tenants/YOUR_TENANT_ID/items
Next Steps
- Add background jobs for async processing
- Add configuration validation
- Add tests for your extension
- See Building MCP Servers for tool providers