Advanced Guide
Plugins
Extend Orch8 with custom step handlers written in any language. Deploy them as sandboxed WASM modules or gRPC-compatible HTTP/2 services (any language, any infrastructure).
Why Plugins?#
Orch8 ships with a built-in handler set (including http_request, llm_call, and sleep) that covers 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 through a reachable HTTP/2 service (gRPC plugin)
- —Registry-based deployment — register a source once, then reference it from sequences
- —Language-agnostic — write plugins in Rust, Go, Python, TypeScript, C, or anything that compiles to WASM or speaks HTTP/2
- —Registry updates — change a plugin source path without changing sequence definitions
- —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) |
| Overhead | No network round trip; benchmark your module | Network and serialization costs; benchmark your service |
| 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 | Use a public endpoint accepted by the 0.7.0 SSRF guard |
| Availability | Requires a binary compiled with the wasm feature (enabled in standard builds) | Included in standard builds |
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.
In 0.7.0, the resolved endpoint passes an SSRF guard that rejects loopback, private, link-local, and unspecified addresses. This means a localhost or private-network sidecar will be rejected by the standard build. Use a reviewed public HTTPS/2 endpoint, or prefer an external worker/WASM plugin until that deployment constraint fits your setup.
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
# Serve this application behind an HTTP/2-capable TLS endpoint.
# The exact command depends on the ASGI server and certificate setup you choose.
# Verify HTTP/2 negotiation before registering the endpoint.Step 3: Register the plugin
POST /plugins
{
"name": "grpc://company-enrichment",
"plugin_type": "grpc",
"source": "grpcs://plugins.example.com/Enrichment/LookupCompany",
"description": "Company enrichment via Clearbit-style API"
}The 0.7.0 dispatcher looks up a gRPC plugin by the complete handler string, so the registry name and sequencehandler must match exactly. Encode the name as a path segment when using get, update, or delete routes.
Step 4: Use in a sequence
{
"blocks": [
{
"type": "step",
"id": "enrich",
"handler": "grpc://company-enrichment",
"params": { "domain": "{{context.data.company_domain}}" },
"timeout": 10000,
"retry": { "max_attempts": 3, "initial_backoff": 2000 }
}
]
}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": { "model": "v2" }, // Stored plugin metadata
"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 is required when the source value changes. The 0.7.0 dispatcher does not inject the registry configobject into plugin requests; pass runtime settings in step params.
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 | Stored registry metadata; not injected into plugin calls in 0.7.0 |
| 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 — each invocation is capped at 64 MiB in 0.7.0
- ✓CPU-metered — each invocation has a fixed Wasmtime fuel budget
- ✓No shared state — each invocation gets a fresh instance
These controls reduce the host access available to a module. Treat third-party or user-submitted modules as untrusted input anyway: review them, test resource behavior, and apply tenant-level policy before use.
gRPC plugin security
gRPC plugins are external services, so security depends on your deployment:
- —Endpoint policy — 0.7.0 rejects loopback and private/internal addresses before dispatch
- —Transport — grpcs:// uses HTTPS/2 server authentication; the built-in client does not configure mutual TLS
- —Timeout enforcement — the built-in client has a 5s connect timeout and 30s request timeout
- —Connection pooling — HTTP/2 connections are reused across invocations
- —Error handling — 5xx = retryable, 4xx = permanent failure
Multi-tenancy
Plugin API operations enforce tenant ownership when a tenant is present. In 0.7.0, however, plugin names are globally unique and dispatch resolves by that global name; it does not support a tenant override with the same name. Use distinct names per tenant when tenant-specific behavior is needed.
Example naming:
wasm://acme-input-validator
wasm://globex-input-validatorProduction 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://company-enrichment",
"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 | Avoids a network round trip; measure module runtime locally |
| 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 → Is avoiding a network round trip important?
YES → WASM plugin; benchmark the module in your deployment
NO → Either works. gRPC can be easier to develop and debug.
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.