Skip to main content
When a task requires continuous, closed-loop control, the agent writes a WASM controller. The controller is a small program that runs at 100 Hz inside a wasmtime sandbox, reading sensor state and writing motor commands through the channel interface. The agent writes the code in WAT (WebAssembly Text format), which is human-readable and easy for LLMs to generate. The deploy_controller tool compiles, verifies, and deploys the controller — all in one step.

Required Export

Every controller must export a process function that takes a tick counter:
(func (export "process") (param i64))
This function is called once per tick (every 10 ms at 100 Hz). The i64 parameter is a monotonically increasing tick counter starting from 0.

Host Function Table

Controllers import host functions to interact with the robot. These are the available imports:
ModuleFunctionSignatureDescription
commandset(i32, f64) -> i32Write a value to command channel
commandcount() -> i32Number of command channels
commandlimit_min(i32) -> f64Min limit for channel
commandlimit_max(i32) -> f64Max limit for channel
stateget(i32) -> f64Read a state channel
statecount() -> i32Number of state channels
mathsin(f64) -> f64Sine (WASM has no trig intrinsics)
mathcos(f64) -> f64Cosine
safetyrequest_estop() -> ()Request an emergency stop
timingnow_ns() -> i64Wall-clock nanoseconds since epoch
timingsim_time_ns() -> i64Simulation time in nanoseconds
telemetryemit_metric(f64) -> ()Record a scalar metric for observability
timing::sim_time_ns respects pause and speed scaling in simulation, while timing::now_ns returns real wall-clock time. Use sim_time_ns for time-based trajectories.

Example: Constant Velocity

A minimal controller that sets all joints to a constant velocity:
(module
  (import "command" "set" (func $set (param i32 f64) (result i32)))
  (import "command" "count" (func $count (result i32)))

  (func (export "process") (param $tick i64)
    (local $i i32)
    (local.set $i (i32.const 0))
    (block $break
      (loop $loop
        (br_if $break (i32.ge_s (local.get $i) (call $count)))
        (drop (call $set (local.get $i) (f64.const 0.5)))
        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        (br $loop)
      )
    )
  )
)

Example: Sine Wave Oscillation

A controller that oscillates joint 0 with a sine wave using simulation time:
(module
  (import "command" "set" (func $set (param i32 f64) (result i32)))
  (import "math" "sin" (func $sin (param f64) (result f64)))
  (import "timing" "sim_time_ns" (func $sim_time (result i64)))

  (func (export "process") (param $tick i64)
    (local $t f64)
    ;; Convert sim time from nanoseconds to seconds
    (local.set $t
      (f64.div
        (f64.convert_i64_s (call $sim_time))
        (f64.const 1e9)
      )
    )
    ;; joint 0 = sin(2pi * 0.5Hz * t) rad/s
    (drop
      (call $set
        (i32.const 0)
        (call $sin
          (f64.mul
            (f64.const 3.14159265358979)  ;; pi * frequency
            (local.get $t)
          )
        )
      )
    )
  )
)

Deploy Lifecycle

When the agent calls deploy_controller, the code goes through a multi-step pipeline before reaching the robot:
1

Compile

WAT source is compiled to WASM bytecode using wasmtime. Compilation errors are returned to the agent immediately.
2

Link host functions

The WASM module’s imports are resolved against the host function table. Missing or mismatched imports cause a link error.
3

Verify (100 ticks)

The compiled module runs for 100 ticks under production safety limits. The safety filter checks every command value against the channel manifest’s limits. If any tick produces a safety violation (value exceeds limits, NaN output, or a WASM trap), the controller is rejected.
4

Deploy to Copper

The verified WASM binary is sent to the running Copper control loop via an internal channel. The new controller replaces the previous one atomically. There is no gap in control — the old controller runs until the new one is ready.

Epoch-Based Interruption

Each process(tick) call has an 8 ms budget. If the controller does not return within 8 ms, wasmtime’s epoch interruption mechanism traps the execution. This prevents infinite loops or excessively complex computations from blocking the 100 Hz control loop. The 8 ms budget leaves 2 ms of headroom within the 10 ms tick period for safety filtering, actuator communication, and sensor reads.
  |--- 10 ms tick period ---|
  |-- 8 ms WASM budget --|-- 2 ms overhead --|

What Happens on Rejection

If verification fails, the deploy_controller tool returns an error message describing what went wrong. The agent can then fix the code and try again. Common rejection reasons:
  • Compilation error — invalid WAT syntax or unsupported WASM features
  • Link error — importing a host function that does not exist or with the wrong signature
  • Safety violation — a command value exceeded the channel’s configured limits during the 100-tick verification
  • NaN output — a command channel received a NaN or infinite value (mapped to zero by the safety filter, but flagged as a rejection)
  • WASM trap — the module hit an unreachable instruction, divided by zero, or exceeded the epoch deadline
Controllers run in a memory-isolated wasmtime sandbox with a 16 MiB memory cap. They cannot access the filesystem, network, or any host state beyond the channel interface. The unsafe keyword is denied workspace-wide in roz.

Source