Writing a c4mo plugin
This is the tutorial. If you want the authoritative spec, read
plugin-abi.md and plugin-schema.md.
They are intentionally terse and normative; this file is the hand-holding
version, complete with runnable examples and common mistakes.
The guide is written so that you can skim if you're an experienced WASM
developer, or follow start-to-finish if you've never touched Extism before.
Sections marked Expert note are optional context.
Table of contents
- What is a c4mo plugin?
- The two functions you must export
- Hello world: a "solid color" plugin in Go
describe() in detail
generate(input) in detail
- Widget inference: how params become UI
- Randomness and seeds
- Language choices
- Testing locally
- Uploading a plugin
- Limits and sandbox
- Common mistakes
- Further reading
What is a c4mo plugin?
A c4mo plugin is a WebAssembly module that takes some JSON parameters
and returns a PNG image. The module is packaged in the
Extism plugin format, which means it exports
well-known functions and uses a small I/O protocol on top of regular
wasip1.
There are two kinds of plugins in c4mo:
- First-party plugins live in the c4mo repo, are content-addressed
by sha256, and run server-side in a Go host with enforced resource
limits.
- User plugins are uploaded through the web UI and run in the
user's browser. The c4mo server never executes them.
The exact same ABI applies to both. The only difference is where the
generate() call physically runs. If you're writing one, you don't need
to pick a kind up front — you can develop locally, upload as a user
plugin to test the full loop, and promote to first-party later if it
ever becomes one.
The two functions you must export
Every plugin exports exactly two functions:
| Function |
Input |
Output |
When it runs |
describe() |
nothing |
a JSON document |
upload-time validation, and any time the host wants the params schema |
generate(input) |
a JSON envelope (see below) |
raw PNG bytes |
every time someone hits Generate |
describe() tells the host and the UI what the plugin is called,
what parameters it accepts, and whether it uses randomness. The UI
renders a schema-driven form from this output, and the upload validator
enforces that the schema conforms to the
allowed Draft-07 subset.
generate(input) is the actual work. The input is a JSON object
with a params field that matches your declared schema, and an
optional image field (base64-encoded) if your plugin declared
needs_image: true. The output is raw PNG bytes — nothing else. Not
base64, not JSON, not a multipart response. Just the bytes of a PNG
file.
Expert note. Extism plugins communicate via a small runtime
protocol. pdk.Input() reads the input buffer, pdk.Output() writes
the output buffer. You don't interact with stdin/stdout, you don't
allocate file descriptors. The ABI layer is what lets the same plugin
run in the Go host and in a browser without recompilation.
Hello world: a "solid color" plugin in Go
Let's build the simplest plugin possible: it produces a solid-color PNG
of a given width, height, and hex color. No RNG, no image input.
Project layout (you can put this anywhere — it's its own Go module):
solid/
├── go.mod
└── main.go
go.mod:
module example.com/solid
go 1.24
require github.com/extism/go-pdk v1.1.3
main.go:
package main
import (
"bytes"
"encoding/json"
"image"
"image/color"
"image/png"
pdk "github.com/extism/go-pdk"
)
// describe() output — hardcoded as a string constant so it's trivial
// to keep in sync between the schema and the runtime.
const describeJSON = `{
"name": "solid",
"version": "1.0.0",
"needs_image": false,
"uses_randomness": false,
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"width": {"type": "integer", "minimum": 1, "maximum": 2048, "default": 512},
"height": {"type": "integer", "minimum": 1, "maximum": 2048, "default": 512},
"color": {"type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "default": "#4A6741"}
},
"required": ["width", "height", "color"]
}
}`
type params struct {
Width int `json:"width"`
Height int `json:"height"`
Color string `json:"color"`
}
type envelope struct {
Params json.RawMessage `json:"params"`
}
//go:wasmexport describe
func describe() int32 {
pdk.OutputString(describeJSON)
return 0
}
//go:wasmexport generate
func generate() int32 {
var env envelope
if err := json.Unmarshal(pdk.Input(), &env); err != nil {
pdk.SetError(err)
return 1
}
var p params
if err := json.Unmarshal(env.Params, &p); err != nil {
pdk.SetError(err)
return 1
}
c, err := parseHex(p.Color)
if err != nil {
pdk.SetError(err)
return 1
}
img := image.NewRGBA(image.Rect(0, 0, p.Width, p.Height))
for y := range p.Height {
for x := range p.Width {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
pdk.SetError(err)
return 1
}
pdk.Output(buf.Bytes())
return 0
}
func parseHex(s string) (color.RGBA, error) {
if len(s) != 7 || s[0] != '#' {
return color.RGBA{}, errInvalid
}
n := hex2(s[1:3])<<16 | hex2(s[3:5])<<8 | hex2(s[5:7])
return color.RGBA{
R: uint8((n >> 16) & 0xff),
G: uint8((n >> 8) & 0xff),
B: uint8(n & 0xff),
A: 0xff,
}, nil
}
func hex2(s string) int {
var v int
for _, c := range s {
v *= 16
switch {
case c >= '0' && c <= '9':
v += int(c - '0')
case c >= 'a' && c <= 'f':
v += int(c-'a') + 10
case c >= 'A' && c <= 'F':
v += int(c-'A') + 10
}
}
return v
}
var errInvalid = errInvalidHex{}
type errInvalidHex struct{}
func (errInvalidHex) Error() string { return "color must be #RRGGBB" }
func main() {}
Build it:
GOOS=wasip1 GOARCH=wasm go build -o solid.wasm -buildmode=c-shared .
You now have a ~3 MB solid.wasm file. Upload it in the c4mo UI at
/app/plugins/upload and you'll see a form with width, height,
and color fields. Hit Execute and you get a solid-color PNG
back.
That's it. You've written a c4mo plugin.
Expert note. The Go stdlib ships with a complete PNG encoder
(image/png), so you don't need a separate dep for image I/O. You
also don't need tinygo — plain Go ≥1.24 cross-compiles to wasip1
with the //go:wasmexport pragma. The resulting binaries are larger
than tinygo output (~3 MB vs ~100 KB for trivial plugins), but the
tradeoff is that you get the whole stdlib with zero compatibility
caveats. For c4mo, ~3 MB is well under the 10 MB upload cap.
describe() in detail
The describe export takes no input and writes a UTF-8 JSON string
using pdk.OutputString. The JSON must have this shape:
{
"name": "snake-case-id",
"version": "1.2.3",
"needs_image": false,
"uses_randomness": false,
"schema": { /* Draft-07 subset */ }
}
Field requirements (enforced by internal/plugins.DescribeWasm):
name — must match ^[a-z0-9][a-z0-9-]{0,31}$. This is the
machine identifier, and it's what the UI displays. Pick it carefully;
while user plugin uniqueness is scoped per owner, the name is still
what users read.
version — any non-empty string. Display only — identity is the
wasm hash. Two uploads of the same bytes with different versions are
still the same plugin as far as the host is concerned.
needs_image — boolean. If true, the UI shows an image upload
control and the generate() input envelope will include a base64
image field.
uses_randomness — boolean. If true, your schema must
include a seed property (see Randomness and seeds).
schema — a JSON Schema Draft-07 object describing your params.
See plugin-schema.md for the allowed keywords.
The schema is rejected at upload time if:
- It uses keywords outside the subset (e.g.
oneOf, $ref,
pattern that doesn't compile in Go's regex engine).
- Depth exceeds 4 levels or the total JSON size exceeds 16 KB.
uses_randomness is true but there's no seed integer property.
Expert note. describe() runs at upload time with a tight 2-second
timeout and a 64 MiB memory cap. It also runs during browser-side
validation before the upload request even leaves the client. That second
run exists so obvious errors are caught without a round-trip. Keep
describe() cheap — it should be a string constant, not a computation.
generate(input) in detail
generate() reads the input buffer with pdk.Input() and writes the
output with pdk.Output(). The input is always a JSON envelope:
{
"params": { ... matches your declared schema ... },
"image": "<base64>"
}
The image field is only present when your plugin declared
needs_image: true. Ignore it otherwise.
The output must be raw PNG bytes. Any other format (JPEG, raw RGB,
JSON, a string) is rejected. The browser client checks the PNG magic
bytes; the server-side runner trusts the output as-is.
If you need to report an error, call pdk.SetError(err) and return a
non-zero exit code. The host will surface the error message to the user
via the job's error_text and the SSE error event.
//go:wasmexport generate
func generate() int32 {
var env envelope
if err := json.Unmarshal(pdk.Input(), &env); err != nil {
pdk.SetError(err)
return 1
}
// ... your code ...
pdk.Output(pngBytes)
return 0
}
Expert note. The host calls generate() via
plugin.CallWithContext(ctx, "generate", inputJSON). It does not pass
the envelope fields as separate arguments — your plugin must parse the
JSON envelope itself. This keeps the ABI language-agnostic.
Widget inference: how params become UI
c4mo uses @rjsf/core to render a form from your schema, with a
shadcn-styled widget theme. You don't have to configure widgets
explicitly — c4mo infers which widget to use from the shape of each
property:
| Property shape |
Widget in the UI |
string with pattern: "^#[0-9a-fA-F]{6}$" |
color popover (HSL sliders) |
array of the above |
palette editor (draggable swatches + Save / Load / Extract from image) |
integer or number with both minimum and maximum |
slider with a live value readout |
property named seed (integer) |
numeric input + "roll" dice button |
string with enum of ≤ 6 items |
segmented control |
boolean |
ON/OFF stencil toggle |
| anything else |
plain text / number input |
The practical upshot: if you use sensible constraints, your form
renders correctly with zero UI work on your end. A width integer
with minimum: 64, maximum: 2048 automatically becomes a slider. A
hex color pattern automatically becomes a color picker. An array of
hex colors becomes the palette editor, including image extraction.
Randomness and seeds
If your plugin uses any randomness, set uses_randomness: true in
describe() and declare a seed integer parameter:
"seed": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295,
"description": "RNG seed for deterministic output."
}
The seed property may be required or optional. If the user omits it,
the host generates a random uint32 before enqueue and stores it on the
job row, so re-runs from history are bit-for-bit reproducible.
Your plugin must use this seed, not time.Now() or math/rand
without seeding. The host does not enforce this mechanically — it's
an honor-system assertion on first-party plugins and an entirely
user-facing concern for user plugins. If you lie about
uses_randomness, the "re-run" button in history will silently
produce different output, and that's your bug.
A decent xorshift PRNG is six lines:
func rngFromSeed(seed uint64) func(max int) int {
state := seed
if state == 0 {
state = 1 // avoid degenerate case
}
return func(max int) int {
state ^= state << 13
state ^= state >> 7
state ^= state << 17
return int(state>>1) % max
}
}
Language choices
c4mo doesn't care which language compiles your wasm, only that the
result conforms to the ABI. Here's what's known to work:
Go (recommended for stdlib + simplicity)
- Toolchain: plain
go ≥1.24
- PDK:
github.com/extism/go-pdk
- Build:
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .
- Exports:
//go:wasmexport describe / //go:wasmexport generate
- Size: ~3 MB for typical plugins (stdlib is big)
- Why pick this: full stdlib, including
image/png. Zero compatibility surprises.
Rust
- Toolchain:
cargo + wasm32-wasip1 target
- PDK:
extism-pdk crate
- Build:
cargo build --target wasm32-wasip1 --release
- Size: ~200–500 KB with
opt-level = "s" + strip = true
- Why pick this: tiny output binaries, excellent image libraries
(
image, imageproc, palette).
Zig
- Toolchain:
zig build with -target wasm32-wasi
- PDK:
extism-pdk-zig
- Size: very small (~50–200 KB)
- Why pick this: absolute minimum binary size, full control over
memory allocation.
AssemblyScript / TypeScript
- Toolchain:
npm install assemblyscript + extism PDK bindings
- Why pick this: familiar syntax, no Rust/Go/Zig learning curve.
Worse image library ecosystem.
Pick whichever you're comfortable with. For a first plugin, Go is the
fastest path — the example above works with zero tinygo or wasi
gymnastics.
Testing locally
Extism ships a CLI that runs wasm plugins against a JSON input on your
machine. Install it:
brew install extism/extism/extism
# or
curl -sL https://get.extism.org/cli | sh
Then test your plugin:
# Inspect the describe() output
extism call solid.wasm describe
# Run generate with some params
echo '{"params":{"width":256,"height":256,"color":"#e84393"}}' \
| extism call solid.wasm generate --stdin \
> out.png
# Look at it
open out.png
If you want a Go-based test harness that mirrors how the c4mo worker
runs plugins, copy the tiny script at
plugins/voronoi/testrun/main.go (if you have
the c4mo repo checked out) or write your own using
github.com/extism/go-sdk. Either way, the runtime options you want
to match the host exactly are:
EnableWasi: true (required for wasip1 plugins to instantiate —
sandboxed stubs only, no real fs/net)
Memory.MaxPages: 4096 (256 MiB, matches the host cap)
- A 30-second context deadline on the
Call invocation
Uploading a plugin
For user plugins, upload is via the web UI at /app/plugins/upload:
- Click Choose a .wasm file and pick your binary.
- The browser instantiates your plugin, calls
describe(), parses
the output, and validates the schema subset. This is the same
validation the server runs — if it passes here, it will pass there.
- You see a preview card with the name, version,
needs_image,
uses_randomness, and file size.
- Click Upload plugin. The file goes to c4mo's R2 bucket, a row
is inserted in the
plugins table, and you're redirected to your
plugin list.
- Hop to the Playground, pick your plugin from the segmented
switcher, and execute.
Things to know:
- Max upload size is 10 MB. Rust/Zig plugins are comfortably
under that. Go plugins usually are too.
- User plugins run in your browser. The c4mo server never executes
user-uploaded code. This means a buggy plugin can only affect your
own tab.
- Free-tier users can hold 5 uploaded plugins at a time; Pro users
have no cap.
- Upload is content-addressed by sha256. Uploading the same bytes
twice returns the existing row instead of creating a duplicate.
Limits and sandbox
These are enforced by the Go host for first-party plugins. For user
plugins they are advisory — user plugins run in the browser, and
whatever constraints the browser tab has are what your plugin gets.
| Limit |
Value |
| Max memory |
256 MiB |
| Max execution time |
30 seconds wall clock |
| Host functions |
none |
| Filesystem access |
none |
| Network access |
none |
| Max output size |
effectively unbounded, but huge PNGs will OOM the browser on display |
If your plugin runs for more than 30 seconds server-side, the host
kills it and marks the job as failed with an error message. Tune
your algorithm accordingly — server-side first-party plugins should
target sub-second for typical inputs.
Common mistakes
- Returning JSON instead of PNG.
generate() must return raw PNG
bytes. Wrapping in a JSON envelope is wrong.
- Forgetting to unwrap the
params envelope. The input is
{"params": {...}, "image": "..."}, not your params struct directly.
- Using
math/rand without seeding. Breaks reproducibility. Always
seed from the seed param.
- Declaring
uses_randomness: true without a seed property in
the schema. The upload validator rejects this.
- Declaring
needs_image: true and ignoring the image field.
At minimum, handle the "no image" case by returning an error.
- Using keywords outside the Draft-07 subset like
oneOf,
anyOf, $ref. The validator rejects them.
- Using a
pattern regex that compiles in JS but not in Go's RE2.
Avoid lookaheads, lookbehinds, and backreferences — RE2 doesn't
support them.
- Expecting state between calls. Each
generate() call is
independent. Any module-level state you set in one call is either
preserved across calls (server-side, in the cached plugin instance)
or wiped (browser-side, depending on how the host re-instantiates).
Don't rely on either behavior.
- Returning a 0×0 image because you parsed
params incorrectly and
got zero width/height. Check your defaults. The Go image/png
encoder rejects 0×0 with a cryptic error.
Further reading
- plugin-abi.md — authoritative ABI spec. Short
and normative. Read this when you need exact semantics.
- plugin-schema.md — the full list of allowed
JSON Schema keywords, with constraints on each.
plugins/voronoi/ — the reference
first-party plugin. Full working Go source for a non-trivial
generator with seed, palette, and spatial indexing.
- Extism docs — the upstream Extism
runtime and PDK docs. Language-specific guides for every PDK.