c4mo/ docs

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

  1. What is a c4mo plugin?
  2. The two functions you must export
  3. Hello world: a "solid color" plugin in Go
  4. describe() in detail
  5. generate(input) in detail
  6. Widget inference: how params become UI
  7. Randomness and seeds
  8. Language choices
  9. Testing locally
  10. Uploading a plugin
  11. Limits and sandbox
  12. Common mistakes
  13. 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:

  1. Click Choose a .wasm file and pick your binary.
  2. 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.
  3. You see a preview card with the name, version, needs_image, uses_randomness, and file size.
  4. 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.
  5. 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.

Plugin ABI

Every Camouflage plugin — first-party or user-uploaded — is an Extism WASM module that exports two functions. This spec is authoritative; the validator in internal/plugins enforces it and rejects non-conforming modules.

Exports

describe() -> JSON

No input. Returns a UTF-8 JSON document:

{
  "name": "voronoi",
  "version": "1.0.0",
  "needs_image": false,
  "uses_randomness": true,
  "schema": { /* JSON Schema Draft-07 subset — see plugin-schema.md */ }
}

Fields:

  • name — short machine identifier (^[a-z0-9][a-z0-9-]*$, max 32 chars).
  • version — semver string. Display only; identity is the WASM hash.
  • needs_imagetrue if generate requires a source image. The frontend shows an upload control when true.
  • uses_randomnesstrue if the plugin uses any RNG. When true, schema must declare a seed integer property (see §Seed).
  • schema — parameters the plugin accepts, in the Draft-07 subset documented in plugin-schema.md.

Validator errors surface as RFC 9457 Problem Details with type=plugin-invalid-describe on upload.

generate(input) -> PNG bytes

Input is a JSON document with this shape:

{
  "params": { /* validated against schema */ },
  "image": "<base64>"                 // present iff needs_image = true
}

Output is the raw bytes of a PNG image. Any other output format is rejected.

The plugin should return an Extism error (non-zero exit + error message) for any failure; the host will surface the error via the job record and the SSE error event.

Seed

If uses_randomness = true, the plugin's schema must include:

"seed": {
  "type": "integer",
  "minimum": 0,
  "maximum": 4294967295,
  "description": "RNG seed for deterministic output."
}

The seed property may be required or optional. When omitted by the caller, the host generates a random seed before enqueue and stores it on the job row so re-runs are reproducible.

Trust model for uses_randomness

The host does not and cannot mechanically verify that a plugin's uses_randomness flag matches its actual behavior. The flag is an assertion by the plugin author, not a runtime-enforced property.

  • First-party plugins: reviewed in code before publish. Dishonest flags are a code review failure, not a runtime concern.
  • User-uploaded plugins: run only in the user's own browser, and only affect their own results. A user lying to themselves about seed support is their problem; the host has no stake in it.

If a published first-party plugin is later found to produce non-reproducible results despite declaring uses_randomness=true, unpublishing it is the correction. There is no automated detector.

Resource limits (server-side execution only)

The Go host enforces these limits via Extism's runtime config:

Limit Value
Max memory 256 MiB
Max execution time 30 s wall clock
Host functions exposed none
Filesystem access none
Network access none

Client-side (browser) execution is not bounded by the host — users run their own plugins in their own browser, at their own risk.

Identity and versioning

Plugin identity is sha256(wasm_bytes). Two uploads of identical bytes map to the same logical plugin. The version string in describe() is display metadata and does not affect identity or routing.

Jobs pin plugin_hash at enqueue. The worker fetches the plugin from R2 by hash. Publishing a new version is safe for in-flight jobs — they continue running against the old hash.

Host function reference (v1: none)

The MVP exposes zero host functions. Plugins cannot log, cannot report progress beyond what the Extism runtime emits, and cannot call back into the host. This is intentional: it keeps the trust boundary tight and the ABI surface small.

A future version may add an opt-in progress(percent int, stage string) host function. Plugins must not assume it exists in v1.

Example: a minimal plugin

The snippet below is pseudocode, not a working implementation. Real plugins are written against an Extism PDK (Go, Rust, Zig, AssemblyScript, C, etc.) — see the Extism docs for the PDK of your language.

