Assembly Required: Linking Capabilities and Building Modules
The glue that turns a function body into a complete WASM module.
We have capabilities that define host functions. We have an engine that compiles WAT. We have a state struct that holds memory, arguments, and results. But we are still missing the glue: how does a function body become a complete WASM module? How do capabilities get wired into a compiled module so it can be instantiated quickly and repeatedly?
This article builds the last two pieces of the execution pipeline: the Template, which assembles WAT modules from parts, and the Linker, which pre-links compiled modules against registered capabilities for fast re-instantiation.
The Problem
An LLM generates a WAT function body — the instructions inside (func $run ...). It does not generate a complete module. A valid WASM module needs:
- Import declarations for every host function the body calls
- A memory export (
mem.tape) for the bump allocator - The function wrapped in the correct
(func $run (result i32) ...)syntax - An export declaration for the
runfunction
The imports depend on which capabilities are active. If we give the LLM HTTP and AI capabilities, the module needs both sets of imports. If we only give it core, it needs fewer. The Template handles this assembly.
Once the module is assembled and compiled, we need to connect its import declarations to actual host function implementations. wasmtime's Linker handles this, but there's a performance consideration: linking resolves imports by walking the module's import list and matching names. For a module that gets instantiated once, this is fine. For a module that gets instantiated hundreds of times (e.g., a library program called repeatedly), it's wasteful.
wasmtime provides InstancePre — a pre-linked module where all imports are already resolved. Instantiating an InstancePre skips the import resolution step entirely. Our LinkedModule wraps this.
Template
The Template collects WAT import declarations and human-readable descriptions from capabilities, then assembles a complete module by wrapping a function body.
// rt/src/template.rs
pub struct AssembleResult {
/// The complete WAT module text.
pub wat: String,
/// Static data sections to write into mem.tape before execution.
pub data_sections: Vec<DataSection>,
/// Initial bump allocator top (past the static string zone).
pub initial_top: u32,
}
pub struct Template {
wat_imports: Vec<String>,
descriptions: Vec<String>,
}Adding Capabilities
impl Template {
pub fn new() -> Self {
Template {
wat_imports: Vec::new(),
descriptions: Vec::new(),
}
}
pub fn add_capability(&mut self, cap: &dyn Capability) {
let imports = cap.wat_imports().trim();
if !imports.is_empty() {
self.wat_imports.push(imports.to_string());
}
let desc = cap.describe().trim_end();
if !desc.is_empty() {
self.descriptions.push(desc.to_string());
}
}Each call to add_capability appends that capability's import declarations and descriptions. The order doesn't matter for correctness — WAT import order is irrelevant — but we preserve insertion order for readable output.
Assembly
The assemble method takes a function body (the WAT instructions) and produces a complete module:
pub fn assemble(&self, body: &str) -> Result<AssembleResult> {
let pre = crate::preprocessor::preprocess(body)?;
let mut wat = String::from("(module\n");
for import_block in &self.wat_imports {
for line in import_block.lines() {
wat.push_str(" ");
wat.push_str(line.trim());
wat.push('\n');
}
}
wat.push_str(" (memory $mem.tape 1)\n");
wat.push_str(" (export \"mem.tape\" (memory $mem.tape))\n\n");
wat.push_str(" (func $run (result i32)\n");
for line in pre.body.lines() {
wat.push_str(" ");
wat.push_str(line);
wat.push('\n');
}
wat.push_str(" )\n");
wat.push_str(" (export \"run\" (func $run))\n");
wat.push_str(")\n");
Ok(AssembleResult {
wat,
data_sections: pre.data_sections,
initial_top: pre.initial_top,
})
}The method does three things in order:
-
Preprocess the body. The preprocessor (covered in a later article) handles string literal inlining and local declaration hoisting. It returns the processed body text, any static data sections, and the initial allocator top.
-
Build the WAT text. Import declarations come first (WASM requires imports before other definitions), then the memory declaration and export, then the function body wrapped in
(func $run (result i32) ...), and finally the function export. -
Return the result. The
AssembleResultcarries the WAT text, the data sections (static strings that need to be written into memory before execution), and the initial allocator top (so the bump allocator starts after the static data zone).
For a body like (i32.const 42) with CoreCapability registered, the assembled WAT looks like:
(module
(import "sys" "alloc" (func $sys.alloc (param i32) (result i32)))
(import "sys" "argv" (func $sys.argv (param i32) (result i32 i32)))
(import "sys" "resv" (func $sys.resv (param i32)))
(import "sys" "nil" (func $sys.nil (result i32)))
(memory $mem.tape 1)
(export "mem.tape" (memory $mem.tape))
(func $run (result i32)
(i32.const 42)
)
(export "run" (func $run))
)Describing Imports for the LLM
The describe_imports method produces a human-readable string that tells the LLM what host functions are available:
pub fn describe_imports(&self) -> String {
if self.descriptions.is_empty() {
return String::new();
}
let mut out = String::new();
for desc in &self.descriptions {
out.push_str(desc);
out.push_str("\n\n");
}
out
}This is used by the agent's prompt construction. The LLM sees something like:
sys.alloc(size: i32) -> i32
Allocate `size` bytes of memory. Returns a pointer.
sys.nil() -> i32
Get a pointer to a serialized None value.
http.get(url_ptr: i32) -> (i32, i32)
Fetch a URL via HTTP GET. Returns (result_ptr, error_code).The LLM uses this to decide which host functions to call in its generated WAT.
Linker
The Linker wraps wasmtime's linker, providing two operations: registering capabilities and pre-linking compiled modules.
// rt/src/linker.rs
pub struct Linker {
inner: wasmtime::Linker<State>,
}
impl Linker {
pub fn new(engine: &Engine) -> Self {
Linker {
inner: wasmtime::Linker::new(engine.inner()),
}
}
pub fn register(&mut self, cap: &dyn Capability) -> Result<()> {
cap.register(&mut self.inner)
}
pub fn pre_link(&self, module: &Module) -> Result<LinkedModule> {
let pre = self
.inner
.instantiate_pre(module.inner())
.map_err(Error::Other)?;
Ok(LinkedModule {
inner: Arc::new(pre),
hash: module.hash(),
})
}
}The register method delegates to the capability's own register method, passing the wasmtime linker. The pre_link method calls instantiate_pre, which verifies that all of the module's imports are satisfied by the linker's registered functions, and produces an InstancePre — a pre-resolved handle ready for fast instantiation.
If the module imports a function that hasn't been registered, instantiate_pre fails. This is a compile-time-equivalent check: you find out about missing capabilities before execution, not during.
LinkedModule
A LinkedModule is the result of pre-linking. It carries the wasmtime InstancePre (wrapped in Arc for cheap cloning) and the content hash of the original module.
// rt/src/linker.rs
#[derive(Clone)]
pub struct LinkedModule {
inner: Arc<wasmtime::InstancePre<State>>,
pub hash: u64,
}The hash is useful for caching. If you have a HashMap<u64, LinkedModule>, you can check whether a given WAT body has already been compiled and pre-linked, skipping both steps on a cache hit.
Instantiation
This is the hot path — where the actual WASM instance comes to life:
impl LinkedModule {
pub async fn instantiate(
&self,
engine: &Engine,
extensions: ExtensionMap,
args: Vec<Vec<u8>>,
data_sections: Vec<DataSection>,
initial_top: u32,
) -> Result<Instance> {
let arg_count = args.len();
let state = State {
memory: WasmMemory::new(engine.config().max_memory_bytes, initial_top),
args,
results: Vec::new(),
arg_pointers: vec![None; arg_count],
nil_pointer: None,
extensions,
};
let mut store = wasmtime::Store::new(engine.inner(), state);
if let Some(fuel) = engine.config().fuel {
store.set_fuel(fuel).map_err(Error::Other)?;
}
let instance = self
.inner
.instantiate_async(&mut store)
.await
.map_err(Error::from)?;
// Write static string data sections into mem.tape
if !data_sections.is_empty() {
let mem = instance
.get_export(&mut store, "mem.tape")
.and_then(|e| e.into_memory())
.ok_or_else(|| Error::Other(anyhow::anyhow!("mem.tape not found")))?;
let needed_pages = (initial_top as u64).div_ceil(65536);
let current_pages = mem.size(&store);
if needed_pages > current_pages {
mem.grow(&mut store, needed_pages - current_pages)
.map_err(Error::Other)?;
}
for ds in &data_sections {
mem.write(&mut store, ds.offset as usize, &ds.bytes)
.map_err(|e| Error::Other(e.into()))?;
}
}
let run_fn = instance
.get_typed_func::<(), u32>(&mut store, "run")
.map_err(Error::Other)?;
Ok(Instance::new(store, run_fn))
}
}The method does the following, step by step:
-
Build the State. Create a fresh
WasmMemorywith the engine's memory limit and the initial top from the preprocessor. Initializearg_pointersas a vector ofNonewith one slot per argument. Set results to empty, nil_pointer toNone, and attach the providedExtensionMap. -
Create the Store. wasmtime's
Storeowns theStateand provides it to host functions viaCaller. If fuel metering is configured, set the initial fuel amount. -
Instantiate.
instantiate_asynccreates the WASM instance from the pre-linked module. This is fast — no import resolution, just memory allocation and initialization. -
Write data sections. If the preprocessor produced static data sections (from string literals in the WAT body), write them into
mem.tapeat their specified offsets. Grow pages if the static data zone exceeds the initial page. This must happen before execution because the WAT body references these offsets with(i32.const N)instructions. -
Find the run function. Look up the
runexport and cast it toTypedFunc<(), u32>. This gives us a type-safe handle: calling it with()returns au32exit code. -
Return the Instance. The caller now has a ready-to-run WASM instance.
Helper Functions
Two utility functions round out the linker module:
/// Serialize a value as an argument byte vector.
pub fn serialize_arg<T: Serialize>(val: &T) -> Result<Vec<u8>> {
protocol::to_bytes(val).map_err(Error::from)
}
/// Compute the content hash of a byte slice.
pub fn content_hash(bytes: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}serialize_arg is a convenience wrapper that the orchestrator uses to prepare arguments for LinkedModule::instantiate(). It takes any serializable value and produces the byte vector that sys.argv will later write into WASM memory.
content_hash uses the same algorithm as the engine's module cache. External code can use it to check whether a given WAT body is already cached without calling compile_wat.
The Full Pipeline
Here is the complete path from a WAT function body to an executed program:
WAT body
│
▼
preprocess()
│ - Inline string literals into data sections
│ - Hoist local declarations
│ - Expand macros (argv, check, resv)
▼
Template.assemble()
│ - Prepend capability imports
│ - Wrap in (module ... (func $run ...) ...)
│ - Return AssembleResult { wat, data_sections, initial_top }
▼
Engine.compile_wat()
│ - Hash the WAT text
│ - Check cache (hit → return cached Module)
│ - Compile via wasmtime::Module::new()
│ - Insert into cache
▼
Linker.pre_link()
│ - Verify all imports are satisfied
│ - Produce InstancePre (pre-resolved imports)
│ - Wrap in LinkedModule with content hash
▼
LinkedModule.instantiate()
│ - Create State (memory, args, extensions)
│ - Create Store with fuel
│ - instantiate_async (fast — no import resolution)
│ - Write data sections into mem.tape
│ - Find "run" export
▼
Instance.run()
│ - call_async the run function
│ - Returns exit code (u32)
▼
Instance.result::<T>(0)
│ - Deserialize from State.results
▼
ResultEach step is independently testable. The engine knows nothing about capabilities. The template knows nothing about compilation. The linker knows nothing about memory management. Each layer adds one concern.
Where Caching Applies
Two layers have caches:
-
Engine cache: Keyed by WAT text hash. If the same WAT body is assembled with the same imports twice, the compiled
wasmtime::Moduleis reused. This is the most expensive step to cache — WAT compilation involves parsing, validation, and machine code generation. -
LinkedModule: Carries its own hash, and can be stored in an external cache. If the same module is pre-linked against the same set of capabilities, the
InstancePreis reused. This is cheaper to create than a compiled module, but still worth caching for frequently-used library programs.
Instantiation is intentionally not cached. Each instantiation gets fresh state — fresh memory, fresh arguments, fresh results. Sharing state between executions would break isolation.
Tests
Minimal End-to-End
Assemble a trivial WAT body, compile it, pre-link it, instantiate it, and run it:
#[tokio::test]
async fn run_minimal() {
let engine = Engine::new(EngineConfig::default()).unwrap();
let linker = Linker::new(&engine);
let wat = r#"(module
(memory (export "mem.tape") 1)
(func (export "run") (result i32) (i32.const 42))
)"#;
let module = engine.compile_wat(wat).unwrap();
let linked = linker.pre_link(&module).unwrap();
let mut instance = linked
.instantiate(&engine, ExtensionMap::new(), vec![], vec![], 0)
.await
.unwrap();
let code = instance.run().await.unwrap();
assert_eq!(code, 42);
}This test uses a hand-written module (not assembled from a body) to verify the pipeline from compilation onward. No capabilities are registered, and no arguments are passed. The program returns 42.
Fuel Exhaustion
Verify that an infinite loop is stopped by fuel metering:
#[tokio::test]
async fn fuel_exhaustion() {
let config = EngineConfig {
fuel: Some(1),
..Default::default()
};
let engine = Engine::new(config).unwrap();
let linker = Linker::new(&engine);
let wat = r#"(module
(memory (export "mem.tape") 1)
(func (export "run") (result i32)
(loop (br 0))
(i32.const 0)
)
)"#;
let module = engine.compile_wat(wat).unwrap();
let linked = linker.pre_link(&module).unwrap();
let result = linked
.instantiate(&engine, ExtensionMap::new(), vec![], vec![], 0)
.await
.unwrap()
.run()
.await;
assert!(matches!(result, Err(crate::error::Error::FuelExhausted)));
}The engine is configured with fuel: Some(1) — barely enough to start execution, nowhere near enough for an infinite loop. The loop (br 0) instruction branches back to the loop start unconditionally, but fuel runs out and execution traps with Error::FuelExhausted.
This is the hard guarantee we discussed in Article 1. The guest cannot evade fuel metering. There is no instruction to disable it, no way to request more fuel, no cooperative yield the guest can forget to call. The engine deducts fuel on every instruction, and when it hits zero, execution stops.
Template Assembly
Verify that the template produces valid WAT:
#[test]
fn assemble_minimal() {
let template = Template::new();
let result = template.assemble("(i32.const 42)").unwrap();
assert!(result.wat.contains("(module"));
assert!(result.wat.contains("(memory $mem.tape 1)"));
assert!(result.wat.contains("(i32.const 42)"));
assert!(result.wat.contains("(export \"run\" (func $run))"));
// Verify it's valid WAT by parsing with wasmtime
let engine = wasmtime::Engine::default();
wasmtime::Module::new(&engine, &result.wat)
.expect("assembled WAT should be valid");
}The strongest assertion here is the last one: the assembled WAT compiles without error. This catches structural issues — mismatched parentheses, missing exports, invalid import syntax — that string matching would miss.
What We Have So Far
After this article, the execution pipeline is complete. Starting from a WAT function body, we can:
- Preprocess it (string inlining, local hoisting)
- Assemble it into a complete WAT module with capability imports
- Compile it to machine code (with caching)
- Pre-link it against registered capabilities (for fast re-instantiation)
- Instantiate it with arguments and per-execution state
- Run it with fuel metering
- Extract typed results
This is the entire infrastructure the agent needs to execute LLM-generated programs safely. What remains is the agent itself: the orchestrator that generates WAT bodies, decides which capabilities to provide, runs the programs, observes the results, and iterates. But first, we need to make WAT generation easier — because writing raw WAT by hand (or by LLM) is painful. The next article addresses that.