If the module type is module-lua — write a standalone Lua module:
Write the module in custom-lua/{name}.lua:
local state = {}
local function intstr(n)
if n == math.floor(n) then return string.format("%d", n) end
return tostring(n)
end
function compute(base, req, opts)
local action = nil
if type(req) == "table" then
if type(req.body) == "table" then
action = req.body.Action or req.body.action
end
if not action then
action = req.Action or req.action
end
end
if action == "MyAction" then
end
base.results = {
outbox = {
["1"] = { data = "result" }
},
output = ""
}
return base
end
Key patterns:
- Entry point is
compute(base, req, opts) — NOT Handlers.add
- Action is in
req.body.Action or req.body.action (HTTP lowercases headers)
- Response goes in
base.results.outbox — string-keyed table
- Module-level variables persist between compute calls (Lua VM state is saved)
- No AOS framework available — no
Handlers, ao.send, msg.reply
If the module type is module-wasm — write a Rust WASM64 module:
Write the module in custom-wasm/src/lib.rs:
#![no_std]
#![no_main]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! { loop {} }
static mut BUMP: usize = 4096;
#[no_mangle]
pub extern "C" fn malloc(size: usize) -> usize {
unsafe { let ptr = BUMP; BUMP += size; ptr }
}
#[no_mangle]
pub extern "C" fn free(_ptr: usize) -> usize { 0 }
unsafe fn copy_bytes(dst: *mut u8, src: *const u8, len: usize) {
let mut i = 0;
while i < len { *dst.add(i) = *src.add(i); i += 1; }
}
#[no_mangle]
pub extern "C" fn handle(msg_ptr: usize, _proc_ptr: usize) -> usize {
unsafe { 0 }
}
Key constraints:
#![no_std] required — WAMR rejects memory.copy from std
- All pointers are
i64 (memory64 WASM)
- Must export
malloc, free, handle
- Response is JSON with
Messages array for outbox
- Use manual byte loops, not memcpy
Build with:
cd custom-wasm && cargo +nightly build --target wasm64-unknown-unknown --release -Zbuild-std=core,panic_abort -Zbuild-std-features=panic_immediate_abort
If the task type is module-test — write HyperBEAM integration tests:
For Lua modules — write test/{name}-module.test.js:
import { describe, it, before, after } from "node:test"
import assert from "node:assert"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { HyperBEAM } from "wao/test"
import HB from "wao/hb"
const luaPath = resolve(import.meta.dirname, "../custom-lua/{name}.lua")
async function spawnCustomLua(hb, moduleId) {
return hb.spawn({
"data-protocol": "ao",
variant: "ao.TN.1",
module: moduleId,
"execution-device": "lua@5.3a",
"push-device": "push@1.0",
"patch-from": "/results/outbox",
})
}
describe("{name} custom Lua module", () => {
let hbeam, hb, moduleId
before(async () => {
hbeam = await new HyperBEAM({ reset: true }).ready()
hb = new HB({ url: hbeam.url })
await hb.init(hbeam.jwk)
const luaSrc = readFileSync(luaPath, "utf-8")
moduleId = await hb.cacheScript(luaSrc, "application/lua")
})
after(async () => { if (hbeam) hbeam.kill() })
it("should spawn process with module", async () => {
const { pid } = await spawnCustomLua(hb, moduleId)
assert.ok(pid)
})
})
For WASM modules — write test/{name}-module.test.js:
import { describe, it, before, after } from "node:test"
import assert from "node:assert"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { HyperBEAM } from "wao/test"
import HB from "wao/hb"
const wasmPath = resolve(import.meta.dirname,
"../custom-wasm/target/wasm64-unknown-unknown/release/custom_wasm.wasm")
describe("{name} custom WASM64 module", () => {
let hbeam, hb
before(async () => {
hbeam = await new HyperBEAM({ reset: true }).ready()
hb = new HB({ url: hbeam.url })
await hb.init(hbeam.jwk)
})
after(async () => { if (hbeam) hbeam.kill() })
it("should cache and spawn", async () => {
const wasm = readFileSync(wasmPath)
const imageId = await hb.cacheBinary(wasm, "application/wasm")
const { pid } = await hb.spawnAOS({ image: imageId })
assert.ok(pid)
})
})