Building Extensions

Step-by-step guide to building custom SystemPrompt extensions

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