Agent development
A node embeds a WebAssembly runtime. An agent is a wasm module with a memory cap, a run-time deadline, and four host functions - and nothing else.
Status: it runs, and it is not yet a product
The runtime runs modules, the four host functions below do what they say, and DeployAgent loads and runs a module you give it - it used to record a deployment in a map, report it running, and never execute a byte of wasm. What is still missing is the product around it: no gRPC surface exposes DeployAgent, so a module has to come from inside the process; deployments do not survive a restart; and running an agent costs nobody anything, because the runtime is not metered against the marketplace. Agents are a library feature, not a product one, and this page will not pretend otherwise.
The runtime
wazero, which is a pure-Go wasm runtime: no CGO, no external toolchain in the node. Every module gets a memory ceiling and a per-call deadline, so a guest that never returns is torn down instead of holding the calling goroutine forever.
The deadline is what a MaxFuel field used to promise here. wazero has no instruction meter to spend a fuel budget against, so that number was decoration and for {} in a guest ran until the process died. A deadline is a bound the runtime can enforce, and it enforces the property that matters. Exceeding it returns an error wrapping context.DeadlineExceeded, and the module is not usable afterwards.
// services/core/internal/agent/agent.go
var DefaultMemoryLimits = ResourceLimits{
MaxMemoryPages: 256, // 256 * 64KB = 16MB
MaxRunTime: 5 * time.Second, // per call into the guest
}What a guest cannot exhaust
A deadline bounds one call. It does not bound what a guest ACCUMULATES inside the deadline, and that turned out to be the larger hole. A guest logging a 60 KiB line in a loop wrote 586 MiB from one run; a guest calling send in a loop with no send policy - the secure default, where every send is refused - held 1172 MiB, because the refusals were recorded in full. Both numbers are measured, from purpose-built guests that are now fixtures in the test suite.
So the log and the send record are byte-budgeted as well as line-capped, and a guest past its budget has output dropped rather than the node growing. Ask SendsDropped() whether that happened: silence and success look identical from inside the guest, which is deliberate - a guest must not be able to tell how close it is to a limit and pace itself against it.
The limits you pass are also clamped, not trusted. A deployment asking for the wasm maximum of 65536 pages (4 GiB) gets 1024, and a run-time of an hour gets 30 seconds. The inbox is bounded in both messages and bytes. A limit an operator can raise without bound is not a limit, and a deployment request is not an operator decision.
A guest also cannot read a clock. No WASI module is instantiated and none of the four host functions returns a value, so there is no timer and no reply channel to build one from. That is a deliberate boundary rather than an omission: a guest runs in the node's own process beside the validator's signing key, and a nanosecond timer would let it time its own host calls and turn any data-dependent branch in the host into a side channel without breaking a single other limit. A module that imports wasi_snapshot_preview1 fails to load.
The host ABI
Four functions, and that is the whole surface a guest sees. An agent cannot open a socket, read a file, read a clock or spawn anything - not by policy but by construction, because nothing else is imported into the module. A module that asks for a fifth import does not load.
// Imported by the guest module from the host module "env":
log(offset: u32, length: u32)
send(target_offset: u32, target_length: u32, msg_offset: u32, msg_length: u32)
get_memory(offset: u32, length: u32)
set_memory(offset: u32, length: u32)Each takes a pointer and a length into the guest's own linear memory, which is the usual wasm convention for passing bytes across the boundary. None of them trusts those numbers: a range that is not wholly inside the guest's memory is a refused call, reported on the agent's stderr, rather than a read of whatever sits next to it. The ABI has no return values, so a guest cannot be handed an error - a refusal is visible to the operator, not to the module.
logwrites the guest's bytes to the agent's stdout, tagged with its id, capped at 10,000 lines so a guest in a loop cannot fill a disk through the host's logger.set_memoryandget_memorymove bytes between the guest and a host-side buffer that outlives a call. The buffer is deliberately not addressable by the guest: those two functions are the only way in and out of it.sendhands a target and a payload to theSendFuncthe host configured. Who a module may address is a policy question, so it is a deliberate, configurable send policy with a secure default rather than an invented one. By default inter-agent send is off: every send is refused and reported on the guest's stderr, and the attempt is recorded either way so you can see what a module tried to do. An operator opts in by settingagent.allow_sendand naming an allowlist inagent.send_allowlist; a name is a deployment id on the same node, and a permitted send is delivered into that agent's inbox. There is no wildcard and nothing off-node is addressable.
Building a module
# A guest module can be written in any language that targets wasm.
# Rust, for example:
cargo new --lib my-agent
# Cargo.toml: crate-type = ["cdylib"]
cargo build --target wasm32-unknown-unknown --release
# -> target/wasm32-unknown-unknown/release/my_agent.wasmAny language that compiles to wasm32 works - Rust, Go via TinyGo, C, Zig. The module only has to import the four functions above and export what the host starts.
Loading one
// From Go, inside the process:
a, err := agent.New(ctx, agent.Config{
ID: "my-agent",
Code: wasmBytes, // a compiled module
Stdout: os.Stdout,
Stderr: os.Stderr,
// Nil refuses every send() the guest makes, and says so on stderr.
Send: func(target string, payload []byte) error { return nil },
}, agent.DefaultMemoryLimits)
if err != nil { return err }
if err := a.Start(ctx); err != nil { return err } // runs _start under MaxRunTime
defer a.Stop(ctx)
out, err := a.Call(ctx, "some_export") // any export, same deadline
a.Memory() // the host-side buffer set_memory writes
a.Sent() // every send() the guest attemptedOr through the admin deploy service, which takes the module as a path on the node or inline as base64:
// Or through the admin service, from a module on the node or inline:
deploySvc.DeployAgent(ctx, "my-agent", map[string]interface{}{
"wasm_path": "/srv/agents/my-agent.wasm",
// or: "wasm_base64": "<the module, base64>"
})
deploySvc.Agent("my-agent") // the loaded runtime, to call exports
deploySvc.StopDeployment(ctx, "my-agent") // closes the module and its runtimeSee internal/agent for the runtime itself.
What has to be built next
- Expose
DeployAgentover gRPC, so an operator can upload a module instead of driving the service from inside the process. It loads and runs one today; nothing outside the node can ask it to. - Persist deployments, so an agent survives a restart.
- Meter execution against the marketplace, so running an agent costs MATRIX the way a compute job does. Today the deadline protects the node but bills nobody.
- Route
sendoff-node. Inter-agent send now has a real, configurable policy (a name resolves to another agent's inbox on the same node, refused by default, opt-in viaagent.allow_sendandagent.send_allowlist). What is still unbuilt is addressing an agent on a different node: the primitive is deliberately local for now.
Next
- Quickstart - the parts of the stack that are finished
- Soul Protocol - the identity layer agents are meant to use, also unserved