Plugins
A plugin is a WebAssembly module and a small JSON manifest. You write the module and compile it to wasm. The manifest says who the plugin is and what it is allowed to do. Lookout loads the pair, runs the module in a sandbox, and gives it a fixed set of calls into Lookout.
You write one to get something off your boat and onto the chart. Zig, Go and Rust are currently supported.
| Language | Target | Module size |
|---|---|---|
| Zig | wasm32-freestanding | 75–150 KB |
| Rust | wasm32-wasip1, crate-type = ["cdylib"] | ~120 KB |
| Go 1.24+ | GOOS=wasip1 GOARCH=wasm, -buildmode=c-shared | about 4.6 MB |
Go and Rust programs need WASI to start. The host provides a minimal version:
clocks, random numbers, and stdout and stderr redirected to the plugin log. There
is no file system and no network in it; real capabilities go through the
lookout imports and your manifest.
The wire protocol page lists exactly which WASI calls work.
Only the Zig SDK, plugins/common/lk2.zig, is settled. The Go and Rust
SDKs under sdk/ implement the same API as the Zig one, so write Zig today
unless you are prepared to update your code as they change.
One rule is the same in all three languages: a plugin is single-threaded, and it runs only while the host is calling into it. No background goroutines, no threads, no sleeping. Do the work in the handler and return; to wake up later, ask for a timer.
Start with Build your first plugin: a manifest, one file, and a dashed line drawn on a real chart, with every command on the way. Recipes is what to read next, one page with a dozen things a plugin might do and a complete short listing for each. The plugin SDK is the reference both of them call, and it opens with the entry points in Zig, Go and Rust.
What you can build
There are two jobs. Most plugins do one of them; some do both.
Publish. Turn a data source into values the whole app can use. Your plugin
opens a TCP connection to something on the boat's network, such as a NMEA 0183
multiplexer or a Signal K server, parses what comes back, and writes values
into the vessel store. That store is Lookout's single table of current boat
data, keyed by path: navigation.position,
environment.depth.belowTransducer, and so on. It can write AIS contacts into
the AIS store the same way, keyed by MMSI. Position, heading, course and
speed in the vessel store are what put the boat on the chart and drive follow
mode and course-up. Without a publishing plugin, Lookout has no position.
Draw. Put your own geometry on the chart. Your plugin posts objects (symbols, lines and filled areas, in longitude and latitude) to the overlay, a retained layer the core draws along with the chart. Retained means you post an object once and it stays until you replace it or delete it. Laylines, a guard ring, a route, an anchor-watch circle, a tide arrow, the track you have sailed.
What a plugin does not do is touch the machine. Inside the module there is no socket, no file, no thread and no frame. You ask the host, Lookout's plugin runtime. If your manifest asked for the permission, the host does it.
the boat's network the mariner's settings
│ │
▼ ▼
┌───────────────────────────────────────────────────────┐
│ your plugin: <id>.wasm + <id>.manifest.json │
│ Zig, Go or Rust · single-threaded · WASI floor only │
└──────────────┬────────────────────────────────────────┘
│ twenty-seven imports in, every one of them
│ checked against your manifest
▼
┌───────────────────────────────────────────────────────┐
│ the host sockets, timers, events, one thread for │
│ your plugin and nobody else's │
│ the vessel store · the AIS store │
│ the overlay │
└──────────────┬────────────────────────────────────────┘
▼
the core renders the frame
Where your plugin shows up
Your manifest can declare settings: numbers and toggles. They appear in the app's settings window as ordinary chart settings, filed under the topic they belong to: a collision-alarm limit sits under Alarms beside every other alarm. No pane names your plugin, and the word "plugin" does not appear anywhere in the app.
Write your labels for someone at the helm setting up a boat, not for someone administering software.
What runs today
The plugin layer is a prototype. Five plugins ship with it and are the worked
examples: nmea0183 and signalk publish, and ownship, ais and laylines
draw.
Where you can run a plugin:
| Platform | Plugin host | Overlay rendering |
|---|---|---|
| macOS | Runs. The reference. | Metal, on screen |
| Android | Runs on a device | Vulkan, on screen |
| iOS, iPadOS | Runs on the simulator | Metal, on screen |
| Linux | Compiles and links, not yet run | Vulkan, run offscreen through MoltenVK, not yet on a Linux driver |
| Windows | Compiles and links, not yet run | Direct3D 12, compiles, not yet rendered |
Your module is interpreted on every platform, by WAMR's fast interpreter,
built by scripts/build-wamr.sh. There is no JIT: iOS refuses a process that
asks the operating system for executable pages at run time. Interpreted code
runs well short of native speed, so keep event handlers small.
That is true of your plugin whatever else changes. Lookout's own five plugins
are additionally compiled ahead of time at build time, into a .aot beside the
.wasm, and the host loads that one where it exists; a package you install
carries manifest.json and one .wasm and nothing else, and a package holding
anything more — an .aot included — is refused by name.
This is not a tier you can buy into, and it is not about trust in you. A .aot
is native machine code. Lookout ships the ones it compiled itself, from modules
built in the same tree, with compiler flags chosen to match the runtime — most
of all the software bounds check that keeps a plugin inside its own memory,
which the format does not record and the loader cannot verify. A .aot that
arrived from anywhere else could not be checked for that, so there would be
nothing between it and the rest of the process. Lookout compiles it, or Lookout
interprets it.
On macOS, iOS and Android the host turns on whenever the WAMR archive is
present; on Linux and Windows it needs -Dplugins=true.
Built and usable today:
- The twenty-seven
lookoutimports and the twelve capabilities. - WASI preview1, bounded to a language floor: no filesystem, no sockets, no
environment, no sleeping. It is what lets a Go or Rust module boot at all. The
windlineplugin has been run in the harness in Zig, in Go and in Rust against one replay log. - The vessel store, with an election between competing sources and one 5 s staleness window, and the AIS store, MMSI-keyed and aged.
- The retained overlay: symbols, polylines, polygons, colour tokens, pick payloads for hover and tap, and an own-ship anchor the core moves for you every frame.
- Settings: number, toggle and text fields declared in your manifest, grouped into Lookout's own settings tabs, applied hot without a restart.
- Lists: a group the mariner adds rows to, delivered as a JSON array with a
stable id per row. The
nmea0183andsignalkplugins each use one to hold several TCP connections at once, each with its own socket and its own pause switch. Both file their list under the same settings tab, so one Connections page holds a section per plugin. - Per-row status: a status may carry an
itemsarray, one entry per row, so an app can show "connected, 44 msg/s" beside one connection and "paused" beside another. - One dispatch thread per plugin, a 1 s watchdog, a per-plugin event queue with backpressure, and a fault path that erases everything a dead plugin drew.
lookout-plugin-dev, a harness that runs your plugin against a recorded log and renders the result to a PNG.
The wire protocol is version 1 and unstable
Your module reports the protocol version it speaks. It is 1, and Lookout refuses any module that reports another number. The number confirms that the module and Lookout agree on the protocol today; it does not promise the protocol will stay the same.
Everything on the wire protocol page can change: event kinds, payload formats, capability names, the manifest schema. There is no deprecation period and no compatibility shim, and the version number is not raised for every change, so a plugin built against an older Lookout may still load and then misread what it is handed. What that means for you:
- Pin the Lookout commit you built and tested against, and treat moving to a newer one as work.
- Re-run your plugin in the dev harness after every move. It prints the store, the overlay and the denied calls, which is where a silent protocol change shows up first.
- Expect the plugins in
plugins/to change with the core. They are built in the same tree, and the protocol changes when they need something different. Nothing is holding it still for an out-of-tree plugin yet.
Where to go next
| Page | What it is |
|---|---|
| Build your first plugin | The walkthrough in Zig: a directory, a manifest, a module, the harness, Lookout |
| Recipes | One recipe per thing you might want to do, with the permissions it needs |
| The plugin SDK | The API you write against: the entry points in three languages, inputs, drawing, settings, connections |
| The wire protocol | The reference under the SDK: imports, event kinds, payload formats, the manifest |
| The rules | The rules the host enforces, and the reason behind each one |
| The dev harness | lookout-plugin-dev: the replay log, what it prints, and golden tests |
The code worth reading
The plugins that ship with Lookout are worked examples of everything on these pages. Read them in this order; each one adds something.
plugins/common/lk2.zig the plugin library: inputs, draw, settings, connections
plugins/common/lk.zig the raw shim under it: the externs, a scratch arena, JSON helpers
plugins/laylines/ two close-hauled lines from the true wind, the simplest one
plugins/windline/ one line downwind, and the shortest plugin there is
plugins/ownship/ the boat: symbol, heading line, course vector, track
plugins/nmea0183/ TCP clients, NMEA 0183 and AIVDM parsing, publishing, a settings list
plugins/signalk/ a second publisher: a JSON line protocol, a unit conversion, one seam per transport
plugins/ais/ targets, CPA/TCPA, the collision alarm, aids to navigation
src/plugin/ the host: the imports, the grants, the stores, the watchdog
sdk/rust/, sdk/go/ the Rust and Go SDKs and their windline example;
read them for how a wasip1 module speaks the wire
protocol
nmea0183 and signalk are worth reading as a pair. They do the same job from
two protocols and land in the same stores, so what differs between them is the
part that is yours to write: the wire format, the units, and how a source behaves
when it cannot tell which vessel a value belongs to.
Read src/plugin/ when this documentation and Lookout disagree. The code is the
authority.