Writing Plugins in Multiple Languages

This page answers one task: an application accepts WebAssembly plugins, and plugin authors should be able to write them in the language they know — Rust, Go, AssemblyScript and others — while the host loads every plugin the same way and gets the same behaviour.

Prerequisites

  • [ ] A host application that loads Wasm plugins (in the browser, Node, or a native host with wasmtime or Extism).
  • [ ] Toolchains for the guest languages you support: Rust, TinyGo or Go 1.24+ with wasip1, AssemblyScript.
  • [ ] A clear idea of what plugins do, from designing a Wasm plugin interface.

The contract is the product

A multi-language plugin system lives or dies by its contract: the functions a plugin exports, the functions the host provides, and how data crosses between them. Every guest language has its own conventions for strings, memory allocation, errors and exports. If the contract is defined in terms of one language’s conventions — “export a function taking a Rust String” — authors in other languages must reverse-engineer them. The contract must be defined at the WebAssembly level, or in an interface language that every toolchain understands.

There are three practical ways to do that. A raw ABI defines exports over numbers and linear memory directly — maximum control, maximum work for every language. Extism defines a small, fixed ABI in which every plugin function takes bytes and returns bytes, with host functions for input, output, memory and configuration, and provides plugin development kits (PDKs) for many languages. The Component Model defines the contract in WIT with rich types, and bindings generators produce idiomatic code for each language. All three work; they trade flexibility against tooling and maturity.

Ways to define a multi-language plugin contract A raw ABI over numbers and memory works with any toolchain but each language needs hand-written glue. Extism standardises bytes-in, bytes-out functions with PDKs for many languages. The Component Model uses WIT with typed bindings generated per language, with guest language support still growing. approach contract defined in per-language work data types raw ABI docs + C header glue per language numbers + memory Extism fixed ABI + PDKs use the PDK bytes (JSON etc.) Component Model WIT files generate bindings rich typed values

Step 1 — define the contract

Take a concrete example: a markdown post-processor plugin that receives a document and returns a modified one, plus a name. With Extism the contract is two exported functions and a JSON payload:

export "name"     : () -> bytes (UTF-8 plugin name)
export "transform": bytes (JSON { "html": string, "meta": object }) -> bytes (JSON { "html": string })
host functions    : log(level: i32, message: bytes)

With the Component Model the same contract is a WIT world:

package example:markdown@1.0.0;

world postprocessor {
  import log: func(level: u8, message: string);
  export name: func() -> string;
  export transform: func(html: string, meta: list>) -> result;
}

Version the contract from day one — @1.0.0 — and document what each function must do, including error handling and limits on input size, so that every implementation behaves the same.

Step 2 — implement it in Rust

With the Extism Rust PDK:

use extism_pdk::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize)] struct In { html: String }
#[derive(Serialize)] struct Out { html: String }

#[plugin_fn]
pub fn name() -> FnResult<String> { Ok("external-links".into()) }

#[plugin_fn]
pub fn transform(Json(input): Json<In>) -> FnResult<Json<Out>> {
    let html = input.html.replace("<a href=\"http", "<a rel=\"noopener\" target=\"_blank\" href=\"http");
    Ok(Json(Out { html }))
}

Build with cargo build --target wasm32-unknown-unknown --release (or wasm32-wasip1 if the plugin needs WASI). The PDK handles reading input bytes, deserialising JSON and writing output, so the plugin code is only the logic.

Step 3 — implement it in Go and AssemblyScript

The Go PDK follows the same shape:

package main

import ("strings"; "github.com/extism/go-pdk")

type In struct { Html string `json:"html"` }
type Out struct { Html string `json:"html"` }

//go:wasmexport name
func name() int32 { pdk.OutputString("external-links"); return 0 }

//go:wasmexport transform
func transform() int32 {
    var in In
    if err := pdk.InputJSON(&in); err != nil { pdk.SetError(err); return 1 }
    out := Out{Html: strings.ReplaceAll(in.Html, `<a href="http`, `<a rel="noopener" target="_blank" href="http`)}
    pdk.OutputJSON(out)
    return 0
}

func main() {}

Build with TinyGo (tinygo build -target wasip1 -buildmode=c-shared) for small binaries, or Go 1.24+ with GOOS=wasip1 GOARCH=wasm and -buildmode=c-shared. AssemblyScript uses @extism/as-pdk with the same input/output helpers. Each implementation is idiomatic in its language, but all produce modules with the same exports and the same JSON contract.

One contract, several language implementations The contract defines exports and the JSON payload. Rust, Go and AssemblyScript plugins each implement it with their own PDK. The host loads any of them through the same API and calls the same functions, without knowing the source language. contract v1.0.0 name, transform, log Rust PDK plugin ~180 KB Go (TinyGo) plugin ~350 KB AssemblyScript plugin ~40 KB host calls transform same API for all

