| name | extension-building |
| description | Build library extensions for systemprompt.io with Rust - architecture, traits, schemas, API routes, jobs, and code review |
Extension Building
Build library extensions for systemprompt.io. Reference implementation: extensions/web/.
Core Principle
If it's Rust code, it's an extension. If it's YAML/Markdown, it's a service.
| Category | Format | Location |
|---|
| Extensions | .rs | /extensions/ |
| Services | YAML/Markdown | /services/ |
The core/ directory is a git submodule. Never modify it.
Project Structure
systemprompt-template/
├── core/ # READ-ONLY submodule
├── extensions/ # ALL Rust code
│ ├── web/ # Reference implementation
│ ├── cli/ # CLI extensions
│ └── mcp/ # MCP servers
├── services/ # YAML/Markdown only
│ ├── agents/ # Agent definitions
│ ├── config/ # Configuration
│ ├── content/ # Markdown content
│ ├── skills/ # Skills
│ └── web/ # Theme config
├── profiles/ # Environment configs
└── src/main.rs # Server entry
Layer Model
┌─────────────────────────────────────────────┐
│ src/main.rs │
│ Loads config, connects DB, mounts routers │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ extensions/ │
│ Schemas, Models, Repos, Services, API, Jobs │
└──────────────────────┬──────────────────────┘
│
┌──────────────────────▼──────────────────────┐
│ services/ (config) │
│ Agent YAML, AI config, schedules, content │
└──────────────────────┬──────────────────────┘
│ imports
┌──────────────────────▼──────────────────────┐
│ core/ (read-only) │
│ Traits, Models, IDs, DB, Logging, Security │
└─────────────────────────────────────────────┘
Extension Internal Layers
┌─────────────────────────────┐
│ API (handlers) │ HTTP requests
└─────────────┬───────────────┘
│ calls
┌─────────────▼───────────────┐
│ Services │ Business logic
└─────────────┬───────────────┘
│ calls
┌─────────────▼───────────────┐
│ Repository │ SQL queries
└─────────────┬───────────────┘
│ uses
┌─────────────▼───────────────┐
│ Models │ Domain types
└─────────────────────────────┘
Rules:
- API -> Services -> Repository (never skip)
- No SQL in services
- No business logic in repositories
- Jobs use services, not direct repository access
Creating an Extension
Directory Structure
extensions/my-extension/
├── Cargo.toml
├── build.rs
├── schema/
│ ├── 001_tables.sql
│ └── migrations/
│ └── 001_add_status.sql
└── src/
├── lib.rs
├── extension.rs
├── error.rs
├── models/
├── repository/
├── services/
├── api/
└── jobs/
Cargo.toml
[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 }
thiserror = { workspace = true }
tracing = { workspace = true }
[build-dependencies]
systemprompt-extension = { workspace = true }
src/lib.rs
mod error;
mod extension;
mod models;
mod repository;
mod services;
mod api;
mod jobs;
pub use error::MyExtensionError;
pub use extension::MyExtension;
pub const PREFIX: &str = "my-extension";
src/extension.rs
use std::sync::Arc;
use systemprompt::extension::prelude::*;
use crate::api;
use crate::jobs::CleanupJob;
#[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 priority(&self) -> u32 {
50
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["users"]
}
fn schemas(&self) -> Vec<SchemaDefinition> {
vec![
SchemaDefinition::inline("my_tables", include_str!("../schema/001_tables.sql")),
]
}
fn migration_weight(&self) -> u32 {
50
}
fn router(&self, ctx: &dyn ExtensionContext) -> Option<ExtensionRouter> {
let db = ctx.database();
let pool = db.as_any().downcast_ref::<Database>()?.pool()?;
Some(ExtensionRouter::new(api::router(pool), "/api/v1/my-extension"))
}
fn jobs(&self) -> Vec<Arc<dyn Job>> {
vec![Arc::new(CleanupJob)]
}
}
register_extension!(MyExtension);
src/error.rs
use axum::http::StatusCode;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyExtensionError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Validation error: {0}")]
Validation(String),
}
impl MyExtensionError {
pub fn code(&self) -> &'static str {
match self {
Self::NotFound(_) => "NOT_FOUND",
Self::Database(_) => "DATABASE_ERROR",
Self::Validation(_) => "VALIDATION_ERROR",
}
}
pub fn status(&self) -> StatusCode {
match self {
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Validation(_) => StatusCode::BAD_REQUEST,
}
}
}
impl axum::response::IntoResponse for MyExtensionError {
fn into_response(self) -> axum::response::Response {
let body = serde_json::json!({
"error": {
"code": self.code(),
"message": self.to_string(),
}
});
(self.status(), axum::Json(body)).into_response()
}
}
Database Schema
SQL Patterns
CREATE TABLE IF NOT EXISTS my_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
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);
CREATE INDEX IF NOT EXISTS idx_my_items_created ON my_items(created_at DESC);
Register Schema
fn schemas(&self) -> Vec<SchemaDefinition> {
vec![
SchemaDefinition::inline("my_items", include_str!("../schema/001_tables.sql")),
]
}
fn migration_weight(&self) -> u32 {
50
}
Migrations
Migration SQL lives in schema/migrations/NNN_<name>.sql files — never as a Rust
string literal. The crate's build.rs discovers them; extension_migrations!()
returns them. The version (NNN) and name come from the filename, so adding a
migration is adding one file with no Rust edit.
fn main() {
systemprompt_extension::build::emit_migrations();
}
fn migrations(&self) -> Vec<Migration> {
extension_migrations!()
}
ALTER TABLE my_items ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active';
Inline SQL strings and hand-written Migration::new(...) lists are rejected by
just lint-extensions. Add build.rs to the package include list so it ships
on publish.
Extending Core Tables
Use companion tables, not ALTER:
CREATE TABLE IF NOT EXISTS content_metadata (
id TEXT PRIMARY KEY,
content_id TEXT NOT NULL UNIQUE,
custom_field JSONB DEFAULT '{}',
CONSTRAINT fk_content
FOREIGN KEY (content_id) REFERENCES markdown_content(id) ON DELETE CASCADE
);
API Routes
Router
use axum::{Router, routing::{get, post, put, delete}};
use std::sync::Arc;
use sqlx::PgPool;
mod handlers;
#[derive(Clone)]
pub struct AppState {
pub pool: Arc<PgPool>,
}
pub fn router(pool: Arc<PgPool>) -> Router {
let state = AppState { pool };
Router::new()
.route("/items", get(handlers::list).post(handlers::create))
.route("/items/:id", get(handlers::get).put(handlers::update).delete(handlers::delete))
.with_state(state)
}
Handlers
use axum::{extract::{Path, State, Json}, http::StatusCode};
use uuid::Uuid;
pub async fn list(
State(state): State<AppState>,
) -> Result<Json<Vec<Item>>, MyExtensionError> {
let items = sqlx::query_as!(Item, "SELECT * FROM my_items ORDER BY created_at DESC")
.fetch_all(state.pool.as_ref())
.await?;
Ok(Json(items))
}
pub async fn get(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Item>, MyExtensionError> {
let item = sqlx::query_as!(Item, "SELECT * FROM my_items WHERE id = $1", id)
.fetch_optional(state.pool.as_ref())
.await?
.ok_or_else(|| MyExtensionError::NotFound(id.to_string()))?;
Ok(Json(item))
}
pub async fn create(
State(state): State<AppState>,
Json(input): Json<CreateInput>,
) -> Result<(StatusCode, Json<Item>), MyExtensionError> {
let item = sqlx::query_as!(
Item,
"INSERT INTO my_items (name, description) VALUES ($1, $2) RETURNING *",
input.name,
input.description
)
.fetch_one(state.pool.as_ref())
.await?;
Ok((StatusCode::CREATED, Json(item)))
}
Background Jobs
Job Implementation
use systemprompt_provider_contracts::{Job, JobContext, JobResult};
#[derive(Debug, Clone, Copy, Default)]
pub struct CleanupJob;
#[async_trait::async_trait]
impl Job for CleanupJob {
fn name(&self) -> &'static str {
"my-extension-cleanup"
}
fn description(&self) -> &'static str {
"Clean up expired items"
}
fn schedule(&self) -> &'static str {
"0 0 * * * *"
}
async fn execute(&self, ctx: &JobContext) -> anyhow::Result<JobResult> {
let pool = ctx.db_pool::<Arc<PgPool>>()
.ok_or_else(|| anyhow::anyhow!("Database not available"))?;
let deleted = sqlx::query!(
"DELETE FROM my_items WHERE created_at < NOW() - INTERVAL '30 days'"
)
.execute(&*pool)
.await?
.rows_affected();
tracing::info!(deleted = deleted, "Cleanup completed");
Ok(JobResult::success()
.with_stats(deleted, 0)
.with_message(format!("Deleted {} items", deleted)))
}
}
systemprompt::traits::submit_job!(&CleanupJob);
Cron Schedule (6-field)
| Schedule | Meaning |
|---|
0 0 * * * * | Every hour |
0 */15 * * * * | Every 15 minutes |
0 0 0 * * * | Daily at midnight |
0 30 2 * * * | Daily at 2:30 AM |
"" | Manual only |
Register Job
fn jobs(&self) -> Vec<Arc<dyn Job>> {
vec![Arc::new(CleanupJob)]
}
Workspace Registration
Add to root Cargo.toml
[workspace]
members = [
"extensions/my-extension",
]
[dependencies]
my-extension = { path = "extensions/my-extension" }
Link in src/lib.rs
pub use my_extension as my_ext;
pub fn __force_extension_link() {
let _ = core::hint::black_box(&my_ext::PREFIX);
}
Rust Standards
Idiomatic Patterns
let name = request.name.as_deref().map(str::trim);
let value = opt.unwrap_or_else(|| compute_default());
let result = input.ok_or_else(|| Error::Missing)?;
let valid_items: Vec<_> = items
.iter()
.filter(|item| item.is_active())
.map(|item| item.to_dto())
.collect();
Forbidden Constructs
| Construct | Resolution |
|---|
unsafe | Remove - forbidden |
unwrap() | Use ?, ok_or_else(), or expect() with message |
panic!() / todo!() | Return Result or implement |
Inline comments (//) | Delete - code documents itself |
Doc comments (///) | Delete - no rustdoc |
| Tests in source files | Move to separate test crate |
serde_json::Value | Define typed structs with #[derive(Deserialize)]. Allowed only at protocol/trait boundaries with justification comment |
Mandatory Patterns
- Typed identifiers: Use
ContentId, UserId, etc. from systemprompt_identifiers
- Logging: All via
tracing. No println! in library code
- Repository pattern: Services never execute queries directly. All SQL in repositories using SQLX macros
- SQLX macros only:
query!(), query_as!(), query_scalar!() - compile-time verified
- Error handling: Domain-specific errors with
thiserror
- DateTime:
DateTime<Utc> in Rust, TIMESTAMPTZ in PostgreSQL
- Builder pattern: Required for types with 3+ fields
Naming Conventions
| Prefix | Returns |
|---|
get_ | Result<T> - fails if missing |
find_ | Result<Option<T>> - may not exist |
list_ | Result<Vec<T>> |
create_ | Result<T> or Result<Id> |
update_ | Result<T> or Result<()> |
delete_ | Result<()> |
Code Limits
| Metric | Limit |
|---|
| Source file length | 300 lines |
| Cognitive complexity | 15 |
| Function length | 75 lines |
| Parameters | 5 |
Derive Ordering
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
Database Access Patterns
MCP Server Handlers
let pg_pool = db_pool.pool().ok_or_else(|| {
McpError::internal_error("Database pool not available", None)
})?;
let repo = ContentRepository::new(pg_pool);
Job Handlers
let db_pool = ctx.db_pool::<DbPool>()
.ok_or_else(|| anyhow::anyhow!("Database not available"))?;
let repo = ContentRepository::new(db_pool.pool().unwrap());
Page Data Providers
let Some(db) = ctx.db_pool::<Arc<Database>>() else {
return Ok(json!({ "data": "" }));
};
let Some(pool) = db.pool() else {
return Ok(json!({ "data": "" }));
};
Dependency Rules
Extensions CAN Import
systemprompt-models = { git = "..." }
systemprompt-identifiers = { git = "..." }
systemprompt-traits = { git = "..." }
systemprompt-core-database = { git = "..." }
systemprompt-blog-extension = { path = "../blog" }
Extensions CANNOT Import
systemprompt-core-api = { git = "..." }
systemprompt-core-scheduler = { git = "..." }
The systemprompt Umbrella Crate
Use a single dependency instead of multiple internal crates:
[dependencies]
systemprompt = { version = "0.1", features = ["api"] }
| Feature | Use Case |
|---|
core | Minimal extensions, CLI tools |
database | Extensions with database tables |
mcp | MCP servers with proc macros |
api | Full HTTP API extensions |
full | Complete applications |
Extension Checklist
Review Process
Review extensions as world-class idiomatic Rust. Generate status.md with:
- Verify required files exist
- Verify directory structure
- Read all
.rs files
- Execute checklist items
- Record violations as
file:line + type
- Verdict: COMPLIANT if zero violations
Quick Reference
| Task | Command |
|---|
| Build | cargo build -p systemprompt-{name}-extension |
| Test | cargo test -p systemprompt-{name}-extension |
| Lint | cargo clippy -p systemprompt-{name}-extension -- -D warnings |
| Format | cargo fmt --all |
| Clean build | cargo clean && cargo build |
| Verify | cargo run -- extensions list |
| Run migrations | systemprompt infra db migrate |
| Run job | systemprompt infra jobs run <name> |
Reference Implementations
| Concept | Location |
|---|
| Extension trait | extensions/web/src/extension.rs |
| ExtensionError | extensions/web/src/error.rs |
| Repository | extensions/web/src/repository/ |
| Service | extensions/web/src/services/ |
| API | extensions/web/src/api/ |
| Jobs | extensions/web/src/jobs/ |