Wasm Components for Portable Agent Tools
Package agent tools as typed WebAssembly components with WIT contracts, capability imports, resource lifetimes, host adapters, and conformance receipts.
Wasm Components can turn an agent tool into a typed portable unit, but portability only exists when the interface and every host capability are explicit.
This tutorial packages one document summarizer through WIT, resource ownership, capability imports, host adapters, limits, and a conformance suite that runs across runtimes.
Wasm Components begin at the tool boundary
Choose one product verb with stable inputs, outputs, errors, and cancellation semantics before writing WIT. A document summarizer is narrow enough to explain, while a component called operate-system usually hides authority that no interface can make safe. Open a one-verb boundary brief before debating the boundary. The component host expects caller, input ownership, result, error vocabulary, time budget, effect policy, and non-goals, and the runtime owner uses that record to revisit the premise.
The runtime owner can call Wasm Components credible only after the component host makes WebAssembly component model visible. A smaller world travels farther and is easier to distrust productively.
Start the component exercise by drawing a box around one verb. Summarize accepts a document handle, returns text or a named error, and does not silently publish, browse, or mutate storage. This narrow silhouette is what lets another host understand the tool without inheriting its original application's accidental authority.
Write WIT for meaning, not transport
The runtime owner stages one deliberate failure: generate bindings in two languages and compare the public shapes. The component host closes the branch when every parameter is an unvalidated string containing JSON. Model records, variants, options, results, and named resources around product semantics rather than JSON envelopes copied from one HTTP endpoint. Stable types let hosts bind the same tool into JavaScript, Rust, Python, or another supported language without guessing field meaning.
With WIT interfaces in view, the runtime owner asks a sharper Wasm Components question of the component host. Good WIT lets the interface survive several binding generators without becoming vague.
WIT reads best when it describes meaning rather than a serialized request. Records, variants, results, and resources can express a durable semantic shape while generated bindings handle language details. If transport headers or JavaScript object quirks enter the interface, the supposedly portable contract has already selected a favorite host.
The interface vocabulary comes directly from WebAssembly Component Model, WIT MVP design, and Bytecode Alliance WIT guide. The component-model repository and WIT references define interface structure and canonical lowering. They do not grant a component ambient filesystem, network, clock, or secret access; each host still decides which imports exist.
- WIT: Describe the verb
- Bind: Lower canonically
- Grant: Import narrowly
- Run: Meter the tool
Inventory every imported capability
The component host gives WASI capabilities a boundary, turning Wasm Components into something the runtime owner can inspect. Authority that is absent from the import list is authority the component cannot accidentally use.
Imports are the component's visible appetite. Clocks, randomness, files, network, and environment access should appear as named dependencies that the host can grant or refuse. An empty capability row is meaningful; it says the tool can still perform its central transformation without quietly reaching into ambient machine state.
List clock, randomness, filesystem, network, environment, logging, and secret needs as host-granted imports. A component that only transforms supplied bytes should not receive a directory or socket merely because the development runtime makes that convenient. Run run with each optional capability removed and inspect the exact failure, then read a capability import ledger from the result backward. The component host gives the runtime owner a revision target wherever the accounts split.
Use resources for owned host state
Represent documents, streams, indexes, or sessions as resources when identity and lifetime matter across calls. Define who creates, borrows, transfers, and drops them so generated bindings cannot leak a handle or keep private data alive indefinitely.
No continuation is allowed until a resource lifetime diagram carries resource type, constructor, methods, owner, transfer rules, destructor, and quota. Missing context is a failed component host handoff to the runtime owner. Here portable agent tools is not background vocabulary; it is the runtime owner's test for Wasm Components inside the component host. Ownership becomes testable when cancellation and drop behavior are part of the fixture.
Resources give lifetime a type. A document handle can stay owned by the host, borrowed during summarization, and released on cancellation without copying a large buffer across every boundary. The useful question is not whether a handle exists, but who may retain it after the exported call returns.
Keep the canonical ABI behind bindings
The decisive disturbance is to execute identical fixtures through two independent component runtimes. If one host requires undocumented memory layout knowledge, the runtime owner sends the component host design back for correction. When WebAssembly component model changes, the component host sends the runtime owner back through the Wasm Components decision. Keep generated code replaceable; the semantic world is the asset worth reviewing.
Treat lifting and lowering as generated infrastructure, then test the semantic boundary above it. Hand-coded pointer conventions or shared-memory shortcuts may be fast locally but replace component portability with a private ABI that other runtimes cannot validate.
The canonical ABI is plumbing, not product vocabulary. Generated lowering and lifting should absorb pointer layout, strings, and language conventions while the WIT world stays readable to reviewers. Hand-written ABI glue feels expedient until a second runtime reveals which assumptions were never actually part of the interface. Cross-runtime snapshots make that separation concrete during upgrades.
| Layer | Portable | Host-owned | Proof |
|---|---|---|---|
| Types | WIT | Validation | Bindings |
| Files | Handle | Scope | Denial test |
| Network | Import | Allowlist | Trace |
| Limits | Error | Budget | Trap corpus |
Build a policy-bearing host adapter
A host adapter is where portable syntax meets local policy. The same component may receive a memory-backed document in a browser host and a scoped file handle on a server, yet both hosts must enforce identical result, timeout, and cancellation semantics. Portability requires behavioral agreement, not identical implementation code.
Annotate a host enforcement map after reproducing substitute a component with the same WIT and a different digest. The runtime owner uses that divergence to debug the component host.
The adapter should authenticate the caller, validate arguments, grant least capability, meter work, translate errors, and record effects. Keep these decisions outside component business logic when they depend on deployment identity or organization policy. The runtime owner uses WIT interfaces to challenge which Wasm Components assumption the component host has left implicit. Two different adapters should still tell the caller the same story about success and failure. Policy differences should appear as named capabilities, never surprises.
Bound memory, fuel, time, and output
Portable execution is not automatically bounded execution. Set memory ceilings, instruction or epoch limits where supported, wall deadlines, import quotas, and maximum result sizes, then return a stable resource-exhaustion error instead of crashing the host. By naming WASI capabilities, the component host converts Wasm Components from convention into an operating choice for the runtime owner. The host remains healthy by turning resource exhaustion into an ordinary, bounded outcome.
Bundle limit source, measured peak, timeout, trap kind, partial output policy, and recovery path with a runtime budget table. That pairing lets the runtime owner audit the component host without recreating hidden state.
Metering is part of the call contract because a valid type can still request absurd work. Memory ceilings, fuel or epoch interruption, output limits, and cancellation deadlines turn runaway behavior into named errors. A trapped component should leave resources closed and the host ready for the next invocation. Peak measurements become regression fixtures for later releases.
Version contracts without silent widening
Force the system to load old components in the new host and new components in the old host. The component host has crossed its limit if a minor update requests broader host authority, so the runtime owner redesigns that boundary.
Version review should focus on authority expansion as much as type compatibility. Adding an optional field may be harmless; adding a network import is a deployment-policy change even when old callers still compile. Diff the WIT world and the host grant manifest together so semantic drift cannot hide inside generated bindings.
Add fields compatibly, use variants for meaningful alternatives, and publish a new major package when callers must change behavior. A host should reject an unexpected world or capability import rather than guessing that a newer component is close enough. The runtime owner treats Wasm Components as unfinished until portable agent tools survives the component host's adverse case. Treat capability growth like a permission migration, complete with an explicit owner.
For adjacent host and schema decisions, open agent tool schema evolution, sandboxing AI-generated code, JSON Schema vs Zod for agent tools, and MCP Apps safe interactive UI. Schema evolution, sandboxing, tool-contract authority, and embedded UI safety remain separate layers. A component can be portable while still unsafe if the host adapter quietly widens its capabilities.
- 1WIT
Describe the verb
- 2Bind
Lower canonically
- 3Grant
Import narrowly
- 4Run
Meter the tool
Publish a cross-runtime tool receipt
A releasable Wasm Components path lets the component host show the runtime owner exactly how WebAssembly component model was verified. That receipt demonstrates portability without pretending every runtime has the same internals.
The convincing portability demo is intentionally boring: one component binary, two independent hosts, the same fixture corpus, and matching canonical results. Include denial, timeout, malformed input, and resource cleanup cases. A successful happy path on two runtimes says less than identical failure semantics under pressure.
A release candidate must pair a portable tool release bundle with evidence that it can install the bundle in a clean second runtime and compare every receipt. The runtime owner withholds the component host decision when those accounts cannot be reconciled.
Ship the component digest, WIT package, generated bindings, declared capabilities, SBOM, fixtures, and measured budgets together. The release is portable only when another supported runtime produces the same normalized results and policy-visible failures.
The fixture rejects an undeclared private host import while accepting a narrow WASI-bound world.
Runnable artifact — component-tool-contract.test.mjs
import assert from "node:assert/strict";
const wit=`package jp:tools; interface summarize { resource document; run: func(input: borrow<document>) -> result<string, string>; } world agent-tool { import wasi:clocks/monotonic-clock@0.2.0; export summarize; }`;
const inspect=source=>({world:/world\s+([\w-]+)/.exec(source)?.[1]??null,resources:[...source.matchAll(/resource\s+([\w-]+)/g)].map(x=>x[1]),imports:[...source.matchAll(/import\s+([^;]+);/g)].map(x=>x[1]),exports:[...source.matchAll(/export\s+([^;]+);/g)].map(x=>x[1])});
const instantiate=(source,grants)=>{const contract=inspect(source),missing=contract.imports.filter(name=>!grants.has(name));return{portable:contract.world==="agent-tool"&&contract.exports.length===1&&contract.resources.length===1&&missing.length===0,missing,contract}};
const allowed=new Set(["wasi:clocks/monotonic-clock@0.2.0"]);assert.equal(instantiate(wit,allowed).portable,true);
const ambient=wit.replace("export summarize;","import host:private/files; export summarize;");assert.deepEqual(instantiate(ambient,allowed).missing,["host:private/files"]);
console.log("PASS: component tool contract is portable");
Run node component-tool-contract.test.mjs. Expected receipt: PASS: component tool contract is portable.
Use WebAssembly Components to package a narrow typed tool, not to disguise a host-specific application behind a portable binary. Reopen the contract when WIT, runtime support, capability policy, or resource semantics change, and keep the cross-runtime fixture as the portability proof.