March 12, 2026 (3mo ago)
Written by Temps Team
Last updated March 12, 2026 (3mo ago)
Every successful developer platform hits the same wall. Users ask for a Slack integration. Then Discord. Then PagerDuty, custom webhooks, and five more things you've never heard of. You can't build them all — and you shouldn't try. The answer is a plugin system that lets your community extend the platform while you keep the core lean.
This guide covers the four main plugin architecture patterns, when to use each, and how to build a sidecar-based plugin system from scratch. It finishes with how Temps ships and uses this pattern in production today.
The fastest path is the sidecar process pattern: plugins run as standalone binaries that your platform spawns as child processes. Communication happens over a local socket (Unix domain socket or WebSocket over one). This gives you real process isolation, any-language support, and latency that disappears compared to the work plugins actually do (network calls, notifications, database writes).
Here is a comparison of all four patterns:
| Pattern | Isolation | Latency | Language support | Security | Complexity |
|---|---|---|---|---|---|
| In-process (.so/.dll) | None | ~1 ns | Host language only | Low | Medium |
| Scripting engine (WASM/Lua) | Strong | ~10–100 µs | Multi (via compile) | High | High |
| Sidecar process (HTTP/WebSocket) | Strong | ~50–200 µs | Any | High | Medium |
| Webhook (outbound HTTP) | Complete | ~10–500 ms | Any | Medium | Low |
For most developer platforms, sidecar processes hit the sweet spot — real isolation, any-language support, and sub-millisecond dispatch for non-network work.
WordPress powers 43.5% of all websites, and its plugin directory lists over 59,000 free plugins. That ecosystem isn't a side effect — it's the primary reason WordPress dominates. Extensibility turns a product into a platform.
Your users have workflows you'll never anticipate. One team pipes deployment notifications to a Telegram group. Another triggers a Lighthouse audit after every deploy. Someone else needs to update a Jira ticket when a staging environment spins up.
If you try to build all of these yourself, two things happen: your codebase bloats with integration code that serves 2% of users, and your roadmap stalls because you're maintaining fifty connectors instead of improving the core product.
VS Code has about 30,000 extensions. Kubernetes has hundreds of operators. Grafana has 200+ data source plugins. The pattern repeats because it works: ship a small, stable core and push everything else to the edges.
The host loads the plugin directly into its own process as a shared library (.so, .dll, .dylib). Think Nginx modules or Apache httpd modules.
Pros: Near-zero overhead — function calls are nanoseconds. Full access to the host's data structures.
Cons: A segfault in the plugin kills the host. A memory leak degrades the entire process. You're locked to the host's language (or C ABI). No meaningful isolation boundary.
This pattern works for performance-critical infrastructure like proxies. It's a poor fit for a platform where third-party developers write plugins.
The host embeds a scripting runtime and executes plugin code inside a sandbox. Think OpenResty (Lua in Nginx), Envoy (WASM filters), or Shopify Functions (WASM).
Pros: Strong sandboxing — the runtime controls exactly what the plugin can access. WASM can compile from many languages.
Cons: I/O is hard. Network requests, file access, and database queries all need explicit host-provided APIs. The development experience is awkward, and WASM debugging isn't pleasant.
The plugin runs as its own process. It communicates with the host over a Unix socket, HTTP, or WebSocket. Think HashiCorp's go-plugin library (Terraform, Vault, Consul) or Docker Engine plugins.
Pros: Complete language freedom — write your plugin in Rust, Python, Go, Node, whatever. Process isolation is built in. A crashing plugin doesn't take down the host. Standard debugging tools work.
Cons: Inter-process communication adds latency (50–200 µs per call). You need to manage process lifecycle.
The simplest model. The host fires HTTP requests to registered URLs when events occur. Think GitHub webhooks or Stripe event notifications.
Pros: Dead simple to implement for both you and plugin authors. Language-agnostic. The plugin can run anywhere.
Cons: Fire-and-forget. Network latency dominates. The plugin can't modify the host's behavior inline — it can only react after the fact.
The sidecar pattern was popularized by HashiCorp's go-plugin library, which powers plugins in Terraform, Vault, Consul, and Packer. The concept is simple: the plugin is a separate binary that speaks a defined protocol.
The critical difference from a microservice is lifecycle coupling — the host starts and stops the plugin. This parent-child relationship is what makes the pattern manageable.
/health endpointThe startup handshake uses stdout for simplicity — no socket negotiation required:
// Plugin writes to stdout (phase 1): manifest declaration
{
"type": "manifest",
"name": "slack-notifier",
"version": "1.0.0",
"display_name": "Slack Notifier",
"description": "Sends deployment notifications to Slack",
"events": ["deployment.succeeded", "deployment.ready"],
"health_path": "/health"
}
// Plugin writes to stdout (phase 2): ready signal after HTTP server starts
{
"type": "ready",
"ready": true,
"has_ui": false
}
After the ready signal, the host connects to the plugin's WebSocket channel at /_temps/channel for bidirectional communication.
The WebSocket channel carries three message types:
// Plugin → Platform: request platform data
{
"type": "request",
"id": 1,
"method": "get_project",
"params": { "project_id": 42 }
}
// Platform → Plugin: response
{
"type": "response",
"id": 1,
"result": { "name": "my-app", "slug": "my-app" }
}
// Platform → Plugin: pushed event (fire-and-forget)
{
"type": "event",
"event": {
"id": "uuid",
"event_type": "deployment.succeeded",
"timestamp": "2026-06-06T12:00:00Z",
"project_id": 42,
"data": {
"deployment_id": 100,
"url": "https://myapp.example.com",
"commit_sha": "a1b2c3d"
}
}
}
This design separates concerns cleanly: the channel handles data access and event delivery, while the plugin's own HTTP routes handle user-facing API calls proxied from the platform.
Every plugin declares a manifest during the handshake. The manifest tells the host what events to deliver, what nav entries to add to the UI, and whether the plugin needs a database:
// Rust SDK — PluginManifest builder
PluginManifest::builder("slack-notifier", "1.0.0")
.display_name("Slack Notifier")
.description("Sends deployment notifications to Slack")
.event("deployment.succeeded")
.event("deployment.ready")
.nav(NavEntry {
label: "Slack".into(),
icon: "message-square".into(),
section: NavSection::Settings,
path: "/slack".into(),
order: 60,
})
.requires_db(false)
.build()
A few design decisions matter. The events field is a whitelist — the host only delivers events the plugin declared. Wildcards like "deployment.*" work too. Nav entries with section: NavSection::Platform appear in the main sidebar; Settings places them in the admin section.
Define a clear set of lifecycle events. Start with the ones your users actually ask about. In Temps, the supported events are:
deployment.succeeded — A deployment is live and healthydeployment.ready — A deployment passed health checks and is serving trafficproject.created — A new project was registeredproject.deleted — A project was removeddeployment.* — Wildcard matching all deployment eventsEach event carries a typed payload. Include only what a plugin needs: IDs, timestamps, URLs, status codes. Don't dump your entire internal state.
Plugins own their own HTTP routes under a namespaced prefix. In the Temps architecture, routes at /api/x/{plugin_name}/* are proxied to the plugin process. This means plugin authors write standard axum (or Express, FastAPI, etc.) routes without thinking about routing collisions.
Plugin systems are insecure-by-design magnets — you're running someone else's code on your infrastructure. Layer your defenses.
Run each plugin as its own process with a dedicated auth secret. The auth secret is passed as a CLI argument at spawn time — plugin routes can validate the Authorization header against it to reject unauthorized callers.
# Temps spawns plugins with:
/path/to/plugin-binary \
--socket-path /var/run/temps/plugins/my-plugin.sock \
--auth-secret <generated-per-spawn> \
--data-dir /var/lib/temps/plugins/my-plugin/
For stronger isolation, use cgroups v2 and namespaces to give each plugin its own view of the filesystem, process tree, and network stack.
Set hard ceilings on what each plugin can consume:
# Using systemd resource controls
[Service]
MemoryMax=256M
CPUQuota=25%
TasksMax=64
LimitNOFILE=1024
Without limits, a single misbehaving plugin can exhaust memory, CPU, or file descriptors — and every service on the box suffers.
Plugins should never reach your internal database or admin API directly. Use iptables rules or network namespaces to:
Log every plugin lifecycle event (started, stopped, crashed), every event dispatched and the response received, and resource usage snapshots at regular intervals. This is your forensic record when something goes wrong.
Here is a working implementation covering discovery, lifecycle management, handshake, and event dispatch — under 120 lines of Rust:
Scan a directory for plugin binaries:
use std::fs;
use std::path::{Path, PathBuf};
fn discover_plugins(dir: &Path) -> Vec<PathBuf> {
let mut plugins = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
// Any executable file in the plugins directory is a plugin
if path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = path.metadata() {
if meta.permissions().mode() & 0o111 != 0 {
plugins.push(path);
}
}
}
}
}
}
plugins
}
Spawn each plugin and read its manifest from stdout:
use std::process::{Command, Stdio, Child};
use std::io::{BufRead, BufReader};
use std::collections::HashMap;
use serde::Deserialize;
#[derive(Deserialize)]
struct PluginManifest {
name: String,
version: String,
events: Vec<String>,
health_path: Option<String>,
}
struct RunningPlugin {
child: Child,
manifest: PluginManifest,
socket_path: String,
}
struct PluginManager {
plugins: HashMap<String, RunningPlugin>,
socket_dir: String,
auth_secret: String,
}
impl PluginManager {
fn start_plugin(&mut self, binary: &std::path::Path) -> std::io::Result<()> {
let plugin_name = binary.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let socket_path = format!("{}/{}.sock", self.socket_dir, plugin_name);
let data_dir = format!("/var/lib/temps/plugins/{}", plugin_name);
let mut child = Command::new(binary)
.arg("--socket-path").arg(&socket_path)
.arg("--auth-secret").arg(&self.auth_secret)
.arg("--data-dir").arg(&data_dir)
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
// Read manifest from stdout (handshake phase 1)
let stdout = child.stdout.take().expect("stdout piped");
let mut reader = BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line)?;
// Strip the "type":"manifest" wrapper if present
let manifest: PluginManifest = serde_json::from_str(line.trim())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
// Read ready signal (handshake phase 2)
line.clear();
reader.read_line(&mut line)?;
// { "type": "ready", "ready": true }
self.plugins.insert(plugin_name, RunningPlugin {
child,
manifest,
socket_path,
});
Ok(())
}
fn stop_plugin(&mut self, name: &str) {
if let Some(mut plugin) = self.plugins.remove(name) {
let _ = plugin.child.kill();
}
}
}
Send events to plugins that subscribed to the relevant event type. In production you'd use the WebSocket channel; here is the simplified HTTP POST pattern for illustration:
async fn dispatch_event(
client: &reqwest::Client,
base_url: &str,
event_type: &str,
payload: &serde_json::Value,
) -> Result<(), reqwest::Error> {
let event = serde_json::json!({
"id": uuid::Uuid::new_v4().to_string(),
"event_type": event_type,
"timestamp": chrono::Utc::now().to_rfc3339(),
"data": payload,
});
client.post(format!("{base_url}/_events"))
.json(&event)
.send()
.await?;
Ok(())
}
That is the foundation. Add WebSocket channel handling, health check polling, restart logic, and cgroup resource limits to reach production quality.
Temps ships a complete external plugin system based on this sidecar pattern. Plugins are standalone binaries placed in ~/.temps/plugins/ — Temps discovers, spawns, and manages their lifecycle automatically on startup.
Three Quotable Temps Differentiators
- Embedded UI: Plugins can embed a full React UI at compile time (
include_dir!("web/dist")). Temps proxies/api/x/{plugin_name}/ui/*to the plugin and renders it in an iframe inside the console — no separate deployment required.- Bidirectional channel: The platform-to-plugin WebSocket channel at
/_temps/channellets plugins query platform data (projects, deployments, environments) without direct database access — clean capability boundary by design.- Single Rust binary, ~$6/mo self-hosted: Temps replaces Vercel, PostHog/Plausible, FullStory, Sentry, Pingdom, managed databases, and transactional email in one process. The plugin system extends it further, Apache 2.0.
Drop a plugin binary into the plugins directory and restart Temps:
# Download an official plugin
curl -L https://github.com/gotempsh/temps/releases/latest/download/indexnow-plugin-linux-amd64 \
-o ~/.temps/plugins/indexnow-plugin
chmod +x ~/.temps/plugins/indexnow-plugin
# Temps auto-discovers it on next startup
# Or install Temps itself first:
curl -fsSL temps.sh/install.sh | bash
Official plugins use the temps-plugin-sdk crate. The main! macro and run_plugin() function handle everything: stdout handshake, axum HTTP server on the Unix socket, WebSocket channel, /health endpoint, SIGTERM handler. Plugin authors implement one trait:
use temps_plugin_sdk::prelude::*;
struct SlackNotifier;
impl ExternalPlugin for SlackNotifier {
fn manifest(&self) -> PluginManifest {
PluginManifest::builder("slack-notifier", "1.0.0")
.display_name("Slack Notifier")
.description("Sends deployment notifications to Slack")
.event("deployment.succeeded")
.build()
}
fn router(&self, ctx: PluginContext) -> axum::Router {
axum::Router::new()
.route("/settings", get(get_settings).patch(update_settings))
.with_state(ctx)
}
fn on_event(&self, ctx: &PluginContext, event: PluginEvent) {
if event.event_type != "deployment.succeeded" {
return;
}
let url = event.data.get("url")
.and_then(|v| v.as_str())
.unwrap_or_default();
// Post to Slack webhook, etc.
tracing::info!(url, "Deployment succeeded — notifying Slack");
}
}
temps_plugin_sdk::main!(SlackNotifier);
The protocol is language-agnostic. Python and Node.js SDKs follow the same two-phase stdout handshake:
# Python plugin (using temps-plugin Python SDK)
from temps_plugin import PluginManifest, NavEntry, emit_manifest, emit_ready, run_server
manifest = PluginManifest(
name="slack-notifier",
version="1.0.0",
display_name="Slack Notifier",
description="Sends deployment notifications to Slack",
events=["deployment.succeeded"],
)
emit_manifest(manifest)
# ... start your HTTP server ...
emit_ready(has_ui=False)
# ... serve requests
All the platform asks of a plugin is: write your manifest to stdout, write a ready signal, then listen for events. No special framework required.
Official Temps plugins — IndexNow (instant search engine URL submission), Google Indexing API, Lighthouse audits, and a minimal example — are open source at github.com/gotempsh/temps in the examples/ directory.
It depends on your threat model and performance requirements. WASM gives stronger sandboxing and near-native speed, but the developer experience is rough — I/O requires host-provided APIs, and debugging is painful. Sidecar processes let plugin authors use any language with standard tooling and debuggers. For most developer platforms, sidecars are the pragmatic choice. WASM makes sense if you're executing untrusted code in a request's hot path, like edge compute functions.
Use semantic versioning for your plugin API. The manifest's declared API version is your compatibility contract. When you release a breaking change to the event payload format, bump the major version. Old plugins keep working against the old API version until you deprecate it. For plugin binary updates, treat them like any other deployment: download, validate checksum, swap, restart.
Unix socket roundtrips add 50–100 µs per event dispatch. For comparison, a typical HTTP API call to Slack takes 200–800 ms. The plugin overhead is noise. The real performance cost is spawning processes — keep plugins running as daemons rather than starting a new process per event.
Layer your defenses. Run each plugin as its own OS user with minimal permissions. Use cgroups to cap memory and CPU. Restrict network access with namespaces or firewall rules — block localhost, allow only external HTTPS. Log every event dispatch and response. Never give plugins access to host database credentials or internal API keys. Inject only the specific config values each plugin declares in its manifest.
In the Temps architecture, plugins request platform data over the bidirectional WebSocket channel (/_temps/channel). The plugin sends a request message with a method like get_project or list_environments; the platform responds with only what the plugin is authorized to see. This gives plugins rich data access without any direct database coupling.
A plugin system isn't just a feature — it's a multiplier. It turns your users into contributors, keeps your core lean, and makes your platform adaptable to workflows you haven't imagined yet.
The sidecar process pattern gives you the best foundation for most developer platforms: real isolation, any-language support, and latency that disappears compared to the work plugins actually do. Start with a handful of lifecycle events, a clear manifest format, and strict resource limits. Expand the API surface as your community tells you what they need.
Temps ships a complete external plugin system using exactly this pattern — sidecar processes over Unix sockets with a WebSocket channel, managed by the same daemon that handles your deployments, Apache 2.0. Try it on your own infrastructure at ~$6/mo on Temps Cloud (Hetzner cost + 30%, no per-seat fees) or self-host for free:
curl -fsSL temps.sh/install.sh | bash