# pseudocode only
def describe():
    return {
        "name": "solid",
        "version": "0.1.0",
        "needs_image": False,
        "uses_randomness": False,
        "schema": {
            "type": "object",
            "properties": {
                "color": {"type": "string", "pattern": "^#[0-9a-fA-F]{6}$"},
                "width":  {"type": "integer", "minimum": 1, "maximum": 4096},
                "height": {"type": "integer", "minimum": 1, "maximum": 4096}
            },
            "required": ["color", "width", "height"]
        }
    }

def generate(input):
    p = input["params"]
    img = new_image(p["width"], p["height"], fill=p["color"])
    return png_encode(img)

Plugin schema subset (JSON Schema Draft-07)

Plugin describe() output must include a schema field describing the plugin's params. Only the keywords listed here are allowed. Anything else is rejected at plugin upload time with an RFC 9457 Problem Details response of type plugin-invalid-schema.

This subset exists so that:

  1. The @rjsf/core form renderer can produce a sensible form automatically.
  2. Params can be validated identically on the browser, on the Next.js BFF, and in the Go host without schema-engine-specific surprises.
  3. The surface is small enough that a custom Go validator is feasible.

Top-level shape

The schema root must be an object schema:

{ "type": "object", "properties": { ... }, "required": [ ... ] }

Allowed keywords

Structural

  • type — one of "object", "string", "integer", "number", "boolean", "array"
  • properties — object whose values are themselves schemas in this subset
  • required — array of strings
  • additionalPropertiesfalse only. The validator rejects true or sub-schema values. Plugins must enumerate all params.
  • items — single schema (not an array). Arrays of arrays are not allowed.

Strings

  • minLength, maxLength — non-negative integers
  • pattern — ECMA-262 regex. Must compile on both Go (regexp/syntax) and JS. The validator pre-compiles and rejects mismatched dialects.
  • enum — array of strings (homogeneous types only)

Numbers / integers

  • minimum, maximum
  • multipleOf
  • enum — array of numbers

Arrays

  • minItems, maxItems

Documentation / UX

  • title — used as the form field label
  • description — used as help text
  • default — used as the form default value; must match the schema
  • examples — array; purely informational

Not allowed

These common Draft-07 keywords are explicitly rejected:

  • $ref, $defs, definitions — no composition or recursion
  • oneOf, anyOf, allOf, not — no combinators
  • if, then, else — no conditionals
  • patternProperties, dependencies, propertyNames
  • format — too implementation-defined; use pattern instead
  • const
  • additionalItems, tuple-typed items
  • Anything not listed under "Allowed keywords" above

Reserved property names

  • seed — reserved for RNG seed (see plugin-abi.md §Seed). Plugins may declare it; no other semantics may be assigned to this name.

Size limits

Limit Value
Total schema JSON 16 KiB
Max properties on root object 32
Max nesting depth 4
Max enum length 64 entries
Max pattern length 512 characters (ReDoS mitigation)

Validation flow

  1. On plugin upload (user or admin): the Go validator parses the schema, checks it against this subset, and rejects non-conforming schemas.
  2. On job enqueue: the Go host validates the submitted params against the stored schema.
  3. In the browser: the same validation runs client-side via ajv + ajv-formats restricted to the allowed keywords, so the user sees errors before submit.

The Go and JS validators must produce the same accept/reject decision for every schema in the subset. Discrepancies are bugs — add a fixture to the validator test suite.

Example

{
  "type": "object",
  "additionalProperties": false,
  "required": ["palette", "scale"],
  "properties": {
    "palette": {
      "type": "string",
      "title": "Palette",
      "enum": ["desert", "forest", "arctic", "urban"],
      "default": "desert"
    },
    "scale": {
      "type": "integer",
      "title": "Scale",
      "minimum": 1,
      "maximum": 16,
      "default": 4
    },
    "dither": {
      "type": "boolean",
      "title": "Enable dither",
      "default": true
    },
    "seed": {
      "type": "integer",
      "minimum": 0,
      "maximum": 4294967295,
      "description": "RNG seed for deterministic output."
    }
  }
}