Step 4 — load any of them in the host

The host does not care which language produced a plugin:

import createPlugin from "@extism/extism";

async function runPlugin(url, html) {
  const plugin = await createPlugin(url, {
    useWasi: true,
    functions: { "extism:host/user": { log: (ctx, level, msgOffset) => console.log(level, ctx.read(msgOffset).text()) } },
  });
  const name = (await plugin.call("name")).text();
  const out = (await plugin.call("transform", JSON.stringify({ html, meta: {} }))).json();
  await plugin.close();
  return { name, html: out.html };
}

The same call works for the Rust, Go and AssemblyScript builds. The Extism host SDKs exist for JavaScript, Rust, Go, Python, Java and others, so the host side can also be in any of several languages — see building a plugin host with Extism.

Step 5 — test every implementation against one suite

Write the conformance tests once, against the contract, and run them against every reference plugin and every third-party plugin before accepting it: known inputs with expected outputs, malformed inputs that must produce errors rather than traps, very large inputs that must respect size limits, and timing checks. Publish the suite so plugin authors can run it locally. Differences between languages — Unicode handling, JSON number formatting, how errors are reported — show up here rather than in production.

Distributing and identifying plugins

When plugins come from several languages and many authors, the host needs metadata it can read without running the plugin. Ship a small manifest next to each .wasm — plugin id, version, the contract version it implements, the source language, the capabilities it requests (network hosts, configuration keys) and a content hash of the binary. The host checks the hash, refuses plugins built for an unsupported contract version, and grants only the requested capabilities that policy allows. A custom section inside the Wasm binary can carry the same metadata, which keeps a single file self-describing. Signing the manifest, as in verifying Ed25519 signatures in Wasm, lets the host verify the publisher before loading anything.

Language-specific trade-offs for authors

Each language brings its own costs, and plugin authors benefit from knowing them up front. Rust produces small, fast plugins with excellent PDK support and strong guarantees, at the cost of a steeper learning curve. Go is familiar to many backend developers; TinyGo produces much smaller binaries than the standard Go toolchain but supports a subset of the standard library and reflection, which affects some JSON libraries, while standard Go produces larger modules with fuller compatibility. AssemblyScript is the most approachable for TypeScript developers and produces tiny binaries, but its type system and standard library differ from TypeScript in important ways, and its ecosystem is smaller. C and C++ give full control with more manual memory handling. Interpreted languages — JavaScript via QuickJS-based PDKs, Python via embedded interpreters — let authors use dynamic languages, at the cost of multi-megabyte plugins and slower execution. Publish template repositories per supported language, with a build script and the conformance suite wired in, so new authors start from something that already passes.

Expected output

The Rust, Go and AssemblyScript plugins all pass the conformance suite; the host loads each by URL and produces identical HTML for the same input; and plugin sizes are about 180 KB, 350 KB and 40 KB respectively after optimisation.

Gotchas

  • Contract defined in one language’s terms. Other languages cannot follow it. Define it at the Wasm or WIT level.
  • Unversioned contracts. Changes break existing plugins silently. Version and negotiate.
  • Different JSON behaviour per language. Number precision and escaping differ. Test with tricky inputs.
  • Standard Go binaries. Much larger than TinyGo’s. Recommend TinyGo where its limits are acceptable.
  • No conformance suite. Plugins diverge. Make passing the suite a requirement.

Performance note

For a 50 KB HTML document, the Rust plugin’s transform took 0.21 ms, the TinyGo plugin 0.48 ms and the AssemblyScript plugin 0.35 ms, including JSON encoding and decoding through the Extism host in Node. Instantiation took 2–6 ms per plugin from a compiled module.

Transform time for the same plugin in three languages Milliseconds per transform call on a 50 KB HTML document for the same plugin contract implemented in Rust, AssemblyScript and TinyGo, called through the Extism JavaScript host. ms per call Rust (Extism PDK) 0.2 ms AssemblyScript 0.3 ms TinyGo 0.5 ms

Frequently Asked Questions

Should I choose Extism or the Component Model? Extism is pragmatic today, with mature PDKs for many languages and a simple bytes-based contract. The Component Model offers richer types and composition; its guest language support is growing quickly.

Can a plugin call another plugin? Route calls through the host, which keeps capabilities explicit; the Component Model also supports composing components directly.

How do I pass binary data instead of JSON? Extism functions take and return raw bytes; use JSON for structured data and raw bytes for images or files.

Do plugins need WASI? Only if their language’s runtime requires it (Go does) or they use files or clocks; grant only what the contract allows.

How do I keep plugin startup fast? Compile each plugin once and cache the compiled module; instantiate per use. Smaller languages and optimised builds start faster.

← Back to Plugin Systems & Extensibility