Wasm ABI
All communication goes through one import and a few exports. Data in both directions is CBOR (RFC 8949). The module is a plain core WebAssembly module for wasm32 (no component model) that is run by Wasmtime inside the sandboxed plugin host; see Runtime and sandbox for the process model and limits.
There are two ways to shape a module; the host picks one by looking at the exports:
| Mode | Exports | Used by |
|---|---|---|
| Reactor (the normal one) | memory, fg_alloc, fg_init, fg_on_event (+ optional fg_free) |
Rust, C and any language you bind by hand |
| Command | memory, _start (and no fg_on_event) |
language runtimes that own their main loop, for example the bundled CPython; the protocol is framed CBOR over stdin/stdout, see Runtime and sandbox |
The rest of this page describes the reactor mode.
Import
Section titled “Import”(import "freshgram" "call" (func (param i32 i32 i32 i32) (result i32)))In C notation:
int fg_host_call(const void *request, uint32_t length, void **response, uint32_t *response_length);| Parameter | Meaning |
|---|---|
request, length |
pointer and size of the CBOR request in module memory |
response |
address of a u32 where the host writes the pointer to the answer |
response_length |
address of a u32 where the host writes the answer size |
| result | 0 — an answer was written to the two slots (it may still be {"ok": false, ...}); a negative value — the call did not happen and the slots are untouched |
Negative results:
| Value | Meaning |
|---|---|
-1 |
bad arguments: pointers outside memory, negative or over-limit length, or memory/fg_alloc not exported |
-2 |
the request is not valid CBOR, or exceeds the CBOR limits (depth 32, 65536 items, 16384 entries per container, 128 MiB per string) |
-3 |
the answer could not be encoded |
-4 |
fg_alloc returned 0 for the answer (no memory) |
-5 |
the per-event call budget is used up (256 calls, or 60 s spent inside host calls) |
A well-formed request that is refused by policy is not a transport error: it comes back with result 0 and {"ok": false, "e": ...}. The SDKs map a nonzero result to their Transport error.
If fg_alloc traps, or returns a pointer whose block does not fit in memory, the whole call traps and the instance is dead (see Failures).
Memory ownership
Section titled “Memory ownership”Every block that crosses the boundary is allocated by the host calling your fg_alloc(size) and lives in your linear memory:
- Input blocks (the
infomap forfg_init, the event map forfg_on_event): the host callsfg_alloc, copies the CBOR in, then callsfg_init/fg_on_event(ptr, size). After the handler returns normally the host callsfg_free(ptr, size)on that block, if you exportfg_free. If the handler traps, nothing is freed (the instance is dead anyway). Do not keep pointers to an input block after returning. - Answer blocks (from
freshgram.call): the host callsfg_alloc, copies the answer, writes the pointer and size into your two slots. The host never frees them. The module owns the block and is expected to callfg_free(ptr, response_length)after parsing (an arena allocator may instead reset everything infg_freeat the end of the event). - Request blocks: owned by the module. The host only reads them during the call.
fg_alloc is called re-entrantly, from inside freshgram.call while your code is suspended in the import. The allocator must tolerate that. A returned 0 always means failure.
Exports
Section titled “Exports”| Export | Signature | Required | Purpose |
|---|---|---|---|
memory |
linear memory | yes | the host reads and writes blocks through it; it must be the module’s own memory, not an import |
fg_alloc |
(size: i32) -> i32 |
yes | allocate size bytes, return a pointer; 0 means failure |
fg_init |
(ptr: i32, size: i32) -> i32 |
yes | initialization; the block is the CBOR info map |
fg_on_event |
(ptr: i32, size: i32) -> i32 |
yes | an event; the block is the CBOR event map |
fg_free |
(ptr: i32, size: i32) |
no | free a block; if absent the host simply never frees |
Exports are matched by exact signature; a wrong signature fails the load (BadExport).
_start instead of the three fg_* functions switches the module to command mode. At install time a package is refused unless the module exports memory together with either the three fg_* functions or _start.
The host does not call _initialize or __wasm_call_ctors. A Wasm start section does run during instantiation (with the event timeout and 128 MiB-capped memory like everything else). A toolchain that needs _initialize for its runtime must arrange its own initialization (for example lazily on the first fg_init).
Return values
Section titled “Return values”fg_init and fg_on_event return an i32 status. The host passes it up as the status of the finished event; 0 means success.
- A nonzero status is not an error that stops the plugin. It is not a trap and is not counted as a failure.
- For
fg_init, a nonzero status writes the linefailure: the start handler returned Nto the plugin log and startup continues. (Thefgpl_tool rundev runner counts such a line as a plugin failure and exits with code 3.) - For ordinary events the status is ignored.
- For the message hooks (
message.outgoing, incoming transforms) a nonzero status discards the plugin’s edit; if the hook is blocking, the message is refused with “the plugin failed”.
Use negative numbers for your own errors (the Python SDK returns -1 when a handler raises).
Request
Section titled “Request”A CBOR map:
{ "op": "messages.send", "a": { "chat": 123, "text": "hi" } }| Key | Value |
|---|---|
op |
operation name, see the operation list; required |
a |
argument map (may be omitted); if present and not a map the call fails with invalid |
async |
true — do not wait for the result, return a ticket |
Some operations must be awaited with async: true (time.sleep, files.pick, files.save, fs.*), others cannot be (messages.set_outgoing, messages.drop_outgoing, messages.set_incoming, messages.hide_incoming, plugin.unload). Both mistakes return invalid.
Response
Section titled “Response”{ "ok": true, "v": <value> }{ "ok": false, "e": "denied", "m": "error text" }Codes in e: denied, invalid, not_found, quota, limit, unavailable, cancelled, failed. An unknown code should be treated as failed.
If the encoded answer is larger than the message limit (128 MiB), the host replaces it with {"ok": false, "e": "limit", "m": "the answer is too large"}.
For async: true the answer is {"ok": true, "v": {"ticket": N}}. The result arrives later as the event {"type": "call.result", "ticket": N, "ok": ..., "v": ...} (or with e and m on error): the fields of a regular answer are added to the event, and ticket matches the issued ticket. At most 32 async calls may be pending at once (limit beyond that).
Events
Section titled “Events”An event is a CBOR map with a type key ("message.new", "command", "timer" …). Its fields are described in the events reference. Events are delivered one at a time per plugin instance; fg_on_event is never re-entered. An event larger than 128 MiB cannot be delivered and counts as a failure.
Limits
Section titled “Limits”Defaults (the manifest can change the first three in runtime.limits):
| Limit | Value |
|---|---|
Linear memory (memoryMb) |
64 MiB by default, 1–1024 MiB; counts the memory the module declares at instantiation |
Time per event (eventTimeoutMs) |
500 ms by default, 10–60000 ms; counted per fg_init/fg_on_event, excluding time spent inside host calls (that is capped separately at 60 s per event) |
Storage (storageMb) |
10 MiB by default, 0–1024 |
| Host calls per event | 256 (the 257th gets -5); over 512 in one event the host process is killed |
| Calls per second | 200 per plugin instance, then {"e": "limit"} |
| Pending async calls | 32 |
| Message size (request, answer, event) | 128 MiB |
| Module size | 32 MiB for a .wasm; a precompiled module from the cache may be up to 4 times that (128 MiB) |
| Wasm call stack | 512 KiB (overflow traps) |
| Tables | 10000 elements, 100 tables, one instance, one memory |
Instantiation (including a Wasm start section) |
the event timeout |
Command mode: _start until the first message |
10 s |
The total address space of the host process is also capped (1 GiB by default on Linux and Windows), so a memoryMb near 1024 will not be reachable in practice.
Failures
Section titled “Failures”A trap, a timeout, an invalid pointer from fg_alloc, an unexpected exit (proc_exit), or an unparsable message kills the instance: the host reports a failure, the host process is torn down, queued events are dropped, and the plugin is restarted later with a backoff (see Runtime and sandbox). There is no way to recover inside the same instance.
| Situation | What happens |
|---|---|
| Trap (unreachable, out-of-bounds, stack overflow, integer divide by zero …) | instance dead, failure logged with the trap text |
| Timeout | the epoch deadline fires (ticks of 10 ms, so ±10 ms) and traps the module; the instance is dead. Fuel metering is not used |
memory.grow beyond memoryMb |
the grow instruction returns -1; the module is not trapped. A well-behaved allocator then returns 0 from fg_alloc |
fg_alloc returns 0 for an input block |
the event is not delivered, the call is a failure |
fg_alloc returns 0 for an answer block |
freshgram.call returns -4; the module can continue |
proc_exit |
traps; the instance is dead (exit code 0 in command mode is a clean stop) |
| Handler returns a nonzero status | not a failure, see Return values |
Validation
Section titled “Validation”At install time the host scans the module’s import and export sections, then Wasmtime validates the whole module when it is compiled.
Imports. A module may import only freshgram.call and the wasi_snapshot_preview1 functions from the list below. Anything else (another module name, a function that is not listed, an imported memory, table or global) makes the package invalid, and loading fails again at instantiation with ForbiddenImport if something slips through. The module must define and export its own memory.
args_get, args_sizes_get, clock_res_get, clock_time_get, environ_get, environ_sizes_get, fd_advise, fd_allocate, fd_close, fd_datasync, fd_fdstat_get, fd_fdstat_set_flags, fd_fdstat_set_rights, fd_filestat_get, fd_filestat_set_size, fd_filestat_set_times, fd_pread, fd_prestat_dir_name, fd_prestat_get, fd_pwrite, fd_read, fd_readdir, fd_renumber, fd_seek, fd_sync, fd_tell, fd_write, path_create_directory, path_filestat_get, path_filestat_set_times, path_link, path_open, path_readlink, path_remove_directory, path_rename, path_symlink, path_unlink_file, poll_oneoff, proc_exit, proc_raise, random_get, sched_yield, sock_accept, sock_recv, sock_send, sock_shutdown.
Being in the list only means the import links. Many of them are stubs that always fail, see WASI behavior.
Wasm features. The engine is Wasmtime 50 with Cranelift. Enabled: the MVP, bulk memory, multi-value, reference types (and the sign-extension and non-trapping float-to-int proposals that are always on). Disabled, so a module using them fails to compile: threads and shared memory, SIMD and relaxed SIMD, tail calls, multi-memory, memory64, GC and function references, exceptions, wide arithmetic, custom page sizes, the component model. Build with -mno-simd128, -mno-atomics, no +tail-call, and so on. For Rust, use wasm32-unknown-unknown (or wasm32-wasip1 without threads) and avoid -C target-feature=+simd128.
Package. The file named by runtime.module must end with .wasm. Besides, the package may not contain native executables, scripts with a shebang, nested archives, and so on.
Compilation and cache
Section titled “Compilation and cache”The host does not interpret the module: it is compiled to native code. Compilation runs in a separate sandboxed host process (--compile-only, 120 s limit); the result is cached in the app’s tdata/plugins/cache/<sha256 of the .wasm>.cwasm, sealed with an HMAC and tied to the engine version, so an upgrade of the runtime recompiles. If the cached file is rejected or compilation in the helper fails, the host falls back to compiling in the plugin host itself. The first start of a big module can therefore take noticeably longer than later ones.
WASI behavior
Section titled “WASI behavior”WASI is a thin layer over a read-only virtual file system built from the package; the host process itself has no file, network or process access.
| Area | Behavior |
|---|---|
args_get, environ_get |
empty for a reactor module (the command mode gets the arguments and variables the app passes) |
stdin (fd 0) |
always at end of file in reactor mode |
stdout, stderr (fd 1, fd 2) |
captured and written to the plugin log, one log line per \n; lines over 4096 bytes are split; the rest of a line is flushed when the event ends. Each fd_write call may carry at most 128 MiB. Useful for println!/printf debugging |
Other fd_write / fd_pwrite |
EBADF; nothing can be written |
| VFS layout | preopen fd 3 is /plugin, the package files at their package paths (without manifest.json); a Wasm plugin has no /runtime |
| Reading files | path_open (read only), fd_read, fd_pread, fd_seek, fd_tell, fd_readdir, fd_filestat_get, path_filestat_get, path_readlink work. Up to 64 open handles, paths up to 512 bytes, names up to 128 bytes; the tree has at most 16384 files, 4096 directories and 32 levels of nesting |
| Writing | path_open with write rights, path_create_directory, path_unlink_file, path_rename, path_remove_directory, path_symlink, path_link, fd_allocate, fd_filestat_set_* return EROFS. For data, use storage.* |
clock_time_get |
ids 0 (realtime) and 1 (monotonic) only, truncated to 1 ms; other ids give EINVAL. clock_res_get reports 1 ms |
random_get |
the operating system’s secure random generator; up to 128 MiB per call |
poll_oneoff, proc_raise, fd_renumber, fd_fdstat_set_rights |
ENOSYS. Sleeping and polling do not work; use the time.sleep operation with async or the timer.* operations |
sock_* |
ENOTSUP; use the net.* operations |
sched_yield, fd_sync, fd_datasync, fd_advise |
succeed without effect |
proc_exit |
traps; see Failures |
Building in other languages
Section titled “Building in other languages”The host only needs a core Wasm module with the exports above. No toolchain is shipped for languages other than Rust (plugins_sdk/rust) and C (plugins_sdk/c/freshgram.h); the notes below follow from the rules above, they have not been tested with each toolchain.
- Target
wasm32-unknown-unknown, orwasm32-wasi(preview 1) with threads, SIMD and atomics turned off. Output must be a core module, not a component. - Export
memory(default for most linkers), andfg_alloc,fg_init,fg_on_event(optionallyfg_free) with the exacti32signatures. In clang use__attribute__((export_name("fg_alloc"))); in Zigexport fn fg_alloc(size: u32) u32; in AssemblyScriptexport function fg_alloc(size: i32): i32; in TinyGo//export fg_allocwith-target=wasm-unknown -scheduler=none(so that no scheduler and no_initializeis needed); in C++ wrap the exports inextern "C"and avoid exceptions and threads. - Declare the import as module
freshgram, namecall, fouri32parameters and ani32result. - Provide the allocator behind
fg_alloc/fg_free. A bump arena that is reset infg_freeis enough if you do not keep data between events. Make sure the heap can grow (or is big enough) for the largest message you expect: a failedfg_allocdrops the event. - Implement minimal CBOR: map, text, integer,
bool,null, bytes, array (encoder and decoder). The host rejects non-UTF-8 text. In the Rust SDK this iscbor.rs, in C it is part offreshgram.h. - Do not rely on an initialization hook (
_initialize, ctors) being called, on WASI sleeping, files that are writable, sockets, or threads. - In
manifest.jsonset"runtime": { "kind": "wasm", "module": "x.wasm", "language": "..." };"abi"is1(the only value accepted). - Return
0fromfg_init/fg_on_eventon success. - Test locally with
fgpl_tool run(see Runtime and sandbox). Check that the module links before packaging: an import outside the allowed list is the most common reason for a refused package.
