Skip to content

AgentOS WASM Plugin Guide

This guide walks you through creating, building, testing, and deploying custom policy plugins for AgentOS using WebAssembly (WASM).

Overview

AgentOS evaluates every LLM request and response against a chain of policy filters. Filters can be built-in (keyword matching, regex) or custom WASM modules. WASM plugins let you ship arbitrary policy logic as a single .wasm file without recompiling AgentOS itself.

The host runtime is wazero -- a pure-Go WASM engine with zero CGO dependencies. Plugins run in a sandboxed environment with no access to the network, filesystem, or host memory outside their own linear memory.

How it works

  1. AgentOS loads your .wasm file at startup.
  2. For each request (or response), the host:
  3. Calls alloc twice to reserve space in the module's memory.
  4. Writes the content string and a JSON metadata blob into those buffers.
  5. Calls check(content_ptr, content_len, meta_ptr, meta_len).
  6. If check returns 1, the host reads the result JSON via get_result_ptr / get_result_len.
  7. The configured action (block, warn, log) is applied.

Prerequisites

No CGO, Docker, or special toolchain is needed beyond the Go compiler.

Quick start: your first plugin in 5 minutes

1. Create a new module

mkdir my-plugin && cd my-plugin
go mod init my-org/my-plugin

# Add the SDK as a dependency.
# If you cloned the AgentOS repo, use a replace directive:
go mod edit -require github.com/AgentOS/AgentOS/examples/wasm-plugin-sdk@v0.0.0
go mod edit -replace github.com/AgentOS/AgentOS/examples/wasm-plugin-sdk=../path/to/AgentOS/examples/wasm-plugin-sdk

2. Write the plugin

Create main.go:

package main

import (
    "strings"

    sdk "github.com/AgentOS/AgentOS/examples/wasm-plugin-sdk"
)

func init() {
    sdk.RegisterCheck(func(content string, meta sdk.Metadata) sdk.Result {
        if strings.Contains(strings.ToLower(content), "password") {
            return sdk.BlockResult("content mentions a password")
        }
        return sdk.PassResult()
    })
}

func main() {}

Key points: - Register your check in init(), not main(). - main() must exist but should be empty. - The SDK handles all WASM exports (alloc, check, get_result_ptr, get_result_len) automatically.

3. Build to WASM

GOOS=wasip1 GOARCH=wasm go build -o my-plugin.wasm .

Or with TinyGo for a smaller binary (~100KB vs ~2MB):

tinygo build -o my-plugin.wasm -target=wasip1 -no-debug .

4. Deploy

Copy the .wasm file to your AgentOS server and add it to your config:

policies:
  input:
    - name: "my-plugin"
      type: "wasm"
      action: "block"
      path: "plugins/my-plugin.wasm"
      timeout: 100ms
      on_error: "block"

5. Test

Send a request through AgentOS and confirm the plugin fires:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"openai-chat","messages":[{"role":"user","content":"what is my password"}]}'

You should get a policy violation response.

SDK reference

Types

// Metadata is the request context from the AgentOS host.
type Metadata struct {
    TenantID string `json:"tenant_id"`
    Model    string `json:"model"`
    Provider string `json:"provider"`
    Phase    string `json:"phase"`   // "input" or "output"
}

// Result is what your check function returns.
type Result struct {
    Block   bool
    Message string
}

Functions

Function Description
RegisterCheck(fn CheckFunc) Register your policy check. Call once in init().
PassResult() Result Return this to allow the request.
BlockResult(msg string) Result Return this to flag a violation with a reason.
ReadInput(ptr, len uint32) string (Advanced) Read raw memory. Most plugins do not need this.

CheckFunc signature

type CheckFunc func(content string, meta Metadata) Result
  • content -- the request or response text being evaluated.
  • meta -- context about the request (tenant, model, provider, phase).
  • Return PassResult() or BlockResult("reason").

ABI reference (low-level)

If you are writing a plugin in Rust, AssemblyScript, or another language that cannot import the Go SDK, you need to implement these four exports:

Export Signature (WASM types) Description
alloc (size: i32) -> i32 Allocate size bytes in module memory. Return the pointer.
check (content_ptr: i32, content_len: i32, meta_ptr: i32, meta_len: i32) -> i32 Evaluate the content. Return 0 to allow, 1 to block.
get_result_ptr () -> i32 After check returns 1, return a pointer to the result JSON.
get_result_len () -> i32 Length (bytes) of the result JSON.

Memory layout

Module linear memory
+------+---------------------------------------------+
| addr | content                                     |
+------+---------------------------------------------+
| 0x.. | [content bytes written by host via alloc]    |
| 0x.. | [metadata JSON written by host via alloc]    |
| 0x.. | [result JSON written by plugin after check]  |
+------+---------------------------------------------+

