---
name: gotempsh/temps-plugin
source: https://app.decimal.ai/s/gotempsh-temps-plugin@1/SKILL.md
source_sha256: 1d896f659e2f
---

# 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

```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:
```toml
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`.

```rust
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

```rust
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:

```rust
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):

```rust
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:

```rust
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

```rust
#[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:

```rust
#[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:

```ts
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:

```ts
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):

```ts
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:

```rust
#[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

```bash
# 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/`