Skip to content

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.

JSON
{
  "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}}.

JSON
// 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)
OverheadNo network round trip; benchmark your moduleNetwork and serialization costs; benchmark your service
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 engineUse a public endpoint accepted by the 0.7.0 SSRF guard
AvailabilityRequires 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:

JSON
// 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"] }
JSON
// 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

JSON
# 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

JSON
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

JSON
{
  "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.

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)

JSON
# requirements.txt
fastapi>=0.100
uvicorn[standard]>=0.23
httpx>=0.24
JSON
# 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

JSON
# 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

JSON
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

JSON
{
  "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

JSON
// 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);
JSON
// 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

JSON
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

JSON
GET /plugins
GET /plugins?tenant_id=acme    // Filter by tenant

Get a plugin

JSON
GET /plugins/{name}

Update a plugin (hot-reload)

JSON
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

JSON
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
configobjectNoStored registry metadata; not injected into plugin calls in 0.7.0
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 — 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.

JSON
Example naming:
wasm://acme-input-validator
wasm://globex-input-validator

Production Patterns#

Pattern 1: Plugin versioning

Deploy new versions alongside old ones. Update the plugin source when ready to cut over.

JSON
# 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:

JSON
# 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

JSON
# 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:

JSON
{
  "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#

ScenarioRecommendedWhy
Fast data transform / validationWASMAvoids a network round trip; measure module runtime locally
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

JSON
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.