Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Build a WASM Tool

This guide walks through building and deploying a WebAssembly tool that agents can invoke through Orloj's embedded wazero runtime. WASM tools execute in-process with host-enforced resource limits -- no external runtime binary required.

Prerequisites

  • Orloj server (orlojd) and at least one worker running
  • orlojctl available
  • A language toolchain that can compile to WebAssembly (Go, Rust, C/C++, Zig, AssemblyScript, etc.)
  • Familiarity with Tools and Isolation concepts

How WASM Tools Work

WASM tools communicate with the host over stdin/stdout using a JSON contract (v1):

  1. Orloj writes a JSON request to the module's stdin.
  2. The module reads the request, does its work, and writes a JSON response to stdout.
  3. The host reads the response and passes the result back to the agent.

The module runs inside an embedded wazero runtime (pure Go, zero CGO). Memory, CPU fuel, and I/O access are enforced by the host, not by the guest. The module cannot escape its sandbox.

The WASM Tool Contract v1

Request (host writes to stdin)

The host sends a single JSON object:

{
  "contract_version": "v1",
  "namespace": "production",
  "tool": "my-wasm-tool",
  "input": "{\"query\": \"search term\"}",
  "capabilities": ["wasm.my-tool.invoke"],
  "risk_level": "low",
  "runtime": {
    "entrypoint": "run",
    "max_memory_bytes": 67108864,
    "fuel": 1000000,
    "enable_wasi": true
  },
  "auth": {
    "profile": "bearer",
    "headers": {
      "Authorization": "Bearer sk-..."
    }
  }
}
FieldTypeDescription
contract_versionstringAlways "v1".
namespacestringThe Orloj namespace the tool is running in.
toolstringThe tool resource name.
inputstringThe agent's tool input, serialized as a JSON string.
capabilitiesstring[]Declared capabilities from the Tool manifest.
risk_levelstringlow, medium, high, or critical.
runtimeobjectResource limits and entrypoint (informational; enforced by host).
authobjectAuth profile and resolved headers. Only present if spec.auth is configured on the Tool.

The input field is a string containing serialized JSON from the agent. Your module should parse it to extract parameters.

Response (module writes to stdout)

The module must write exactly one JSON object to stdout.

Success:
{
  "contract_version": "v1",
  "status": "ok",
  "output": "The result of the tool invocation."
}
Error (retryable):
{
  "contract_version": "v1",
  "status": "error",
  "error": {
    "code": "rate_limited",
    "reason": "upstream API throttled",
    "message": "try again in 5s",
    "retryable": true
  }
}
Denied:
{
  "contract_version": "v1",
  "status": "denied",
  "error": {
    "code": "permission_denied",
    "reason": "insufficient scope",
    "message": "tool requires admin access",
    "retryable": false
  }
}
FieldTypeRequiredDescription
contract_versionstringyesMust be "v1".
statusstringyes"ok", "error", or "denied".
outputstringon successThe tool result returned to the agent.
errorobjecton error/deniedStructured error with code, reason, message, retryable, and optional details (map of string to string).

The error.code and error.retryable fields drive the runtime's retry and dead-letter behavior. Use the error taxonomy codes when applicable.

WASI and proc_exit

When WASI is enabled (enable_wasi: true), the guest has access to stdin, stdout, and stderr. Go and Rust WASI guests typically call proc_exit(0) on normal termination. The host treats proc_exit(0) as success -- only non-zero exit codes are treated as failures.

Step 1: Write a Guest Module

Any language that compiles to WASM can produce a guest. The simplest path is Go targeting wasip1.

Go

package main
 
import (
    "encoding/json"
    "io"
    "os"
)
 
type request struct {
    ContractVersion string `json:"contract_version"`
    Tool            string `json:"tool"`
    Input           string `json:"input"`
}
 
type response struct {
    ContractVersion string `json:"contract_version"`
    Status          string `json:"status"`
    Output          string `json:"output"`
}
 
type errorResponse struct {
    ContractVersion string      `json:"contract_version"`
    Status          string      `json:"status"`
    Error           errorDetail `json:"error"`
}
 
