Write evaluator actions in JavaScript
JavaScript is the fastest path from idea to working WASM. You write normal JS, compile it with jco, and the engine runs it.
Prerequisites
npm install -g @bytecodealliance/jcoInstall the TypeScript definitions to enable autocomplete:
# Copy from the repo
cp sdk/js/wanaku-actions.d.ts /path/to/your/action/The wanaku-evaluators-<version>.tar.gz release asset also contains this file in sdk/js/ and the matching WIT interface in wit/. Use the files from the release that matches your server version.
Step 1: Import Host Functions
The host provides four namespaces. Import what you need:
import { block, rejectMalformed, warn, pass, filterTools, setMetadata } from 'wanaku:evaluator/response';
import { verifyLlmResult } from 'wanaku:evaluator/validation';
import { listTools, getTool, copyToolToNamespace } from 'wanaku:evaluator/registry';
import { getHistory } from 'wanaku:evaluator/conversation';
import { info, warn, error } from 'wanaku:evaluator/log';Step 2: Implement evaluate(ctx)
This is the only export the host calls:
export function evaluate(ctx) {
// ctx fields:
// - method: "tools/call" | "tools/list" | etc.
// - namespace: "default" | "finance" | etc.
// - toolName: "restart-database" (present for tools/call)
// - arguments: [["target", "prod"], ["timeout", "30"]]
// - llmResult: raw string from the LLM
// - conversationId: "conv-12345" (if present)
// Your logic here
}Step 3: Available Host APIs
Registry (wanaku:evaluator/registry)
import { listTools, listToolsInNamespace, getTool, copyToolToNamespace } from 'wanaku:evaluator/registry';
// List all tools across all namespaces
const tools = listTools(); // [{name, description, uri, toolType, namespace}, ...]
// List tools in a specific namespace
const financeTools = listToolsInNamespace("finance");
// Get a single tool by name
const tool = getTool("restart-database"); // {name, description, ...} or undefined
// Copy a tool into a target namespace (for assembly)
const copied = copyToolToNamespace("read-balance", "finance-team"); // true if foundResponse (wanaku:evaluator/response)
Call exactly ONE of these per evaluation. If you call none, the default is pass().
import { pass, block, rejectMalformed, warn, filterTools, setMetadata } from 'wanaku:evaluator/response';
// Allow the request to proceed
pass();
// Block the request with a JSON-RPC error (code -32001)
block("Tool call blocked: database restart requires manual approval");
// Signal that the input data is malformed and the evaluator cannot decide (code -32002)
// Use this for invalid LLM output, not for a policy rejection
rejectMalformed("LLM result missing required 'level' field");
// Log a warning but allow the request to proceed
warn("Elevated privilege operation detected");
// Return a filtered tools/list response (only for tools/list triggers).
// The response contains only the named tools that are in the request namespace.
filterTools(["tool-1", "tool-2", "tool-3"]);
// Set metadata for downstream filters
setMetadata("wanaku.risk_level", "high");Validation (wanaku:evaluator/validation)
import { verifyLlmResult } from 'wanaku:evaluator/validation';
// Validate ctx.llmResult against the evaluator's declared result_schema.
// Returns { tag: 'ok', val: validatedString } on success,
// or { tag: 'err', val: errorMessage } on failure.
// If no result_schema is configured, always succeeds.
const result = verifyLlmResult(ctx.llmResult);
if (result.tag === 'err') {
rejectMalformed(`Invalid LLM output: ${result.val}`);
return;
}Conversation (wanaku:evaluator/conversation)
import { getHistory } from 'wanaku:evaluator/conversation';
const messages = getHistory(ctx.conversationId);
// [{role: "user", content: "..."}, {role: "assistant", content: "..."}, ...]
// Empty array if no history or conversation ID is absentLogging (wanaku:evaluator/log)
import { info, warn, error } from 'wanaku:evaluator/log';
info("Evaluator executed successfully");
warn("LLM returned unexpected format, falling back to default");
error("Failed to parse JSON from LLM result");Step 4: EvaluationContext Object
The ctx parameter passed to evaluate(ctx) has these fields:
interface EvaluationContext {
method: string; // "tools/call", "tools/list", etc.
namespace: string; // "default", "finance", etc.
toolName?: string; // Present for tools/call, absent for tools/list
arguments: [string, string][]; // Key-value pairs from the tool call
llmResult: string; // Raw LLM output (JSON string, label, or prose)
conversationId?: string; // If present in the request
}Step 5: Compile to WASM
npx @bytecodealliance/jco componentize action.js \
--wit features/evaluator/wit/evaluator.wit \
--world-name evaluator-action \
--disable all \
-o action.wasmFlags explained:
--wit: path to the WIT interface file--world-name: the world to target (alwaysevaluator-action)--disable all: Disable all optional WASI features. The action uses only the features in the WIT interface.-o: output file
The first compilation can report errors if the imports do not match the WIT interface. Verify the namespace and function names.
Step 6: Deploy
Place the .wasm file somewhere the server can read it (e.g., /wasm/ directory), then reference it in your evaluator config:
processor:
path: "/wasm/safety-block.wasm"Or update via the management API:
curl -X PUT http://localhost:8080/api/v1/evaluators \
-H "Content-Type: application/json" \
-d @evaluator-config.jsonWorked examples
Example 1: Safety Classification
Block dangerous tool calls based on LLM classification.
llm_connections:
- name: "local-llama"
model: "llama3.2"
url: "http://localhost:11434/v1"
evaluators:
- name: "safety-gate"
trigger:
method: "tools/call"
engine:
type: llm
operation: classify
prompt: |
You are a strict safety classifier. Classify this tool call as:
- green: safe operations (read-only, low impact)
- yellow: elevated operations (writes, config changes)
- red: dangerous operations (database restarts, production deploys)
Respond with ONLY a JSON object: {"level": "green|yellow|red", "reason": "brief explanation"}
connection: "local-llama"
result_schema: # Optional: validate LLM output shape
type: object
properties:
level:
type: string
enum: ["green", "yellow", "red"]
reason:
type: string
required: ["level", "reason"]
processor:
path: "actions/dist/safety_review_action.wasm"What happens:
- User calls
restart-databasetool - Engine sends tool name + args to LLM with your prompt
- LLM returns
{"level": "red", "reason": "database restart is dangerous"} - Engine runs
safety_review_action.wasmwith the engine result inctx.llmResult - WASM script parses the JSON, sees
"red", callsblock("Tool call blocked: red") - User gets JSON-RPC error instead of executing the tool
JavaScript action
The example uses the bundled Rust action. The following script is a smaller JavaScript safety gate. It blocks only red results.
import { block, rejectMalformed, pass } from 'wanaku:evaluator/response';
import { verifyLlmResult } from 'wanaku:evaluator/validation';
import { warn } from 'wanaku:evaluator/log';
export function evaluate(ctx) {
// If a result_schema is configured, validate first
const validated = verifyLlmResult(ctx.llmResult);
if (validated.tag === 'err') {
warn(`LLM result failed schema validation: ${validated.val}`);
rejectMalformed(`Cannot assess safety: ${validated.val}`);
return;
}
let level = "red";
try {
const result = JSON.parse(ctx.llmResult);
level = result.level || "red";
} catch (e) {
warn("Failed to parse LLM result, defaulting to red");
}
if (level === "red") {
const reason = `Tool call blocked by safety classification: ${ctx.llmResult}`;
warn(reason);
block(reason);
} else {
pass();
}
}Compile:
npx @bytecodealliance/jco componentize safety-block.js \
--wit features/evaluator/wit/evaluator.wit \
--world-name evaluator-action \
--disable all \
-o safety-block.wasmExample 2: Tool Assembly
Dynamically populate a namespace with only the tools the LLM approves.
evaluators:
- name: "assembly-gate"
trigger:
method: "tools/list"
namespace: "curated"
engine:
type: llm
operation: filter
prompt: |
You are a tool curator. Given the conversation history and available tools,
return a JSON array of tool names that are relevant and safe for this user.
Respond with ONLY a JSON array: ["tool-name-1", "tool-name-2"]
connection: "local-llama"
processor:
path: "/wasm/assembly-filter.wasm"This example reuses the local-llama connection from Example 1.
What happens:
- User sends
tools/listto thecuratednamespace (/curated/mcp) - Engine sends conversation history + available tools to LLM
- LLM returns
["read-balance", "transfer-funds", "generate-report"] - Engine runs
assembly-filter.wasmwith that JSON inctx.llmResult - WASM script parses JSON. For each tool in the
defaultnamespace or inctx.namespace, it callscopyToolToNamespace(name, ctx.namespace). It skips tools from other namespaces. - WASM calls
filterTools(assembled)to return only the assembled tools - User sees a curated tool list
JavaScript action
import { copyToolToNamespace, getTool } from 'wanaku:evaluator/registry';
import { filterTools } from 'wanaku:evaluator/response';
import { info, warn } from 'wanaku:evaluator/log';
export function evaluate(ctx) {
let approved;
try {
approved = JSON.parse(ctx.llmResult);
} catch (e) {
warn('Failed to parse LLM result as tool name array, returning all tools');
return;
}
if (!Array.isArray(approved) || approved.length === 0) {
info('LLM returned empty tool list, returning all tools (fail-open)');
return;
}
// Assemble only from the shared catalog or the request namespace, so the
// LLM result cannot pull in tools that belong to other namespaces.
const assembled = [];
for (const name of approved) {
const tool = getTool(name);
if (!tool) {
continue;
}
const source = tool.namespace ?? 'default';
if (source !== 'default' && source !== ctx.namespace) {
warn(`Skipping tool '${name}' from namespace '${source}'`);
continue;
}
copyToolToNamespace(name, ctx.namespace);
assembled.push(name);
}
info(`Registered ${assembled.length} tools into namespace '${ctx.namespace}'`);
filterTools(assembled);
}Error handling pattern: The LLM can return invalid output. Use try/catch when you parse JSON. Return early to fail open; the default action is pass().
Try it locally
This script configures an evaluator, makes a tool call against a tool discovered from a forwarded MCP server, and verifies the response.
Prerequisites: You need an upstream MCP server that exposes at least one tool. Register it as a forward before running this script:
wanaku forwards add --service="http://<your-mcp-server>/mcp" --name my-mcp-server --namespace default --no-authReplace restart-database in the script below with a tool name discovered from your forward. Run wanaku tools list --no-auth to see the available tools.
The script also needs:
- The
local-llamaconnection inllm_connectionsin thewanaku.yamlthat the server loaded at startup. The management API cannot create connections. - The built
actions/dist/safety_review_action.wasmfile. - A governance posture that lets the call reach the evaluator. With the default
no_match: denyand no action policy, the action-policy filter denies the call with-32003before the evaluator runs. For a local test, setgovernance.namespaces.default.no_match: allow.
#!/usr/bin/env bash
set -euo pipefail
MGMT=http://localhost:8080
MCP=http://localhost:8081
WASM="$(pwd)/actions/dist/safety_review_action.wasm"
echo "Configuring evaluator with WASM action..."
curl -sf -X PUT $MGMT/api/v1/evaluators -H "Content-Type: application/json" \
-d '{
"evaluators": [{
"name": "js-safety",
"trigger": {"method": "tools/call"},
"engine": {
"type": "llm",
"operation": "classify",
"prompt": "You are a safety classifier. You MUST classify every tool call as exactly one of: green, yellow, or red. Restarting any database is ALWAYS red. Respond with ONLY a JSON object, no other text: {\"level\": \"green|yellow|red\", \"reason\": \"brief\"}",
"connection": "local-llama"
},
"processor": {"path": "'"$WASM"'"}
}]
}' > /dev/null
echo "Making tool call..."
curl -sf -X POST $MCP/default/mcp -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"restart-database","arguments":{"target":"prod"}}}' | jq .
echo "Cleaning up evaluator..."
curl -sf -X PUT $MGMT/api/v1/evaluators -H "Content-Type: application/json" -d '{"evaluators":[]}' > /dev/null
echo "Done."Expected output: JSON-RPC error with code -32001 and message containing "blocked by safety classification".
Next steps
- WIT interface and TypeScript definitions: the authoritative API reference.
- JavaScript examples: more example action scripts.
- jco: the componentize tool documentation.
- Evaluators: configure the evaluator that runs your action.