skills/temps-plugin/SKILL.md
Build external plugins for the Temps deployment platform. Use when the user wants to create, modify, or debug a Temps plugin binary — a standalone Rust process that communicates with Temps over a Unix domain socket. Also use when the user mentions "temps plugin", "external plugin", "plugin binary", "plugin for temps", "plugin UI", or asks about plugin architecture, plugin events, plugin manifest, or plugin SDK. Covers the full lifecycle: project scaffolding, manifest, router, events, SQLite persistence, embedded React UI, build.rs, testing, and deployment into the plugins directory.
npx skillsauth add gotempsh/temps temps-pluginInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build external plugins as standalone Rust binaries that Temps discovers, spawns, and proxies to.
Temps (main process)
├── Scans ~/.temps/plugins/ for binaries
├── Spawns each binary with --socket-path, --auth-secret, --data-dir
├── Reads JSON manifest from stdout (handshake phase 1)
├── Reads ready signal from stdout (handshake phase 2)
├── Opens WebSocket to plugin's /_temps/channel (bidirectional data access)
├── Proxies /api/x/{plugin_name}/* → Unix socket
├── Serves plugin UI at /api/x/{plugin_name}/ui/*
└── Delivers platform events over the WebSocket channel
Plugins are self-contained binaries. They own their own HTTP routes (axum Router), optional React UI (embedded via include_dir), and SQLite database (via sea-orm in their data_dir).
/health route — the SDK runtime already provides one. Axum panics on Router::merge with duplicate routes.rt.block_on() directly inside router() — it deadlocks. Use tokio::task::block_in_place(|| Handle::current().block_on(...)) instead.#[tokio::main] — the SDK creates its own runtime via run_plugin().ctx.temps() for platform data queries over the WebSocket channel.sea-orm with the main Temps database — plugins get their own SQLite in data_dir.anyhow::Result — use typed error enums with thiserror..unwrap() or .expect() in production paths.temps_plugin_sdk::main!(YourPlugin) as the entry point.ExternalPlugin trait with manifest() and router() at minimum.block_in_place for any async initialization inside router().include_dir!("$CARGO_MANIFEST_DIR/web/dist") and serve via own routes.#[cfg(test)] mod tests).cargo check -p your-plugin after every modification.cargo test -p your-plugin to verify tests pass.examples/your-plugin/
├── Cargo.toml
├── build.rs # Builds web UI (bun + vite), creates fallback in debug
├── src/
│ ├── main.rs # Plugin struct, manifest, router, on_event, UI handlers, entry point
│ ├── db.rs # SQLite persistence (sea-orm entities + raw DDL migrations)
│ ├── types.rs # Shared types (Settings, API DTOs) — all serde(rename_all = "camelCase")
│ └── ... # Additional modules as needed
└── web/ # React UI (Vite + TypeScript)
├── package.json
├── vite.config.ts # base: "/api/x/{plugin_name}/ui/"
├── tsconfig.json
├── index.html
└── src/
├── main.tsx
├── App.tsx
├── api.ts # API_BASE = "/api/x/{plugin_name}"
├── types.ts
├── router.ts # Hash-based routing with useSyncExternalStore
├── styles.css
└── components/
[package]
name = "temps-your-plugin"
version = "0.1.0"
edition = "2021"
publish = false
[[bin]]
name = "temps-your-plugin"
path = "src/main.rs"
[dependencies]
temps-plugin-sdk = { path = "../../crates/temps-plugin-sdk" }
axum = { version = "0.8" }
sea-orm = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
chrono = { version = "0.4", features = ["serde"] }
thiserror = { workspace = true }
include_dir = "0.7"
mime_guess = "2.0"
# Add reqwest, scraper, url, uuid etc. as needed
[dev-dependencies]
tempfile = "3"
Add the crate to the workspace Cargo.toml members list:
members = [
# ...existing...
"examples/your-plugin",
]
Copy from the reference implementation. Key behavior:
web/dist/index.html so include_dir! doesn't fail.FORCE_WEB_BUILD=1): Runs bun install + bun run build.use std::env;
use std::path::Path;
use std::process::Command;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let web_dir = Path::new(&manifest_dir).join("web");
let dist_dir = web_dir.join("dist");
println!("cargo:rerun-if-changed=web/src");
println!("cargo:rerun-if-changed=web/index.html");
println!("cargo:rerun-if-changed=web/vite.config.ts");
println!("cargo:rerun-if-changed=web/package.json");
println!("cargo:rerun-if-env-changed=FORCE_WEB_BUILD");
let profile = env::var("PROFILE").unwrap_or_default();
if profile == "debug" && env::var("FORCE_WEB_BUILD").is_err() {
println!("cargo:warning=Skipping plugin web build in debug mode (use FORCE_WEB_BUILD=1 to build)");
let _ = std::fs::create_dir_all(&dist_dir);
let fallback = dist_dir.join("index.html");
if !fallback.exists() {
let _ = std::fs::write(&fallback, r#"<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Plugin (dev)</title></head>
<body style="font-family:system-ui;padding:2rem;color:#a1a1aa;background:#09090b;text-align:center">
<h2>Plugin UI not built</h2>
<p>Run <code style="color:#3b82f6">cd examples/your-plugin/web && bun install && bun run build</code></p>
<p>Or set <code style="color:#3b82f6">FORCE_WEB_BUILD=1</code> before cargo build.</p>
</body></html>"#);
}
return;
}
if !web_dir.join("node_modules").exists() {
let status = Command::new("bun").arg("install").current_dir(&web_dir).status()
.expect("Failed to run `bun install`. Is bun installed?");
if !status.success() { panic!("bun install failed"); }
}
let status = Command::new("bun").args(["run", "build"]).current_dir(&web_dir).status()
.expect("Failed to run `bun run build`. Is bun installed?");
if !status.success() { panic!("Vite build failed"); }
assert!(dist_dir.join("index.html").exists(), "Vite build did not produce dist/index.html");
}
mod db;
mod types;
use axum::body::Body;
use axum::extract::{Json, Path, Query, State};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, patch, post};
use include_dir::{include_dir, Dir};
use std::sync::Arc;
use temps_plugin_sdk::prelude::*;
use crate::db::YourStore;
use crate::types::*;
static UI_DIST: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/dist");
pub fn ui_dist() -> &'static Dir<'static> {
&UI_DIST
}
struct YourPlugin;
impl Default for YourPlugin {
fn default() -> Self { Self }
}
impl ExternalPlugin for YourPlugin {
fn manifest(&self) -> PluginManifest {
PluginManifest::builder("your-plugin", "0.1.0")
.display_name("Your Plugin")
.description("What it does")
.requires_db(false)
.nav(NavEntry {
label: "Your Plugin".into(),
icon: "puzzle".into(), // Lucide icon name
section: NavSection::Platform,
path: "/your-plugin".into(), // Sidebar route
order: 50,
})
.event("deployment.succeeded") // Subscribe to events (optional)
.build()
}
fn router(&self, ctx: PluginContext) -> axum::Router {
// Async init MUST use block_in_place — plain block_on deadlocks!
let store = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(
YourStore::open(ctx.data_dir())
)
}).expect("Failed to open store");
let state = Arc::new(AppState { store });
axum::Router::new()
.route("/settings", get(get_settings).patch(update_settings))
// ... your API routes ...
// UI routes — embedded React SPA
.route("/ui", get(redirect_to_ui))
.route("/ui/", get(serve_ui_index))
.route("/ui/{*path}", get(serve_ui_asset))
// DO NOT add /health — SDK already provides it!
.with_state(state)
}
fn on_event(&self, _ctx: &PluginContext, event: temps_core::external_plugin::PluginEvent) {
if event.event_type != "deployment.succeeded" { return; }
// Handle event — spawn a background task for async work
tokio::spawn(async move {
// ...
});
}
}
temps_plugin_sdk::main!(YourPlugin);
These are the same for every plugin — copy verbatim:
async fn redirect_to_ui() -> Response {
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header(header::LOCATION, "ui/")
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
async fn serve_ui_index() -> Response {
serve_embedded_file(ui_dist(), "index.html")
}
async fn serve_ui_asset(Path(path): Path<String>) -> Response {
let dist = ui_dist();
if dist.get_file(&path).is_some() {
return serve_embedded_file(dist, &path);
}
serve_embedded_file(dist, "index.html") // SPA fallback
}
fn serve_embedded_file(dist: &Dir<'static>, path: &str) -> Response {
match dist.get_file(path) {
Some(file) => {
let mime = mime_guess::from_path(path).first_or_octet_stream().to_string();
let cache = if path == "index.html" { "no-cache" }
else { "public, max-age=31536000, immutable" };
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, cache)
.body(Body::from(file.contents()))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("404 Not Found"))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()),
}
}
Use sea-orm with raw DDL migrations (not sea-orm-migration crate):
use sea_orm::{entity::prelude::*, ConnectOptions, Database, DatabaseConnection, Statement};
use std::path::Path;
use std::sync::Arc;
pub struct YourStore {
db: Arc<DatabaseConnection>,
}
impl YourStore {
pub async fn open(data_dir: &Path) -> Result<Self, StoreError> {
let db_path = data_dir.join("your-plugin.db");
let url = format!("sqlite://{}?mode=rwc", db_path.display());
let mut opts = ConnectOptions::new(&url);
opts.max_connections(1).sqlx_logging(false); // SQLite is single-writer
let db = Database::connect(opts).await
.map_err(|e| StoreError::Connect { path: db_path.display().to_string(), reason: e.to_string() })?;
Self::migrate(&db).await?;
Ok(Self { db: Arc::new(db) })
}
async fn migrate(db: &DatabaseConnection) -> Result<(), StoreError> {
db.execute(Statement::from_string(sea_orm::DatabaseBackend::Sqlite, r#"
CREATE TABLE IF NOT EXISTS your_table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
"#)).await.map_err(|e| StoreError::Migration(e.to_string()))?;
Ok(())
}
}
Define sea-orm entities in the same file:
pub mod your_entity {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "your_table")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub created_at: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("Failed to connect to SQLite at {path}: {reason}")]
Connect { path: String, reason: String },
#[error("Migration failed: {0}")]
Migration(String),
#[error("Database error: {0}")]
Database(String),
}
enum AppError {
Store(StoreError),
BadRequest(String),
Internal(String),
}
impl From<StoreError> for AppError {
fn from(e: StoreError) -> Self { AppError::Store(e) }
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
AppError::Store(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
All API types use serde(rename_all = "camelCase") to match JavaScript conventions:
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginSettings {
pub some_setting: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSettings {
pub some_setting: Option<String>,
pub enabled: Option<bool>,
}
vite.config.ts — Critical: base must match the Temps proxy path:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/api/x/your-plugin/ui/", // Must match plugin name!
build: { outDir: "dist", emptyOutDir: true },
server: {
port: 5175,
proxy: {
"/api/x/your-plugin": {
target: "http://localhost:8081",
changeOrigin: true,
},
},
},
});
api.ts — All API calls use absolute paths:
const API_BASE = "/api/x/your-plugin";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: { "Content-Type": "application/json", ...options?.headers },
});
if (!res.ok) throw new Error(await res.text() || res.statusText);
if (res.status === 204) return null as T;
return res.json();
}
router.ts — Hash-based routing (plugins run in an iframe):
import { useSyncExternalStore, useCallback } from "react";
// IMPORTANT: useSyncExternalStore compares by reference (Object.is).
// Cache the parsed route to avoid infinite re-renders.
let cachedHash = "";
let cachedRoute: Route = { kind: "list" };
function getSnapshot(): Route {
const hash = window.location.hash;
if (hash !== cachedHash) {
cachedHash = hash;
cachedRoute = parseHash(hash);
}
return cachedRoute;
}
Tests go in #[cfg(test)] mod tests at the bottom of each source file:
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn test_store() -> (YourStore, TempDir) {
let dir = TempDir::new().expect("create temp dir");
let store = YourStore::open(dir.path()).await.expect("open store");
(store, dir) // TempDir must live as long as the store
}
#[tokio::test]
async fn test_crud_operations() {
let (store, _dir) = test_store().await;
// ... test create, read, update, delete
}
#[tokio::test]
async fn test_settings_defaults() {
let (store, _dir) = test_store().await;
let settings = store.get_settings().await.unwrap();
assert_eq!(settings.some_setting, "default_value");
}
}
Run tests: cargo test -p temps-your-plugin
# Check compilation
cargo check -p temps-your-plugin
# Run tests
cargo test -p temps-your-plugin
# Build without UI (fast, for Rust dev)
cargo build -p temps-your-plugin
# Build the web UI separately
cd examples/your-plugin/web && bun install && bun run build
# Build with embedded UI
FORCE_WEB_BUILD=1 cargo build -p temps-your-plugin
# Symlink into plugins directory for local dev
ln -sf $(pwd)/target/debug/temps-your-plugin crates/temps-cli/temps_data/plugins/
# Restart Temps to pick up the new plugin
# (reload only restarts plugins, not the server — but new plugins need a full restart)
Subscribe via .event("event_name") in the manifest builder:
| Event | Data fields | Description |
|---|---|---|
| deployment.succeeded | url, deployment_id, project_id, environment_id, environment_name | Fires after proxy confirms routes are loaded |
| deployment.failed | deployment_id, project_id, environment_id, error | Deployment pipeline failed |
Events are delivered over the WebSocket channel and fall back to HTTP POST /_events.
/health. Adding it in your router causes an axum panic on merge.router() is called from within a tokio runtime. Using block_on() directly deadlocks. Must use block_in_place(|| Handle::current().block_on(...)).target/debug/.... After cargo build, the binary is updated but Temps keeps the old process. Must restart Temps (or use Reload Plugins in the UI if the binary signature hasn't changed).RUST_LOG=temps_external_plugins=debug to see plugin stderr output in Temps logs.base in vite.config.ts must be /api/x/{plugin_name}/ui/ (with trailing slash). A mismatch causes 404s for JS/CSS assets.examples/example-plugin/examples/indexnow-plugin/tools
Install, configure, and manage the Temps deployment platform and CLI. Covers self-hosted Temps installation, CLI setup (bunx @temps-sdk/cli), initial configuration, user management, and platform administration. Use when the user wants to: (1) Install Temps on their server, (2) Set up the Temps CLI, (3) Configure Temps for the first time, (4) Manage Temps platform settings, (5) Create admin users, (6) Configure DNS providers, (7) Set up TLS certificates. Triggers: "install temps", "setup temps", "temps cli", "configure temps", "temps platform", "self-hosted deployment platform".
tools
Configure the Temps MCP server to enable AI assistants to interact with the Temps platform. Provides tools for listing projects, viewing project details, and managing deployments directly from Claude or other MCP-compatible clients. Use when the user wants to: (1) Set up Temps MCP server, (2) Configure Claude to manage Temps projects, (3) Add Temps tools to their AI assistant, (4) Enable AI-powered deployment management, (5) Connect Claude Desktop to Temps, (6) Use MCP to interact with Temps API. Triggers: "temps mcp", "configure temps tools", "add temps to claude", "temps ai assistant", "mcp server setup".
tools
Complete command-line reference for managing the Temps deployment platform. Covers all 54+ CLI commands including projects, deployments, environments, services, domains, monitoring, backups, security scanning, error tracking, and platform administration. Use when the user wants to: (1) Find CLI command syntax, (2) Manage projects and deployments via CLI, (3) Configure services and infrastructure, (4) Set up monitoring and logging, (5) Automate deployments with CI/CD, (6) Manage domains and DNS, (7) Configure notifications and webhooks. Triggers: "temps cli", "temps command", "how to use temps", "@temps-sdk/cli", "bunx temps", "npx temps", "temps deploy", "temps projects", "temps services".
development
Deploy applications to the Temps platform with automatic framework detection, Dockerfile generation, and container orchestration. Supports Next.js, Vite, React, Node.js, Python, Go, Rust, Java, and C# applications. Use when the user wants to: (1) Deploy their app to Temps, (2) Set up CI/CD with Temps, (3) Configure deployment settings, (4) Create a Dockerfile for Temps, (5) Deploy a containerized application, (6) Set up automatic deployments from Git. Triggers: "deploy to temps", "temps deployment", "push to temps", "containerize for temps", "temps ci/cd".