Plugin Development Guide#
Audience. This is the developer-facing reference for Lesstruct plugin development. It references source-tree paths (e.g.
internal/plugin/,pkg/sdk/).If you are a Lesstruct user who has installed the binary and wants to write a plugin against your installation, use the user-facing snapshot bundled with the
lesstruct-plugin-developmentskill atskills/lesstruct-plugin-development/references/plugin-development.md. It covers the same contract (hooks, memory protocol, host functions, build instructions) but with no source-tree references.
Lesstruct supports WebAssembly (WASM) plugins that extend functionality through hooks. Plugins are compiled from Go, Rust, C/C++, or any language that targets WASI.
How Plugins Work#
- Compile your plugin to a
.wasmfile targeting the WASI runtime. - Place the
.wasmfile (and an optional.manifestfile) in theplugins/directory. - Lesstruct discovers and loads the plugin at startup.
- The plugin system calls exported hook functions at the appropriate lifecycle points.
See also: Plugin Capabilities — host functions for HTTP, database, and logging. If your plugin needs to call external APIs or query the database, add a capability manifest.
The plugins/ directory is the relative path plugins/ from the Lesstruct working directory (main.go:382). It is not configurable via env or flag.
Plugin Capabilities (Host Functions)#
Plugins that need access to host resources (HTTP, database, logging) declare their requirements in a capability manifest — a TOML file placed alongside the .wasm file.
Manifest File#
Create <plugin-name>.manifest next to <plugin-name>.wasm:
| |
If no .manifest file exists, the plugin runs with zero host functions — hooks only.
Available Host Functions#
| Function | Import Path | Description |
|---|---|---|
lesstruct.http_get | lesstruct.http_get | HTTP GET request (URL allowlist checked) |
lesstruct.http_post | lesstruct.http_post | HTTP POST request (URL allowlist checked) |
lesstruct.db_query | lesstruct.db_query | Execute SELECT query (table access checked) |
lesstruct.db_exec | lesstruct.db_exec | Execute INSERT/UPDATE/DELETE (table access checked) |
lesstruct.log_info | lesstruct.log_info | Log info message to host |
lesstruct.log_error | lesstruct.log_error | Log error message to host |
The full reference lives in Plugin Capabilities.
Host Function Import (Go/TinyGo)#
Unlike hooks (which use //export), host functions use //go:wasmimport:
| |
Note. The Lesstruct SDK (
pkg/sdk/hostfunctions.go) currently ships only the constant names for these host functions. It does not ship the//go:wasmimportdeclarations themselves. Plugin authors write their own bindings, or use thereferences/host-function-imports.go.txtblock bundled with thelesstruct-plugin-developmentskill.
Hook System#
Available Hooks#
The host invokes three hooks today:
| Hook | WASM Export Name | Description |
|---|---|---|
| BeforeSaveContent | hook_before_save | Called before content is saved (create or update) |
| AfterCreateContent | hook_after_create | Called after content is created |
| AfterPublishContent | hook_after_publish | Called after content is published |
Two additional hooks are defined in the host registry but are not currently invoked by any production code path. If your plugin exports only one of these, the plugin will load successfully but the hook will never fire:
| Hook | WASM Export Name | Status |
|---|---|---|
| OnPluginLoaded | hook_on_plugin_loaded | Defined, not invoked |
| BeforeDeleteContent | hook_before_delete | Defined, not invoked |
Do not rely on hook_on_plugin_loaded or hook_before_delete for production
behaviour. Use one of the three invoked hooks instead.
Failure Mode#
When a hook returns an error, the request fails. The content service maps
the error to a 500 response and the content is not saved (for before_save)
or the error is logged (for after_create / after_publish, whose results
are not stored). There is no automatic rollback of prior hooks in a chain.
System Fields#
System fields are special custom field values managed by plugins. They are defined in the post type TOML schema with the system = true flag and stored alongside regular custom fields in the customFields JSON map.
Hook Data Format#
When before_save or after_create hooks execute, the host sends a JSON object with eight fields:
| |
Field notes:
contentIdis0on create, the existing content’s ID on update.userIdis the authenticated user performing the action.statusis one ofdraft,published,archived, or a custom value.postTypeispost,page, or a custom type.customFieldscontains both regular custom fields and system fields.
Reading System Fields#
In a before_save hook, read system field values from customFields in the JSON input:
| |
Writing System Fields#
A plugin can set or modify system field values in customFields. The host validates plugin-set system field values against their schema definition (type, required, options, min/max). Validation runs in the content service after the hook returns — the hook itself does not see validation errors.
If the plugin writes an invalid system field value, the API call fails with a 500 and the content is not saved. The hook does not receive the validation error; the error is mapped to the API caller.
| |
What the Host Reads Back from the Result#
The host only reads back the customFields key from the hook’s result. The
plugin may write other keys (title, tags, etc.) into its result JSON, but
the host ignores them. To mutate the content item, the plugin must write
customFields and return a JSON object containing it.
Important Notes#
- System field values are only preserved when set through
before_savehooks. User-submitted system field values are stripped for security. after_createandafter_publishhooks can read but should not write system fields — their results are not stored (notification-style hooks).- If no plugin handles a
before_savehook, system fields are stripped as usual and content creation proceeds normally.
Memory Protocol#
Hook Functions vs Host Functions#
- Hooks use
//export— the plugin exports them, the host calls them. Data flows via(offset, length) -> resultOffsetat offset65536(64KB). - Host functions use
//go:wasmimport— the host exports them, the plugin calls them. The plugin manages offsets. The host writes results to a fixed offset of4096. See Plugin Capabilities.
Hook Function Signature#
Every hook function must follow this signature:
| |
Data Flow#
- The host writes input data to WASM linear memory at offset
65536(64KB). - The host calls the hook function with
(65536, dataLength). - The plugin reads input from
(offset, offset+length). - The plugin writes the result to WASM memory.
- The plugin returns the offset where the result starts.
- The host reads the result from the returned offset.
If the plugin returns 0 (or empty bytes), the host treats the result as
“no change” and uses the original input.
Variable-Length Results#
If the result length differs from the input length, export __hook_result_len:
| |
The export is optional. If it is missing, the host assumes the result length equals the input length.
Data Format#
All data passed through hooks is JSON-encoded bytes (UTF-8).
Required Exports#
Every plugin .wasm file must export:
memory— Linear memory (auto-exported by most compilers).- One or more
hook_*functions from the Available Hooks table. - Optional:
__hook_result_len(only when result length differs from input length).
Development vs Production Mode#
- Production (default): Plugins are loaded once at startup. To reload, restart the server.
- Development (
DEV_MODE=true): Plugins are hot-reloaded when.wasmfiles change via filesystem watcher.
DEV_MODEis shared with the admin SPA. Toggling the same env var to enable plugin hot-reload also enables admin-panel HMR. If you want production plugin loading but dev admin HMR, setDEV_MODE=false; if you want plugin hot-reload, the admin panel will also be in dev mode.
The watcher is non-recursive: subdirectories of plugins/ are not watched.
It debounces filesystem events by 150 ms and reloads only the affected
.wasm files. On reload, the host unregisters the plugin’s old hooks and
re-runs discovery.
Go / TinyGo Guide#
Prerequisites#
- Go 1.20+
- TinyGo 0.28+
Project Setup#
| |
| |
Note.
hook_on_plugin_loadedis not currently invoked by the host. Usehook_before_saveor one of the other invoked hooks when authoring a plugin that needs to run.
Build#
| |
TinyGo Notes#
- Use
-target=wasi(notwasm32-wasi). - Export functions with
//export function_namedirective (no space after//). unsafepackage is needed for WASM memory operations.- No standard library networking or file I/O in WASI sandbox.
Rust Guide#
Prerequisites#
- Rust 1.70+
wasm32-wasip1target
Install Target#
| |
Project Setup#
| |
| |
Build#
| |
No official Rust examples ship in the Lesstruct repo today. The memory protocol described above applies directly. Validate your
.wasmwithwasm-tools validate plugin.wasmbefore deploying.
C/C++ Guide#
Prerequisites#
- Clang or GCC with WASI support
- WASI SDK (recommended)
Project Setup#
| |
Build#
| |
No official C/C++ examples ship in the Lesstruct repo today. The memory protocol described above applies directly. Validate your
.wasmwithwasm-tools validate plugin.wasmbefore deploying.
Testing Plugins#
- Compile the plugin to
.wasm. - Copy the
.wasmfile to theplugins/directory. - Restart Lesstruct (or use
DEV_MODE=truefor hot-reload). - Observe behaviour in the application logs.
For a manual smoke test per hook, see the references/plugin-checklist.md
file bundled with the lesstruct-plugin-development skill.
If you observe your hooks firing on create but not on delete, or
on_plugin_loaded not firing at all, you have hit a hook that is
defined but not currently invoked. See the Available Hooks
section above.