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 five hooks today:
| Hook | WASM Export Name | Description |
|---|---|---|
| BeforeSaveContent | hook_before_save | Called before content is saved (create, update, or admin system-fields update) |
| AfterCreateContent | hook_after_create | Called after content is created |
| AfterPublishContent | hook_after_publish | Called after content is published |
| BeforeDeleteContent | hook_before_delete | Called before content is deleted (admin or owner path) |
| AfterUnpublishContent | hook_after_unpublish | Called after content leaves the published state (published → draft/archived/other, via unpublish or update) |
One additional hook is defined in the host registry but is not currently invoked by any production code path. If your plugin exports only this one, the plugin will load successfully but the hook will never fire:
| Hook | WASM Export Name | Status |
|---|---|---|
| OnPluginLoaded | hook_on_plugin_loaded | Defined, not invoked |
Do not rely on hook_on_plugin_loaded for production
behaviour. Use one of the five 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
and before_delete) or the error is logged (for after_create /
after_publish / after_unpublish, 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, before_delete, 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, delete, or system-fields update.userIdis the authenticated user performing the action on create/update; on delete and system-fields updates it is the content author (existing.UserID), not the acting admin.statusis one ofdraft,published,archived, or a custom value.postTypeispost,page, or a custom type.customFieldscontains both regular custom fields and system fields.
Deleting Content#
before_delete fires after authorization but before the row is removed, with
the full payload — including plugin-managed system field values, which are
unrecoverable once the row is gone. The hook’s result is discarded (a
deletion has nothing to write back); only the error matters: returning an
error aborts the delete, exactly like before_save. Use it to decrement
counters, recompute aggregates, or clean up cross-references.
Unpublishing Content#
after_unpublish fires after the row is persisted whenever content leaves
the published state — via the unpublish endpoint (POST /api/v1/content/{id}/unpublish) or a status change through the Update
endpoint (published → draft, archived, or any other status). It is the mirror
of after_publish (which fires when content enters the published state) and
is notification-style: the result is discarded and an error only logs.
Unpublishing an already-draft item is a no-op and fires nothing.
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. - The admin system-fields endpoints (
PUT /api/admin/content/{id}/system-fieldsand the agent mirrorPUT /api/v1/content/{id}/system-fields) also firebefore_save, with the merged content payload (contentIdand the author’suserId). The hook may adjust system fields only on this path — changes to regular custom fields in the result are ignored, keeping the endpoint’s system-fields-only contract. A hook error aborts the update, and plugin-set system field values are validated exactly as on create/update.
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 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.