type errorDetail struct {
    Code    string `json:"code"`
    Message string `json:"message"`
}
 
func main() {
    data, err := io.ReadAll(os.Stdin)
    if err != nil {
        writeError("guest_error", "failed to read stdin: "+err.Error())
        return
    }
 
    var req request
    if err := json.Unmarshal(data, &req); err != nil {
        writeError("guest_error", "failed to parse request: "+err.Error())
        return
    }
 
    // --- Your tool logic here ---
    result := "processed: " + req.Input
 
    resp := response{
        ContractVersion: "v1",
        Status:          "ok",
        Output:          result,
    }
    _ = json.NewEncoder(os.Stdout).Encode(resp)
}
 
func writeError(code, msg string) {
    resp := errorResponse{
        ContractVersion: "v1",
        Status:          "error",
        Error:           errorDetail{Code: code, Message: msg},
    }
    _ = json.NewEncoder(os.Stdout).Encode(resp)
}

Build:

GOOS=wasip1 GOARCH=wasm go build -o my-tool.wasm my_tool.go

Rust

use std::io::{self, Read};
 
fn main() {
    let mut input = String::new();
    io::stdin().read_to_string(&mut input).unwrap();
 
    // Parse JSON (use serde_json in real code)
    // ... your tool logic ...
 
    println!(r#"{{"contract_version":"v1","status":"ok","output":"result here"}}"#);
}

Build with the WASI target:

cargo build --target wasm32-wasip1 --release
cp target/wasm32-wasip1/release/my_tool.wasm .

Other Languages

Any language with a WASI compilation target works: C/C++ (via wasi-sdk or Emscripten), Zig (-target wasm32-wasi), AssemblyScript, etc. The only requirement is reading JSON from stdin and writing JSON to stdout.

Step 2: Register the Tool

Create a Tool manifest with type: wasm and a spec.wasm block:

apiVersion: orloj.dev/v1
kind: Tool
metadata:
  name: my-wasm-tool
spec:
  type: wasm
  wasm:
    module: /opt/orloj/tools/my-tool.wasm
    entrypoint: run
    max_memory_bytes: 67108864   # 64 MB (default)
    fuel: 1000000                # Execution step limit (default: 1M)
    enable_wasi: true            # Required for stdin/stdout
  capabilities:
    - wasm.my-tool.invoke
  risk_level: low
  runtime:
    isolation_mode: wasm
    timeout: 5s

Apply:

orlojctl apply -f my-wasm-tool.yaml

spec.wasm Fields

FieldDefaultDescription
module(required)Local path, HTTPS URL, or OCI artifact reference (oci://...) to the .wasm module.
entrypointrunExported function name to invoke.
max_memory_bytes67108864 (64 MB)Maximum WASM linear memory. Host-enforced.
fuel1000000 (1M)Execution fuel limit. Prevents runaway modules. Host-enforced.
enable_wasifalseEnable WASI (stdin/stdout/stderr). Most tools need this set to true.
image_pull_secret(optional)Name of a Secret containing registry credentials for pulling OCI-referenced modules. The Secret must have username and password keys.

Module reference formats

The module field accepts three formats:

  • Local path (existing): /opt/orloj/tools/echo.wasm
  • HTTPS URL: https://artifacts.example.com/tools/echo-v1.2.wasm
  • OCI reference: oci://ghcr.io/orloj-tools/echo:v1.2

Remote modules (HTTPS and OCI) are fetched once and cached on disk in --tool-wasm-cache-dir (default ~/.orloj/wasm-cache), keyed by SHA-256 of the reference. Subsequent invocations use the cached copy.

For local paths, the module path must be accessible to the orlojd / orlojworker process. In a containerized deployment, mount the .wasm file into the pod.

Private OCI registries

For private OCI registries, set image_pull_secret to reference a Secret with username and password keys:

spec:
  wasm:
    module: oci://ghcr.io/my-org/private-tool:v1
    image_pull_secret: ghcr-creds

Step 3: Grant Agent Access

Add the tool to an agent's tools list:

apiVersion: orloj.dev/v1
kind: Agent
metadata:
  name: research-agent
spec:
  model_ref: openai-default
  tools:
    - web_search
    - my-wasm-tool
  limits:
    max_steps: 10
    timeout: 60s

If governance is enabled, you also need a ToolPermission and an AgentRole. See the governance guide.

Step 4: Test Locally

You can test the guest contract without running Orloj by piping JSON to the WASM binary.

Using Go's WASI runner:

echo '{"contract_version":"v1","tool":"my-wasm-tool","input":"hello"}' | \
  GOOS=wasip1 GOARCH=wasm go run my_tool.go

Using wasmtime (if installed):

echo '{"contract_version":"v1","tool":"my-wasm-tool","input":"hello"}' | \
  wasmtime run my-tool.wasm

Expected output:

{"contract_version":"v1","status":"ok","output":"processed: hello"}

Resource Limits

All limits are enforced by the host, not the guest module. A malicious or buggy guest cannot override them.

LimitWhat it controlsDefault
max_memory_bytesWASM linear memory ceiling64 MB
fuelExecution step budget (prevents infinite loops)1,000,000
runtime.timeoutWall-clock timeout for the entire invocation30s
enable_wasiWhen false, the module has no access to stdin/stdout/stderrfalse

If fuel is exhausted before the module completes, the host terminates execution and returns an error to the agent.

Coexistence with Container Tools

WASM tools run on a dedicated runtime slot, independent of the --tool-isolation-backend flag. You can mix WASM tools and container-isolated tools (including MCP servers, CLI tools, etc.) in the same agent system:

spec:
  tools:
    - my-wasm-tool          # Runs in wazero (always available)
    - kubectl-get            # Runs in container (requires --tool-isolation-backend=container)
    - web_search             # Runs as HTTP (no isolation)

Error Handling Best Practices

  1. Always write a response. If your module exits without writing to stdout, the host treats it as a contract violation.
  2. Use contract_version: "v1" and a valid status. Missing or unsupported values cause a contract error.
  3. Prefer structured errors over panics. Write an error response JSON instead of crashing. Panics produce an opaque host-level error with no retry information.
  4. Set retryable accurately. The runtime uses this field to decide whether to retry or dead-letter the invocation.

Scaffold a New Tool

Use orlojctl tool scaffold to generate a ready-to-build project:

orlojctl tool scaffold my-echo --lang go

This creates a my-echo/ directory with a contract-compliant guest module, Makefile, tool manifest, test fixtures, and a README. Supported languages: go, rust.

Test a Tool

Use orlojctl tool test to validate a WASM module against fixture files:

orlojctl tool test my-echo.wasm --fixtures fixtures/

Each fixture is a JSON file specifying input, expected status, and expected output:

{
  "name": "echo hello",
  "input": "{\"query\": \"hello\"}",
  "expected_status": "ok",
  "expected_output": "processed: {\"query\": \"hello\"}",
  "timeout": "5s"
}

The test runner validates the contract (v1, valid status), asserts expected output, and reports pass/fail with timing.

Options:

FlagDefaultDescription
--fixturesfixtures/Directory containing JSON fixture files.
--fuel-budget1000000Maximum fuel per fixture run.
--memory-budget67108864Maximum memory bytes per fixture run.

Observability

WASM tool execution emits Prometheus metrics automatically:

MetricTypeLabelsDescription
orloj_tool_execution_duration_secondsHistogramtool, type, statusDuration of tool execution (all types).
orloj_wasm_fuel_consumedCountertoolTotal fuel consumed.
orloj_wasm_compilation_cache_hits_totalCountertoolModule compilation cache hits.
orloj_wasm_compilation_cache_misses_totalCountertoolModule compilation cache misses.
orloj_wasm_module_fetch_duration_secondsHistogramsourceRemote module fetch duration.

Reference Example

A complete working example lives in the repository:

  • Guest source: examples/resources/tools/wasm-reference/echo_guest.go
  • Tool manifest: examples/resources/tools/wasm-reference/wasm_echo_tool.yaml
  • README with build instructions: examples/resources/tools/wasm-reference/README.md

Next Steps