temps-plugin

Installation
SKILL.md

Temps Plugin Development

Build external plugins as standalone Rust binaries that Temps discovers, spawns, and proxies to.

Architecture Overview

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).

Critical Rules

NEVER

  • Register a /health route — the SDK runtime already provides one. Axum panics on Router::merge with duplicate routes.
  • Use rt.block_on() directly inside router() — it deadlocks. Use tokio::task::block_in_place(|| Handle::current().block_on(...)) instead.
  • Use #[tokio::main] — the SDK creates its own runtime via run_plugin().
  • Access the Temps database directly — use ctx.temps() for platform data queries over the WebSocket channel.
  • Use sea-orm with the main Temps database — plugins get their own SQLite in data_dir.
  • Return anyhow::Result — use typed error enums with thiserror.
  • Use .unwrap() or .expect() in production paths.

ALWAYS

  • Use temps_plugin_sdk::main!(YourPlugin) as the entry point.
  • Implement ExternalPlugin trait with manifest() and router() at minimum.
  • Use block_in_place for any async initialization inside router().
  • Embed the UI with include_dir!("$CARGO_MANIFEST_DIR/web/dist") and serve via own routes.
  • Keep tests in the same file as the code they test (#[cfg(test)] mod tests).
  • Run cargo check -p your-plugin after every modification.
  • Run cargo test -p your-plugin to verify tests pass.

Project Structure

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/

Step-by-Step: Creating a New Plugin

1. Cargo.toml

[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",
]

2. build.rs

Copy from the reference implementation. Key behavior:

  • Debug mode (default): Skips web build, creates fallback web/dist/index.html so include_dir! doesn't fail.
  • Release mode (or 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");
}

3. main.rs — Plugin Definition

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);

4. UI Serving Handlers

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()),
    }
}

5. SQLite Persistence (db.rs)

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 {}
}

6. Error Handling

#[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()
    }
}

7. Types (types.rs)

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>,
}

8. React UI (web/)

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;
}

9. Testing

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

Build & Deploy

# 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)

Available Platform Events

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.

Common Gotchas

  1. Duplicate /health route — The SDK runtime registers /health. Adding it in your router causes an axum panic on merge.
  2. Deadlock in router()router() is called from within a tokio runtime. Using block_on() directly deadlocks. Must use block_in_place(|| Handle::current().block_on(...)).
  3. Plugin not loading after rebuild — The symlink points to 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).
  4. Plugin stderr not visible — Set RUST_LOG=temps_external_plugins=debug to see plugin stderr output in Temps logs.
  5. Vite base path mismatch — The base in vite.config.ts must be /api/x/{plugin_name}/ui/ (with trailing slash). A mismatch causes 404s for JS/CSS assets.
  6. Channel timeout — If Temps doesn't connect the WebSocket channel within 30s, the plugin exits. This usually means Temps isn't running or can't reach the socket.

Reference Implementations

  • SEO Analyzer (full-featured with UI): examples/example-plugin/
  • IndexNow (full-featured with UI): examples/indexnow-plugin/
Related skills
Installs
6
Repository
gotempsh/temps
GitHub Stars
419
First Seen
Apr 2, 2026