Plugin System
Temps is a modular monolith. Every feature — from the HTTP proxy to analytics to deployments — is a plugin that implements the TempsPlugin trait. This page is the end-to-end reference for writing plugins: how services are registered, how dependencies are wired, how routes are exposed, and how to test the whole thing.
Overview
A plugin is a self-contained unit that bundles four things:
- Services — the business logic layer (e.g.
BackupService,DeployerService). - Routes — Axum handlers exposed under a consistent URL prefix.
- OpenAPI schema — automatically merged into the global
/swagger-ui/docs. - Lifecycle hooks — a two-phase init so plugins can cross-reference each other's services.
Plugins talk to each other only through the service registry. They never import each other's internal modules, which keeps the dependency graph flat and the build fast.
Why a plugin system?
- Name
Modularity- Description
Each crate owns its services, errors, handlers, and migrations. Adding a feature means adding a crate, not touching 20 files.
- Name
Type-safe DI- Description
The service registry is keyed by
TypeId.require_service::<Arc<T>>()fails at startup with a clear error if a dependency is missing — never at request time.
- Name
Testability- Description
Services are constructed with
Arc<T>andArc<dyn Trait>dependencies, so tests inject mocks without a DI container.
- Name
OpenAPI out of the box- Description
Every plugin contributes its utoipa schemas. The global OpenAPI doc is the union of every registered plugin.
- Name
Zero runtime overhead- Description
Dispatch is a single
Arcclone at startup. No reflection, no dynamic loading, no plugin FFI.
Example Plugins
The easiest way to learn the plugin system is to read working code. Official plugins live in a dedicated monorepo at gotempsh/plugins — each one is a standalone Cargo crate you can clone, build, and hack on.
- Name
example-plugin- Description
A minimal "hello world" plugin with a web UI bundle. The shortest path to understanding the plugin protocol, stdin/stdout channel, and UI asset layout. View source →
- Name
lighthouse-plugin- Description
Runs Google Lighthouse audits after every successful deployment and tracks Core Web Vitals over time. Demonstrates deploy-event subscriptions and background workers. View source →
- Name
indexnow-plugin- Description
Automatically pings the IndexNow API (Bing, Yandex, Seznam) with the list of URLs changed in each deployment. Good template for search-engine integrations. View source →
- Name
google-indexing-plugin- Description
Notifies the Google Indexing API when pages are published or removed. Shows how to store and refresh a Google service-account credential inside plugin storage. View source →
Option 1: install a prebuilt binary
Every tagged release ships per-platform binaries for every plugin.
curl -L -o ~/.temps/plugins/temps-lighthouse-plugin \
https://github.com/gotempsh/plugins/releases/latest/download/temps-lighthouse-plugin-x86_64-linux
chmod +x ~/.temps/plugins/temps-lighthouse-plugin
Option 2: build from source
git clone https://github.com/gotempsh/plugins
cd plugins
cargo build --release -p temps-lighthouse-plugin
cp target/release/temps-lighthouse-plugin ~/.temps/plugins/
chmod +x ~/.temps/plugins/temps-lighthouse-plugin
Then open Settings → Plugins in the Temps dashboard and click Reload Plugins.
External Plugins (Binaries)
Temps supports two kinds of plugins:
- In-process plugins — Rust crates compiled into the
tempsbinary, implementing theTempsPlugintrait. Everything from the rest of this page is about in-process plugins. - External plugins — standalone binaries (any language) that Temps spawns as child processes and talks to over stdin/stdout. These are what you install via Settings → Plugins.
External plugins are the right choice when you:
- Want to ship a plugin without forking or recompiling Temps itself.
- Need to use a non-Rust language (Python, Go, TypeScript, etc.).
- Are distributing a proprietary or commercial plugin as a prebuilt binary.
How it works
- On startup (and on Reload), Temps scans
$TEMPS_DATA_DIR/plugins/(default~/.temps/plugins). - Every executable file is spawned as a child process.
- Temps and the plugin exchange JSON messages over stdin/stdout — the protocol is defined in the
temps-plugin-sdkcrate. - Plugins can register routes, bundle a web UI, subscribe to deploy events, and persist data.
Installing an external plugin
# 1. Drop the binary into the plugins directory
cp ./my-plugin ~/.temps/plugins/
# 2. Make it executable
chmod +x ~/.temps/plugins/my-plugin
# 3. Reload from the dashboard (Settings → Plugins → Reload)
# or restart the Temps server
The dashboard will show the plugin name, version, and whether it exposes a UI or requires database access. If you don't see it after Reload, check the Temps server logs for the plugin's stderr output.
Plugin Architecture
The TempsPlugin trait
Every plugin implements this trait. Only name and register_services are required — the rest are opt-in.
#[async_trait]
pub trait TempsPlugin: Send + Sync {
/// Unique identifier, used in logs and error messages.
fn name(&self) -> &'static str;
/// Phase 1: register the services this plugin provides.
/// Called for every plugin BEFORE any `initialize` is called.
fn register_services(
&self,
ctx: &ServiceRegistrationContext,
) -> Result<(), PluginError>;
/// Phase 2 (optional): initialize services that depend on
/// services from OTHER plugins. All registrations are complete
/// by the time this runs.
async fn initialize(&self, _ctx: &PluginContext) -> Result<(), PluginError> {
Ok(())
}
/// Configure HTTP routes for this plugin (optional).
fn configure_routes(&self, _ctx: &PluginContext) -> Option<PluginRoutes> {
None
}
/// Contribute to the global OpenAPI schema (optional).
fn openapi_schema(&self) -> Option<OpenApi> {
None
}
}
Two-phase initialization
The registration context enforces a strict ordering that makes circular dependencies impossible:
During Phase 1, each plugin registers the services it owns. It may require_service to pull in core dependencies (database, config) but MUST NOT look up services from other plugins — they may not be registered yet.
During Phase 2, the registry is frozen. Plugins can safely cross-reference each other, because every service is guaranteed to exist. This is where a plugin wires, say, its notification sender into the audit service.
Lifecycle
Key invariants:
require_servicefails fast with a clear error if the dependency was never registered. This is how Temps catches configuration mistakes at startup instead of at 3am.- Services are stored as
Arc<T>. Every clone is cheap. Never pass the raw registry into handlers — pass the specificArc<Service>instance. - Routes are configured once at startup. You cannot mount new routes at runtime.
Build Your First Plugin
This walks through creating a minimal "pings" plugin that exposes POST /api/pings and stores a row in the database. It shows every layer: crate, error type, service, handler, plugin.
1. Create the crate
Add it to the workspace in Cargo.toml:
[workspace]
members = [
# ...
"crates/temps-pings",
]
And in crates/temps-pings/Cargo.toml:
[package]
name = "temps-pings"
version.workspace = true
edition.workspace = true
[dependencies]
temps-core = { path = "../temps-core" }
temps-entities = { path = "../temps-entities" }
async-trait = { workspace = true }
axum = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true }
2. Define a typed error enum
Every crate owns its error type. Include IDs in messages so logs are greppable.
// crates/temps-pings/src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PingError {
#[error("Ping {ping_id} not found")]
NotFound { ping_id: i32 },
#[error("Validation error: {message}")]
Validation { message: String },
#[error("Database error: {0}")]
Database(#[from] sea_orm::DbErr),
}
3. Write the service
The service owns business logic. Handlers NEVER touch the database directly.
// crates/temps-pings/src/service.rs
use std::sync::Arc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
use temps_entities::pings::{self, Entity as Ping};
use crate::error::PingError;
pub struct PingService {
db: Arc<DatabaseConnection>,
}
impl PingService {
pub fn new(db: Arc<DatabaseConnection>) -> Self {
Self { db }
}
pub async fn create(&self, message: String) -> Result<pings::Model, PingError> {
if message.trim().is_empty() {
return Err(PingError::Validation {
message: "message cannot be empty".into(),
});
}
let model = pings::ActiveModel {
message: Set(message),
..Default::default()
}
.insert(self.db.as_ref())
.await?;
Ok(model)
}
pub async fn get(&self, ping_id: i32) -> Result<pings::Model, PingError> {
Ping::find_by_id(ping_id)
.one(self.db.as_ref())
.await?
.ok_or(PingError::NotFound { ping_id })
}
}
4. Write the handler
Handlers handle auth, DTOs, audit, and HTTP mapping — nothing else.
// crates/temps-pings/src/handlers.rs
use std::sync::Arc;
use axum::{extract::{Path, State}, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use temps_core::problemdetails::{self, Problem};
use utoipa::ToSchema;
use crate::{error::PingError, service::PingService};
#[derive(Clone)]
pub struct PingAppState {
pub ping_service: Arc<PingService>,
}
#[derive(Deserialize, ToSchema)]
pub struct CreatePingRequest { pub message: String }
#[derive(Serialize, ToSchema)]
pub struct PingResponse { pub id: i32, pub message: String }
impl From<temps_entities::pings::Model> for PingResponse {
fn from(m: temps_entities::pings::Model) -> Self {
Self { id: m.id, message: m.message }
}
}
// Map domain errors to HTTP responses. Every variant explicitly.
impl From<PingError> for Problem {
fn from(err: PingError) -> Self {
match err {
PingError::NotFound { .. } => problemdetails::new(StatusCode::NOT_FOUND)
.with_title("Ping Not Found")
.with_detail(err.to_string()),
PingError::Validation { .. } => problemdetails::new(StatusCode::BAD_REQUEST)
.with_title("Validation Error")
.with_detail(err.to_string()),
PingError::Database(_) => problemdetails::new(StatusCode::INTERNAL_SERVER_ERROR)
.with_title("Internal Server Error")
.with_detail(err.to_string()),
}
}
}
#[utoipa::path(
post, path = "/api/pings", tag = "Pings",
request_body = CreatePingRequest,
responses(
(status = 201, body = PingResponse),
(status = 400, body = ProblemDetails),
),
)]
pub async fn create_ping(
State(state): State<PingAppState>,
Json(req): Json<CreatePingRequest>,
) -> Result<(StatusCode, Json<PingResponse>), Problem> {
let ping = state.ping_service.create(req.message).await?;
Ok((StatusCode::CREATED, Json(ping.into())))
}
#[utoipa::path(get, path = "/api/pings/{id}", tag = "Pings")]
pub async fn get_ping(
State(state): State<PingAppState>,
Path(id): Path<i32>,
) -> Result<Json<PingResponse>, Problem> {
let ping = state.ping_service.get(id).await?;
Ok(Json(ping.into()))
}
5. Implement TempsPlugin
This is the glue.
// crates/temps-pings/src/plugin.rs
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::DatabaseConnection;
use temps_core::plugin::{
PluginContext, PluginError, PluginRoutes,
ServiceRegistrationContext, TempsPlugin,
};
use crate::{
handlers::{create_ping, get_ping, PingAppState},
service::PingService,
};
pub struct PingsPlugin;
#[async_trait]
impl TempsPlugin for PingsPlugin {
fn name(&self) -> &'static str { "pings" }
fn register_services(
&self,
ctx: &ServiceRegistrationContext,
) -> Result<(), PluginError> {
// `require_service` fails fast with a clear error at startup
// if the DB plugin hasn't registered the connection yet.
let db = ctx.require_service::<Arc<DatabaseConnection>>()?;
ctx.register_service(Arc::new(PingService::new(db)));
Ok(())
}
fn configure_routes(&self, ctx: &PluginContext) -> Option<PluginRoutes> {
let ping_service = ctx.require_service::<Arc<PingService>>().ok()?;
let state = PingAppState { ping_service };
let router = axum::Router::new()
.route("/api/pings", axum::routing::post(create_ping))
.route("/api/pings/{id}", axum::routing::get(get_ping))
.with_state(state);
Some(PluginRoutes::new(router))
}
fn openapi_schema(&self) -> Option<utoipa::openapi::OpenApi> {
Some(crate::api_doc::ApiDoc::openapi())
}
}
6. Register the plugin
In temps-bootstrap (or wherever your plugin list lives):
let plugins: Vec<Box<dyn TempsPlugin>> = vec![
Box::new(temps_database::DatabasePlugin::new(db_config)),
Box::new(temps_auth::AuthPlugin::new(auth_config)),
Box::new(temps_pings::PingsPlugin),
// ...
];
That's it — the platform handles registration, routing, and OpenAPI merging.
Service Registration
require_service vs get_service
// get_service: returns Option<Arc<T>>. Use when the dependency is optional.
let emails: Option<Arc<EmailService>> = ctx.get_service();
// require_service: returns Result<Arc<T>, PluginError>.
// Fails plugin init with a descriptive error. Use for mandatory deps.
let db: Arc<DatabaseConnection> = ctx.require_service()?;
Rule of thumb: if your plugin cannot function without the dependency, use require_service. If it can degrade gracefully (e.g. "email notifications disabled when email plugin missing"), use get_service.
Type-keyed lookup
The registry is keyed by TypeId, not by name. That means:
- One instance per type. Registering
Arc<MyService>twice overwrites the first. - Different types are distinct entries.
Arc<PingService>andArc<dyn PingSender>are separate, even ifPingServiceimplementsPingSender.
If you want to expose both the concrete type and a trait object, register both:
let svc: Arc<PingService> = Arc::new(PingService::new(db));
ctx.register_service(svc.clone());
ctx.register_service(svc as Arc<dyn PingSender>);
Registration flow
Common pitfalls
- Name
Looking up other-plugin services in Phase 1- Description
A plugin's
register_servicesruns in registration order. If you look up a service from another plugin here, that plugin might not have registered yet. Move the lookup toinitialize.
- Name
Storing the registry in the service- Description
Never pass
&ServiceRegistryorServiceRegistrationContextinto a service constructor. Resolve dependencies explicitly and passArc<T>values.
- Name
Using Option<T> for required deps- Description
If the plugin can't work without a service, make it non-optional in the constructor signature. Let startup fail loudly.
- Name
Sharing deps via accessor methods- Description
Don't expose
fn db(&self) -> &DatabaseConnectionon services. Each component that needs the DB should receive its ownArc<DatabaseConnection>clone.
Route Configuration
URL conventions
| Prefix | Use |
|---|---|
/api/admin/* | Admin-only APIs, guarded by permission_guard!(auth, AdminRead) etc. |
/api/<plugin>/* | Plugin-owned APIs (e.g. /api/backups, /api/pings). |
/api/public/* | Unauthenticated public endpoints. |
/api/_temps/* | Reserved for internal Temps use. |
Path parameters
Always use Axum's {param} syntax, never :param:
.route("/api/pings/{id}", axum::routing::get(get_ping))
.route("/api/projects/{project_id}/backups/{backup_id}", ...)
Per-plugin state
Each plugin builds its own AppState struct containing the Arc<Service> instances it needs, and attaches it via .with_state(...). This keeps routers decoupled — a plugin's state doesn't leak into other plugins.
#[derive(Clone)]
pub struct PingAppState {
pub ping_service: Arc<PingService>,
pub audit_service: Arc<AuditService>,
}
Middleware
Apply auth, request-ID, and tracing middleware at the plugin-router level, not on individual routes:
let router = axum::Router::new()
.route("/api/pings", axum::routing::post(create_ping))
.route_layer(axum::middleware::from_fn(require_auth))
.with_state(state);
OpenAPI Integration
Each plugin defines its own utoipa ApiDoc and returns it from openapi_schema. The bootstrap layer merges every plugin's schema into a single doc served at /swagger-ui/ and /openapi.json.
// crates/temps-pings/src/api_doc.rs
use utoipa::OpenApi;
use crate::handlers::{CreatePingRequest, PingResponse};
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::create_ping,
crate::handlers::get_ping,
),
components(schemas(CreatePingRequest, PingResponse)),
tags((name = "Pings", description = "Ping endpoints")),
)]
pub struct ApiDoc;
Tips:
- Use
tag = "Pings"consistently on every#[utoipa::path(...)]macro so endpoints group correctly in Swagger UI. - Document every response code your handler can produce, including 401, 403, and the RFC 7807 Problem schema for errors.
- Re-export shared response types (like
ProblemDetails) fromtemps-coreso every plugin references the same schema.
Testing Plugins
Unit-test the service
Use MockDatabase from Sea-ORM to test happy paths and every error variant:
#[cfg(test)]
mod tests {
use super::*;
use sea_orm::{DatabaseBackend, MockDatabase};
#[tokio::test]
async fn create_rejects_empty_message() {
let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
let svc = PingService::new(Arc::new(db));
let err = svc.create(" ".into()).await.unwrap_err();
assert!(matches!(err, PingError::Validation { .. }));
}
#[tokio::test]
async fn get_returns_not_found_for_missing_id() {
let db = MockDatabase::new(DatabaseBackend::Postgres)
.append_query_results(vec![Vec::<pings::Model>::new()])
.into_connection();
let svc = PingService::new(Arc::new(db));
let err = svc.get(42).await.unwrap_err();
assert!(matches!(err, PingError::NotFound { ping_id: 42 }));
}
}
Integration-test the plugin
Use TempsPluginTestContext (from temps-core::plugin::testing) to run the full register→initialize→route flow in-process:
#[tokio::test]
async fn pings_plugin_registers_service() {
let ctx = TempsPluginTestContext::builder()
.with_service(Arc::new(test_db().await))
.with_plugin(PingsPlugin)
.build()
.await
.expect("plugin init");
let svc = ctx.require_service::<Arc<PingService>>().unwrap();
let ping = svc.create("hello".into()).await.unwrap();
assert_eq!(ping.message, "hello");
}
Contract-test the HTTP layer
Drive the Axum router directly with tower::ServiceExt::oneshot:
let app = PingsPlugin.configure_routes(&ctx).unwrap().into_router();
let response = app
.oneshot(Request::post("/api/pings")
.header("content-type", "application/json")
.body(r#"{"message":"hi"}"#.into()).unwrap())
.await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
Built-in Plugins
Temps ships with 40+ plugins. These are the most commonly extended:
- Name
DatabasePlugin- Description
Registers
Arc<DatabaseConnection>for every other plugin. Runs migrations at startup.
- Name
AuthPlugin- Description
Session management, JWT issuance, and the
RequireAuthextractor used by every write handler.
- Name
ProxyPlugin- Description
Pingora-backed HTTP/HTTPS router. Handles TLS termination and analytics capture.
- Name
DeployerPlugin- Description
Container builds and deploys. Depends on
GitService,BlobService,LogService.
- Name
DomainsPlugin- Description
DNS provisioning and Let's Encrypt certificate management.
- Name
AnalyticsPlugin- Description
Event ingestion, sessions, funnels, and session replay.
- Name
BackupPlugin- Description
Scheduled and on-demand backups for Postgres, MySQL, MongoDB, Redis, and S3-backed volumes.
- Name
ErrorTrackingPlugin- Description
Sentry-compatible ingest endpoint and issue management.
- Name
BlobPlugin / KVPlugin- Description
Object storage and a Redis-backed KV store, exposed as platform services.
- Name
AuditPlugin- Description
Append-only audit log for every write operation across the platform.
Plugin dependency graph (abridged)
Injecting Plugins from Another Binary
Everything above assumes you add your plugin to the OSS plugin list and rebuild the temps binary. But you can also reuse the entire CLI from an out-of-tree binary — one that wraps the same subcommands while registering its own additional plugins. This is exactly how the Enterprise Edition binary (temps-ee/apps/temps-cli) layers EE plugins on top of OSS without forking the CLI.
The library entrypoint
temps-cli builds both a binary and a library target. The binary (src/main.rs) is a thin delegate:
// crates/temps-cli/src/main.rs
fn main() -> anyhow::Result<()> {
temps_cli::run(Vec::new())
}
The library (crates/temps-cli/src/lib.rs) exposes the full subcommand surface so another crate can call into it:
| Item | Signature | Purpose |
|---|---|---|
Cli | pub struct Cli | The clap parser (log flags + command). |
Commands | pub enum Commands | Every subcommand (Serve, Proxy, Setup, …). |
dispatch | pub fn dispatch(cli: Cli, extra_plugins: Vec<Box<dyn TempsPlugin>>) -> anyhow::Result<()> | Routes a parsed Cli to its command. extra_plugins is forwarded to serve only. |
run | pub fn run(extra_plugins: Vec<Box<dyn TempsPlugin>>) -> anyhow::Result<()> | Parses args, installs tracing + the rustls crypto provider, then dispatches. |
The [lib] target is declared in crates/temps-cli/Cargo.toml:
[lib]
name = "temps_cli"
path = "src/lib.rs"
An EE-bundled binary then becomes its own thin delegate that passes a non-empty plugin vec:
// temps-ee/apps/temps-cli/src/main.rs (illustrative)
fn main() -> anyhow::Result<()> {
temps_cli::run(vec![
Box::new(temps_ee_teams::TeamsPlugin::new()),
// ... other EE plugins
])
}
Where extra plugins get registered
dispatch forwards extra_plugins only to temps serve — every other subcommand has no plugin lifecycle and ignores them. Inside serve, the plumbing is:
ServeCommand::execute()is justself.execute_with_extra_plugins(Vec::new()).ServeCommand::execute_with_extra_plugins(extra_plugins)carries the vec intoConsoleApiParams, which has a public fieldextra_plugins: Vec<Box<dyn TempsPlugin>>(OSS callers pass an empty vec).- The console registers these plugins last — after every OSS plugin and immediately before
plugin_manager.initialize_plugins().
Because they register last, extra plugins can resolve any OSS service via require_service in their register_services and initialize phases. They observe the full OSS service registry, so an EE plugin can wrap or extend OSS services without the OSS crates knowing it exists.
Sandbox API URL resolution
A related fix lives in the agent-workflow sandbox. When the platform launches a sandbox container for an agent run, it injects a TEMPS_API_URL env var so the in-sandbox CLI can reach the control plane. That value is now resolved in this priority order:
| Priority | Source |
|---|---|
| 1 | The TEMPS_INTERNAL_API_URL process env var, if set. |
| 2 | The platform's configured external_url (from settings), trailing slash trimmed, if non-empty. |
| 3 | The hard-coded fallback http://host.docker.internal:3000. |
Previously the resolution fell straight from (1) to the host.docker.internal:3000 fallback. Adding the external_url step means remote and production control planes reach the API on agent-workflow runs without operators having to set TEMPS_INTERNAL_API_URL explicitly.
Best Practices
- One crate per plugin. Keeps compile times fast and boundaries clean.
- Typed errors, not
anyhow. Define a domain error enum per crate, with#[from]for common conversions and structured fields for context. - Services own transactions. Handlers call one service method per request. If a handler needs to coordinate multiple services, add a method to the service layer instead.
- Audit every write. In handlers, call
audit_service.create_audit_log(...)after successful POST/PATCH/DELETE. Log-but-continue on audit failures. - Mask secrets in responses. Never serialize raw API keys, tokens, or passwords. Use
"***"masking in the response DTO. - Paginate everything. Default 20, max 100, sorted by
created_at DESC. See the architecture guide for the standard pagination helper. - Test Docker-backed code gracefully. If a test requires Docker, skip at runtime with a log message instead of
#[ignore]. Keeps CI green on machines without Docker.
Troubleshooting
PluginError::ServiceNotFound { service_type: "Arc<X>" } at startup
A plugin called require_service::<Arc<X>>() but no plugin registered that type. Check:
- Is the providing plugin in your plugin list, before the consumer?
- Did you register as the right type?
Arc<X>andArc<dyn Trait>are separate keys. - Are you looking up in
register_servicesinstead ofinitialize? The providing plugin may register later in Phase 1 — move the lookup to Phase 2.
Routes return 404 in production but work in tests
configure_routes returned None because a require_service call failed. Scan startup logs for WARN/ERROR lines from your plugin and confirm its dependencies registered.
OpenAPI docs missing your endpoints
- Did you implement
openapi_schema? It defaults toNone. - Is every handler decorated with
#[utoipa::path(...)]and listed inpaths(...)on theApiDoc? - Are request/response types listed in
components(schemas(...))?
Panic: thread 'main' panicked at 'already borrowed: BorrowMutError'
You're trying to register a service from inside a handler or background task. Registration is only valid during Phase 1. Move the call back into register_services.
Additional Resources
Declare plugin routes without the /api prefix (e.g. /pings, not /api/pings). The plugin listener nests every plugin route under /api globally, so the served path becomes /api/pings. See the API Reference for where the prefix is applied.