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 orlojctlavailable- 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):
- Orloj writes a JSON request to the module's stdin.
- The module reads the request, does its work, and writes a JSON response to stdout.
- 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-..."
}
}
}| Field | Type | Description |
|---|---|---|
contract_version | string | Always "v1". |
namespace | string | The Orloj namespace the tool is running in. |
tool | string | The tool resource name. |
input | string | The agent's tool input, serialized as a JSON string. |
capabilities | string[] | Declared capabilities from the Tool manifest. |
risk_level | string | low, medium, high, or critical. |
runtime | object | Resource limits and entrypoint (informational; enforced by host). |
auth | object | Auth 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."
}{
"contract_version": "v1",
"status": "error",
"error": {
"code": "rate_limited",
"reason": "upstream API throttled",
"message": "try again in 5s",
"retryable": true
}
}{
"contract_version": "v1",
"status": "denied",
"error": {
"code": "permission_denied",
"reason": "insufficient scope",
"message": "tool requires admin access",
"retryable": false
}
}| Field | Type | Required | Description |
|---|---|---|---|
contract_version | string | yes | Must be "v1". |
status | string | yes | "ok", "error", or "denied". |
output | string | on success | The tool result returned to the agent. |
error | object | on error/denied | Structured 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.goRust
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: 5sApply:
orlojctl apply -f my-wasm-tool.yamlspec.wasm Fields
| Field | Default | Description |
|---|---|---|
module | (required) | Local path, HTTPS URL, or OCI artifact reference (oci://...) to the .wasm module. |
entrypoint | run | Exported function name to invoke. |
max_memory_bytes | 67108864 (64 MB) | Maximum WASM linear memory. Host-enforced. |
fuel | 1000000 (1M) | Execution fuel limit. Prevents runaway modules. Host-enforced. |
enable_wasi | false | Enable 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-credsStep 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: 60sIf 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.goUsing wasmtime (if installed):
echo '{"contract_version":"v1","tool":"my-wasm-tool","input":"hello"}' | \
wasmtime run my-tool.wasmExpected 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.
| Limit | What it controls | Default |
|---|---|---|
max_memory_bytes | WASM linear memory ceiling | 64 MB |
fuel | Execution step budget (prevents infinite loops) | 1,000,000 |
runtime.timeout | Wall-clock timeout for the entire invocation | 30s |
enable_wasi | When false, the module has no access to stdin/stdout/stderr | false |
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
- Always write a response. If your module exits without writing to stdout, the host treats it as a contract violation.
- Use
contract_version: "v1"and a validstatus. Missing or unsupported values cause a contract error. - Prefer structured errors over panics. Write an error response JSON instead of crashing. Panics produce an opaque host-level error with no retry information.
- Set
retryableaccurately. 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 goThis 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:
| Flag | Default | Description |
|---|---|---|
--fixtures | fixtures/ | Directory containing JSON fixture files. |
--fuel-budget | 1000000 | Maximum fuel per fixture run. |
--memory-budget | 67108864 | Maximum memory bytes per fixture run. |
Observability
WASM tool execution emits Prometheus metrics automatically:
| Metric | Type | Labels | Description |
|---|---|---|---|
orloj_tool_execution_duration_seconds | Histogram | tool, type, status | Duration of tool execution (all types). |
orloj_wasm_fuel_consumed | Counter | tool | Total fuel consumed. |
orloj_wasm_compilation_cache_hits_total | Counter | tool | Module compilation cache hits. |
orloj_wasm_compilation_cache_misses_total | Counter | tool | Module compilation cache misses. |
orloj_wasm_module_fetch_duration_seconds | Histogram | source | Remote 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
- Tool Concepts -- tool types, isolation modes, and the error taxonomy
- Build a Custom Tool -- HTTP, gRPC, external, and webhook-callback tool types
- Connect an MCP Server -- auto-discover tools from MCP servers