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
- AgentOS loads your
.wasmfile at startup. - For each request (or response), the host:
- Calls
alloctwice to reserve space in the module's memory. - Writes the content string and a JSON metadata blob into those buffers.
- Calls
check(content_ptr, content_len, meta_ptr, meta_len). - If
checkreturns1, the host reads the result JSON viaget_result_ptr/get_result_len. - The configured
action(block, warn, log) is applied.
Prerequisites
- Go 1.24 or later (for
GOOS=wasip1 GOARCH=wasmsupport).
Go 1.24 added//go:wasmexportwhich this SDK uses. - (Optional) TinyGo 0.34+ for smaller binaries.
Install: https://tinygo.org/getting-started/install/
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
Or with TinyGo for a smaller binary (~100KB vs ~2MB):
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
content-- the request or response text being evaluated.meta-- context about the request (tenant, model, provider, phase).- Return
PassResult()orBlockResult("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:
- Call
alloc(content_len)-- returnscontent_ptr. - Write
contentbytes to[content_ptr .. content_ptr+content_len). - Call
alloc(meta_len)-- returnsmeta_ptr. - Write metadata JSON to
[meta_ptr .. meta_ptr+meta_len). - Call
check(content_ptr, content_len, meta_ptr, meta_len). - If result is
1: - Call
get_result_ptr()andget_result_len(). - Read
result_lenbytes starting atresult_ptr.
Metadata JSON (input to check)
phase is "input" for request policies and "output" for response policies.
Result JSON (output on violation)
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.
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.
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:
You should see
alloc,check,get_result_ptr,get_result_len. - If using TinyGo, make sure you are targeting
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=1if 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
actionin config overrides whatever your plugin returns. If config sayswarnbut your JSON saysblock, the host useswarn.
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
contentandmetadataparameters. - Single-threaded execution. The host holds a mutex during
checkcalls, 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
.wasmfiles due to the runtime. Use TinyGo to get ~100KB binaries, but note that TinyGo has limited standard library support (e.g., noreflect, limitedencoding/json). //go:wasmexportrequires Go 1.24+. Earlier Go versions do not support this directive.