The host performs these steps in order:

  1. Call alloc(content_len) -- returns content_ptr.
  2. Write content bytes to [content_ptr .. content_ptr+content_len).
  3. Call alloc(meta_len) -- returns meta_ptr.
  4. Write metadata JSON to [meta_ptr .. meta_ptr+meta_len).
  5. Call check(content_ptr, content_len, meta_ptr, meta_len).
  6. If result is 1:
  7. Call get_result_ptr() and get_result_len().
  8. Read result_len bytes starting at result_ptr.

Metadata JSON (input to check)

{
  "tenant_id": "default",
  "model": "openai-chat",
  "provider": "openai",
  "phase": "input"
}

phase is "input" for request policies and "output" for response policies.

Result JSON (output on violation)

{
  "action": "block",
  "message": "human-readable reason the content was flagged"
}

The action field in the result JSON is informational only. The action configured in AgentOS.yaml always takes precedence.

Configuration reference

Add a WASM policy under the policies.input or policies.output section of your AgentOS config file (usually AgentOS.yaml):

policies:
  input:
    - name: "profanity-filter"
      type: "wasm"
      action: "block"        # block | warn | log
      path: "plugins/profanity-filter.wasm"
      timeout: 100ms         # max execution time per check (default: 100ms)
      on_error: "block"      # what to do if the plugin crashes: block | allow
  output:
    - name: "json-validator"
      type: "wasm"
      action: "block"
      path: "plugins/json-validator.wasm"
      timeout: 200ms
      on_error: "allow"
Field Required Default Description
name yes -- Unique identifier for this policy.
type yes -- Must be "wasm".
action yes -- Action on violation: block, warn, or log.
path yes -- Path to the .wasm file (relative to working dir or absolute).
timeout no 100ms Maximum time the plugin may run per invocation.
on_error no block Behavior when the plugin errors or times out.

Installing plugins from the marketplace

You can also install community plugins directly:

# Search for plugins
agentctl plugin search profanity

# Get details
agentctl plugin info profanity-filter

# Install (downloads .wasm, verifies SHA-256, updates plugins.yaml)
agentctl plugin install profanity-filter

# List installed plugins
agentctl plugin list

# Check for updates
agentctl plugin outdated

# Remove a plugin
agentctl plugin remove profanity-filter

Example plugins

The repository includes two ready-to-use example plugins built with the SDK:

Profanity filter

Location: examples/wasm-plugins/profanity-filter/

Blocks content containing common profane words. Demonstrates basic string matching with the SDK.

cd examples/wasm-plugins/profanity-filter
make build
# => profanity-filter.wasm

JSON validator

Location: examples/wasm-plugins/json-validator/

Blocks content that is not valid JSON. Useful as an output policy to enforce structured LLM responses.

cd examples/wasm-plugins/json-validator
make build
# => json-validator.wasm

Debugging tips

Plugin does not load

  • Verify the file path in your config is correct (try an absolute path).
  • Check that all four exports exist. Run:
    wasm-tools print my-plugin.wasm | grep "export"
    
    You should see alloc, check, get_result_ptr, get_result_len.
  • If using TinyGo, make sure you are targeting wasip1:
    tinygo build -target=wasip1 ...
    

Plugin times out

  • The default timeout is 100ms. Increase it in the config if your logic is legitimately slow.
  • Avoid unbounded loops or large allocations in your check function.
  • Profile with GODEBUG=gctrace=1 if you suspect GC pressure.

Plugin returns wrong result

  • Print your result JSON to stderr during development. wazero passes stderr through to the host's stderr by default.
  • Verify your result JSON matches the expected schema: {"action": "block", "message": "..."}.
  • Remember: the action in config overrides whatever your plugin returns. If config says warn but your JSON says block, the host uses warn.

Inspecting the WASM binary

# List exports
wasm-tools print plugin.wasm | grep export

# Disassemble to WAT (text format)
wasm-tools print plugin.wasm > plugin.wat

# Check binary size
ls -lh plugin.wasm

Logging from within a plugin

The standard Go println() and fmt.Fprintf(os.Stderr, ...) write to stderr in WASI modules. wazero routes module stderr to the host process stderr, so these messages appear in AgentOS's log output. Use them freely during development; remove or guard them for production to avoid noise.

Limitations and known issues

  • No networking. WASM plugins run in a sandbox with no socket access. If your policy needs to call an external API, use a native Go filter instead.
  • No filesystem access. Plugins cannot read files from the host. Pass all data through the content and metadata parameters.
  • Single-threaded execution. The host holds a mutex during check calls, so a single WASM module instance handles one request at a time. For high throughput, consider running multiple AgentOS replicas.
  • Memory limit. wazero enforces a default linear memory limit. Very large payloads (>10MB) may cause allocation failures.
  • No state between calls. Global variables persist between calls to the same module instance, but you should not rely on this for correctness. Module instances may be recycled.
  • Go standard library size. Plugins built with standard Go produce ~2MB .wasm files due to the runtime. Use TinyGo to get ~100KB binaries, but note that TinyGo has limited standard library support (e.g., no reflect, limited encoding/json).
  • //go:wasmexport requires Go 1.24+. Earlier Go versions do not support this directive.