Skip to content

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 "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).

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 info map for fg_init, the event map for fg_on_event): the host calls fg_alloc, copies the CBOR in, then calls fg_init/fg_on_event(ptr, size). After the handler returns normally the host calls fg_free(ptr, size) on that block, if you export fg_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 calls fg_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 call fg_free(ptr, response_length) after parsing (an arena allocator may instead reset everything in fg_free at 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.

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).

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 line failure: the start handler returned N to the plugin log and startup continues. (The fgpl_tool run dev 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).

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.

{ "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).

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.

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.

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

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.

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 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

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.

  1. Target wasm32-unknown-unknown, or wasm32-wasi (preview 1) with threads, SIMD and atomics turned off. Output must be a core module, not a component.
  2. Export memory (default for most linkers), and fg_alloc, fg_init, fg_on_event (optionally fg_free) with the exact i32 signatures. In clang use __attribute__((export_name("fg_alloc"))); in Zig export fn fg_alloc(size: u32) u32; in AssemblyScript export function fg_alloc(size: i32): i32; in TinyGo //export fg_alloc with -target=wasm-unknown -scheduler=none (so that no scheduler and no _initialize is needed); in C++ wrap the exports in extern "C" and avoid exceptions and threads.
  3. Declare the import as module freshgram, name call, four i32 parameters and an i32 result.
  4. Provide the allocator behind fg_alloc/fg_free. A bump arena that is reset in fg_free is 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 failed fg_alloc drops the event.
  5. 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 is cbor.rs, in C it is part of freshgram.h.
  6. Do not rely on an initialization hook (_initialize, ctors) being called, on WASI sleeping, files that are writable, sockets, or threads.
  7. In manifest.json set "runtime": { "kind": "wasm", "module": "x.wasm", "language": "..." }; "abi" is 1 (the only value accepted).
  8. Return 0 from fg_init/fg_on_event on success.
  9. 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.