Advanced Guide
Plugins
Extend Orch8 with custom step handlers written in any language. Deploy them as WASM modules (sandboxed, zero-latency) or gRPC services (any language, any infrastructure).
Why Plugins?
Orch8 ships with 12 built-in handlers (http_request, llm_call, sleep, etc.) that cover common operations. But real workflows often need custom logic that doesn't fit a generic HTTP call:
- ✓ML inference (run a classification model on step input)
- ✓Data transformation (parse CSV, validate schemas, normalize formats)
- ✓Custom auth flows (OAuth token exchange, SAML, mTLS)
- ✓Legacy system integration (SOAP, proprietary binary protocols)
- ✓Domain-specific validation (regulatory checks, compliance rules)
- ✓Performance-critical operations (avoid HTTP round-trip overhead)
Plugins let you write these handlers in any language, deploy them alongside your engine, and reference them directly in sequence definitions — no external worker polling loop required.
The Goal
The plugin system exists to give you the extensibility of a code-based workflow engine (like writing Temporal activities in Go) while keeping the simplicity of Orch8's JSON-defined sequences. You get:
- —Custom handlers that run inside the engine process (WASM) or as sidecars (gRPC)
- —Zero-config deployment — register once, use in any sequence
- —Language-agnostic — write plugins in Rust, Go, Python, TypeScript, C, or anything that compiles to WASM or speaks HTTP/2
- —Hot-reload — update plugin source paths without restarting the engine
- —Multi-tenancy — scope plugins to specific tenants or make them global
Architecture
When the engine encounters a step, it resolves the handler name through this dispatch chain:
Step handler resolution:
1. "http_request" → Built-in handler (12 available)
2. "wasm://text_classifier" → WASM plugin (in-process, sandboxed)
3. "grpc://ml:50051/Svc.Run" → gRPC plugin (external process, HTTP/2)
4. "send_email" → External worker (REST polling loop)Data Flow
Both plugin types receive the same JSON input and return the same JSON output format. The engine handles serialization, timeout enforcement, retry logic, and output memoization — your plugin only implements the business logic.
{
"instance_id": "inst_abc123",
"block_id": "classify",
"params": {
"text": "Buy now! Limited offer!",
"categories": ["spam", "ham", "promo"]
},
"context": {
"data": { "user_id": "usr_42", "email": "user@example.com" },
"config": { "model_version": "v2" }
},
"attempt": 1
}Your plugin processes this and returns any JSON value. That value becomes the step output, accessible to subsequent steps via {{outputs.classify}}.
// Plugin response (becomes step output)
{
"category": "spam",
"confidence": 0.97,
"flags": ["urgency_language", "sales_pitch"]
}Plugin Types
| Dimension | WASM (wasm://) | gRPC (grpc://) |
|---|---|---|
| Execution | In-process (Wasmtime sandbox) | External process (HTTP/2 call) |
| Latency | ~1-5ms (no network) | ~5-50ms (network + serialization) |
| Languages | Rust, C, Go, AssemblyScript, Zig (anything → .wasm) | Any (Python, Node, Go, Java, Ruby...) |
| Isolation | Memory-sandboxed, no host access | Process-level isolation |
| State | Stateless (fresh instance per call) | Stateful if your service holds state |
| Scaling | Scales with engine (single binary) | Scale independently (separate containers) |
| Use case | Fast transforms, validation, scoring | Heavy compute, external APIs, stateful services |
| Deployment | Ship .wasm file alongside engine | Run as sidecar or separate service |
| Feature gate | ORCH8_WASM_ENABLED=true | Always available |
WASM Plugins: Step-by-Step
WASM plugins run inside the engine process in a sandboxed Wasmtime runtime. They're ideal for fast, stateless operations like data transformation, validation, and scoring.
Step 1: Write your plugin (Rust example)
Create a new Rust library with crate-type = ["cdylib"] and implement the three required exports:
// Cargo.toml
[package]
name = "text-classifier"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }// src/lib.rs
use std::alloc::{alloc, dealloc, Layout};
use std::slice;
// Required export: allocate memory for engine to write input
#[no_mangle]
pub extern "C" fn alloc(size: i32) -> i32 {
let layout = Layout::from_size_align(size as usize, 1).unwrap();
unsafe { alloc(layout) as i32 }
}
// Required export: free memory after engine reads output
#[no_mangle]
pub extern "C" fn dealloc(ptr: i32, size: i32) {
let layout = Layout::from_size_align(size as usize, 1).unwrap();
unsafe { dealloc(ptr as *mut u8, layout) }
}
// Required export: process input, return output
#[no_mangle]
pub extern "C" fn handle(ptr: i32, len: i32) -> i64 {
// 1. Read input JSON from WASM memory
let input_bytes = unsafe {
slice::from_raw_parts(ptr as *const u8, len as usize)
};
let input: serde_json::Value =
serde_json::from_slice(input_bytes).unwrap();
// 2. Extract params and do your work
let text = input["params"]["text"].as_str().unwrap_or("");
let category = classify(text);
// 3. Build output JSON
let output = serde_json::json!({
"category": category,
"confidence": 0.95,
"input_length": text.len()
});
let output_bytes = serde_json::to_vec(&output).unwrap();
// 4. Write output to memory and return packed ptr|len
let out_ptr = alloc(output_bytes.len() as i32);
unsafe {
std::ptr::copy_nonoverlapping(
output_bytes.as_ptr(),
out_ptr as *mut u8,
output_bytes.len(),
);
}
((out_ptr as i64) << 32) | (output_bytes.len() as i64)
}
fn classify(text: &str) -> &str {
if text.contains("buy now") || text.contains("limited offer") {
"spam"
} else if text.contains("invoice") || text.contains("receipt") {
"transactional"
} else {
"ham"
}
}Step 2: Compile to WASM
# Install the WASM target (one-time)
rustup target add wasm32-unknown-unknown
# Build the plugin
cargo build --target wasm32-unknown-unknown --release
# Output: target/wasm32-unknown-unknown/release/text_classifier.wasm
# Copy to your plugins directory
cp target/wasm32-unknown-unknown/release/text_classifier.wasm /opt/orch8/plugins/Step 3: Register the plugin
POST /plugins
{
"name": "text_classifier",
"plugin_type": "wasm",
"source": "/opt/orch8/plugins/text_classifier.wasm",
"description": "Classifies text into spam/ham/transactional"
}Step 4: Use in a sequence
{
"blocks": [
{
"type": "step",
"id": "classify_email",
"handler": "wasm://text_classifier",
"params": {
"text": "{{context.data.email_body}}"
},
"timeout": 5000
},
{
"type": "router",
"routes": [
{
"condition": "{{outputs.classify_email.category == 'spam'}}",
"blocks": [{ "type": "step", "id": "quarantine", "handler": "http_request", "params": { "url": "..." } }]
},
{
"default": true,
"blocks": [{ "type": "step", "id": "deliver", "handler": "http_request", "params": { "url": "..." } }]
}
]
}
]
}Writing WASM plugins in other languages
Any language that compiles to wasm32-unknown-unknown works. The ABI is the same: export alloc, dealloc, handle, and memory.
- Rust — best DX, smallest binary, shown above
- Go — use TinyGo (
tinygo build -target wasm) - C/C++ — use Emscripten or wasi-sdk
- AssemblyScript — TypeScript-like syntax, compiles to .wasm
- Zig —
zig build -target wasm32-freestanding
gRPC Plugins: Step-by-Step
gRPC plugins are external HTTP/2 services that accept a JSON POST and return JSON. Despite the name, the protocol is JSON-over-HTTP/2 — no protobuf required. Any language with an HTTP server works.
Step 1: Write your service (Python example)
# requirements.txt
fastapi>=0.100
uvicorn[standard]>=0.23
httpx>=0.24# plugin_server.py
from fastapi import FastAPI, Request
import httpx
app = FastAPI()
@app.post("/Enrichment/LookupCompany")
async def lookup_company(request: Request):
body = await request.json()
domain = body["params"].get("domain", "")
# Call external API, query database, run ML model, etc.
async with httpx.AsyncClient() as client:
resp = await client.get(f"https://api.example.com/company/{domain}")
data = resp.json()
return {
"company_name": data.get("name"),
"employee_count": data.get("employees"),
"industry": data.get("industry"),
"enriched_at": "2026-04-20T12:00:00Z"
}
@app.post("/Enrichment/ScoreLead")
async def score_lead(request: Request):
body = await request.json()
params = body["params"]
# Your scoring logic
score = 0
if params.get("has_company_email"): score += 30
if params.get("visited_pricing"): score += 25
if params.get("employee_count", 0) > 50: score += 20
if params.get("industry") in ["saas", "fintech"]: score += 25
return {"score": score, "tier": "hot" if score >= 70 else "warm" if score >= 40 else "cold"}Step 2: Run with HTTP/2 support
# Run with HTTP/2 (required for grpc:// dispatch)
uvicorn plugin_server:app --host 0.0.0.0 --port 8090 --http h2
# Or in Docker
FROM python:3.12-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY plugin_server.py .
CMD ["uvicorn", "plugin_server:app", "--host", "0.0.0.0", "--port", "8090", "--http", "h2"]Step 3: Register the plugin
POST /plugins
{
"name": "enrichment",
"plugin_type": "grpc",
"source": "enrichment-svc:8090/Enrichment/LookupCompany",
"description": "Company enrichment via Clearbit-style API"
}Step 4: Use in a sequence
{
"blocks": [
{
"type": "step",
"id": "enrich",
"handler": "grpc://enrichment-svc:8090/Enrichment.LookupCompany",
"params": { "domain": "{{context.data.company_domain}}" },
"timeout": 10000,
"retry": { "max_attempts": 3, "initial_backoff": 2000 }
},
{
"type": "step",
"id": "score",
"handler": "grpc://enrichment-svc:8090/Enrichment.ScoreLead",
"params": {
"has_company_email": true,
"visited_pricing": "{{context.data.visited_pricing}}",
"employee_count": "{{outputs.enrich.employee_count}}",
"industry": "{{outputs.enrich.industry}}"
}
}
]
}gRPC plugins in other languages
// Node.js (Express + http2)
const http2 = require("http2");
const server = http2.createServer();
server.on("stream", (stream, headers) => {
let body = "";
stream.on("data", (chunk) => (body += chunk));
stream.on("end", () => {
const input = JSON.parse(body);
const result = { processed: true, ...yourLogic(input) };
stream.respond({ ":status": 200, "content-type": "application/json" });
stream.end(JSON.stringify(result));
});
});
server.listen(8090);// Go (net/http with HTTP/2)
package main
import (
"encoding/json"
"net/http"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
)
func handler(w http.ResponseWriter, r *http.Request) {
var input map[string]interface{}
json.NewDecoder(r.Body).Decode(&input)
result := map[string]interface{}{"processed": true}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
func main() {
h2s := &http2.Server{}
mux := http.NewServeMux()
mux.HandleFunc("/MyService/Process", handler)
server := &http.Server{Addr: ":8090", Handler: h2c.NewHandler(mux, h2s)}
server.ListenAndServe()
}Plugin Registry API
Plugins are persisted in the database and managed via REST API. The registry supports CRUD operations, tenant scoping, and hot-reload (update a plugin's source path without restarting the engine).
Create a plugin
POST /plugins
Content-Type: application/json
{
"name": "my_plugin", // Unique name (used in handler reference)
"plugin_type": "wasm", // "wasm" or "grpc"
"source": "/opt/plugins/my.wasm", // File path (wasm) or host:port/path (grpc)
"tenant_id": "", // Empty = global, or scope to a tenant
"config": {}, // Plugin-specific JSON config (passed to plugin)
"description": "My custom plugin" // Optional human-readable description
}
// Response: 201 Created
{
"name": "my_plugin",
"plugin_type": "wasm",
"source": "/opt/plugins/my.wasm",
"tenant_id": "",
"enabled": true,
"config": {},
"description": "My custom plugin",
"created_at": "2026-04-20T10:00:00Z",
"updated_at": "2026-04-20T10:00:00Z"
}List plugins
GET /plugins
GET /plugins?tenant_id=acme // Filter by tenantGet a plugin
GET /plugins/{name}Update a plugin (hot-reload)
PATCH /plugins/{name}
{
"source": "/opt/plugins/my_v2.wasm", // Update source (hot-reload)
"enabled": true, // Enable/disable
"config": { "memory_pages": 32 }, // Update config
"description": "Updated description" // Update description
}Updating the source field causes the engine to load the new WASM module or route to the new gRPC endpoint on the next invocation. No restart required.
Delete a plugin
DELETE /plugins/{name}
// Returns 204 No Content on success
// Returns 404 if plugin doesn't existPlugin schema
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Unique plugin identifier. Used as handler reference (e.g. wasm://name) |
| plugin_type | "wasm" | "grpc" | Yes | Dispatch mechanism |
| source | string | Yes | File path (.wasm) or endpoint (host:port/path) |
| tenant_id | string | No | Scope to tenant. Empty = global plugin |
| enabled | boolean | No | Default: true. Disabled plugins return an error when invoked |
| config | object | No | Arbitrary JSON passed to the plugin on invocation |
| description | string | No | Human-readable description |
Security & Sandboxing
WASM sandbox guarantees
WASM plugins run in Wasmtime with strict isolation:
- ✓No filesystem access — plugins cannot read/write host files
- ✓No network access — plugins cannot make outbound connections
- ✓No environment variable access — secrets are not exposed
- ✓Memory-limited — configurable max memory pages (default: 256 pages = 16MB)
- ✓Time-limited — execution timeout enforced by the engine (step-level timeout applies)
- ✓No shared state — each invocation gets a fresh instance
This makes WASM plugins safe for running untrusted code, third-party extensions, or user-submitted logic (e.g., custom scoring rules per tenant).
gRPC plugin security
gRPC plugins are external services, so security depends on your deployment:
- —Network isolation — run plugins in the same VPC/namespace, restrict ingress
- —mTLS — configure mutual TLS between engine and plugin services
- —Timeout enforcement — the engine enforces step-level timeouts on gRPC calls (default: 30s)
- —Connection pooling — HTTP/2 connections are reused across invocations
- —Error handling — 5xx = retryable, 4xx = permanent failure
Multi-tenancy
Plugins can be scoped to a specific tenant via tenant_id. When a step references a plugin, the engine resolves it in order:
Resolution order:
1. Tenant-scoped plugin (tenant_id matches instance tenant)
2. Global plugin (tenant_id = "")
3. Not found → step fails with "handler not found" errorThis enables per-tenant customization: each tenant can have their own scoring model, validation rules, or integration endpoints while sharing the same sequence definition.
Production Patterns
Pattern 1: Plugin versioning
Deploy new versions alongside old ones. Update the plugin source when ready to cut over.
# Deploy v2 alongside v1
cp classifier_v2.wasm /opt/orch8/plugins/
# Hot-reload: update source path (no restart needed)
PATCH /plugins/text_classifier
{ "source": "/opt/orch8/plugins/classifier_v2.wasm" }
# Rollback if needed
PATCH /plugins/text_classifier
{ "source": "/opt/orch8/plugins/classifier_v1.wasm" }Pattern 2: A/B testing with tenant-scoped plugins
Register the same plugin name for different tenants with different implementations:
# Control group: v1 model
POST /plugins
{
"name": "lead_scorer",
"plugin_type": "wasm",
"source": "/opt/plugins/scorer_v1.wasm",
"tenant_id": "control_group"
}
# Treatment group: v2 model
POST /plugins
{
"name": "lead_scorer",
"plugin_type": "wasm",
"source": "/opt/plugins/scorer_v2.wasm",
"tenant_id": "treatment_group"
}
# Same sequence definition works for both tenants
{ "handler": "wasm://lead_scorer", "params": { ... } }Pattern 3: Plugin health checks
# Check if a plugin is registered and enabled
GET /plugins/text_classifier
# Disable a broken plugin without deleting it
PATCH /plugins/text_classifier
{ "enabled": false }
# Re-enable after fix
PATCH /plugins/text_classifier
{ "enabled": true }Pattern 4: Combining WASM + gRPC in one workflow
Use WASM for fast in-process operations and gRPC for heavy external calls in the same sequence:
{
"blocks": [
{
"type": "step",
"id": "validate",
"handler": "wasm://input_validator",
"params": { "schema": "lead_v2", "data": "{{context.data}}" }
},
{
"type": "step",
"id": "enrich",
"handler": "grpc://enrichment-svc:8090/Enrichment.Lookup",
"params": { "domain": "{{context.data.company}}" },
"timeout": 15000
},
{
"type": "step",
"id": "score",
"handler": "wasm://lead_scorer",
"params": {
"enrichment": "{{outputs.enrich}}",
"behavior": "{{context.data.events}}"
}
}
]
}When to Use What
| Scenario | Recommended | Why |
|---|---|---|
| Fast data transform / validation | WASM | No network overhead, ~1ms latency |
| ML inference with GPU | gRPC | Needs GPU hardware, separate scaling |
| Call external API (Stripe, Twilio) | Built-in http_request | Already supported, no plugin needed |
| Custom auth/token exchange | gRPC | Likely needs secrets + network access |
| Per-tenant scoring rules | WASM + tenant scoping | Safe to run user-defined logic |
| Legacy SOAP/binary protocol | gRPC | Needs full language runtime + libraries |
| Simple wait / delay | Built-in sleep | No plugin needed |
| LLM call (OpenAI, Anthropic) | Built-in llm_call | Already supported, handles streaming |
| Heavy data processing (ETL) | gRPC | May need disk, memory, long execution |
| Regex / text processing | WASM | Fast, no dependencies, sandboxed |
Decision flowchart
Does it need network/filesystem access?
YES → gRPC plugin (or built-in http_request if it's a simple API call)
NO → Does it need to be fast (<5ms)?
YES → WASM plugin
NO → Either works. gRPC if you want easier debugging.
Does it need per-tenant customization?
YES → WASM with tenant-scoped plugins (safe for user-defined logic)
NO → Either works. Use what matches your team's stack.
Is it already a running service?
YES → gRPC plugin (just register the endpoint)
NO → WASM if the logic is self-contained, gRPC if it has dependencies.