Skip to content

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

DimensionWASM (wasm://)gRPC (grpc://)
ExecutionIn-process (Wasmtime sandbox)External process (HTTP/2 call)
Latency~1-5ms (no network)~5-50ms (network + serialization)
LanguagesRust, C, Go, AssemblyScript, Zig (anything → .wasm)Any (Python, Node, Go, Java, Ruby...)
IsolationMemory-sandboxed, no host accessProcess-level isolation
StateStateless (fresh instance per call)Stateful if your service holds state
ScalingScales with engine (single binary)Scale independently (separate containers)
Use caseFast transforms, validation, scoringHeavy compute, external APIs, stateful services
DeploymentShip .wasm file alongside engineRun as sidecar or separate service
Feature gateORCH8_WASM_ENABLED=trueAlways 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
  • Zigzig 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 tenant

Get 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 exist

Plugin schema

FieldTypeRequiredDescription
namestringYesUnique plugin identifier. Used as handler reference (e.g. wasm://name)
plugin_type"wasm" | "grpc"YesDispatch mechanism
sourcestringYesFile path (.wasm) or endpoint (host:port/path)
tenant_idstringNoScope to tenant. Empty = global plugin
enabledbooleanNoDefault: true. Disabled plugins return an error when invoked
configobjectNoArbitrary JSON passed to the plugin on invocation
descriptionstringNoHuman-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" error

This 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

ScenarioRecommendedWhy
Fast data transform / validationWASMNo network overhead, ~1ms latency
ML inference with GPUgRPCNeeds GPU hardware, separate scaling
Call external API (Stripe, Twilio)Built-in http_requestAlready supported, no plugin needed
Custom auth/token exchangegRPCLikely needs secrets + network access
Per-tenant scoring rulesWASM + tenant scopingSafe to run user-defined logic
Legacy SOAP/binary protocolgRPCNeeds full language runtime + libraries
Simple wait / delayBuilt-in sleepNo plugin needed
LLM call (OpenAI, Anthropic)Built-in llm_callAlready supported, handles streaming
Heavy data processing (ETL)gRPCMay need disk, memory, long execution
Regex / text processingWASMFast, 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.