---
title: Native plugins (host capabilities)
summary: Read when the SDK lacks a capability (Bluetooth, NFC, hardware, an OS API, a vendor library) — the Service channel and a typed facade, a desktop .dll / .so in C or Rust with no host rebuild, shipping it, and the iOS / Android halves of a plugin of the project (a contract + generated halves).
area: plugins
globals: []
sources: [src/plugins/service.ts, src/runtime/service.ts, src/runtime/channel.ts, src/ui/NativeView.ts, src/plugin.ts]
---

# Native plugins (host capabilities)

Add a native capability the SDK does not have — Bluetooth, NFC, a serial port, an OS API, a
vendor's SDK — as a plugin of your project: a native half per platform behind the plugin channel,
called from app TypeScript through [`Service`](/docs/api/plugins/service.md) (no pixels) or
[`NativeView`](/docs/api/ui/native-view.md) (it draws its own). Check the first-party plugins (camera, map,
geolocation, push …) first.

- add Bluetooth, NFC or another OS feature the SDK does not have
- call a native C, C++ or Rust library from app code
- wrap a vendor's native SDK as a typed service
- keep the app working where the plugin is missing
- ship a desktop plugin with the app
- write the iOS and Android halves of a plugin of the project

## When

- The app needs an OS or hardware feature no SDK global covers, and shelling out to a process or
  talking over localhost is not acceptable (latency, packaging, permissions).
- You already have a C / C++ / Rust library and want it in-process next to the engine.
- A vendor SDK exists only as a native library (payment terminal, scanner, camera stack, DRM).

If the capability is only about *drawing*, check [`Canvas`](/docs/api/core/canvas.md) and the 3D engine
first — a plugin that renders needs the compiled-in path (see the delivery table below), which is
much more work than a headless service.

## The shape

Two channels, both host-optional:

| Channel | For | App side | Host side |
|---|---|---|---|
| **Service** | no pixels: sensors, servers, devices, background work | `Service(name)` — `call()` / `on()` / `close()` | `registerService(name, factory)` |
| **NativeView** | it draws its own pixels: camera preview, map, video | `NativeView(name)` or a typed wrapper | `registerView(name, factory)` |

One protocol behind both: a **method call** is `(method, args[]) → result | error`, an **event** is
`(name, payload?)`. Arguments, results and payloads are plain data — what JSON can say, plus
`Uint8Array` for bytes; the `params` a session or a view is created with are JSON. Sessions are
host-allocated and cheap; the app opens one lazily on the first `call()` and `close()`s it when
done.

How the native half is delivered, per platform:

| Platform | Delivery | Rebuild needed |
|---|---|---|
| **Desktop** (Windows / Linux) | prebuilt library in `plugins/` next to the app or the exe, loaded through the C ABI `hosts/desktop/plugin-sdk/lecodes_plugin.h` — services and sync methods, JSON text at the ABI | none — drop the file in |
| Desktop, native **views** | sources compiled into a custom host (`CREATOR_DESKTOP_PLUGIN_DIR` → `lecodesPluginRegister()`); fullscreen destinations only | the host |
| iOS / Android | the plugin's generated halves, compiled into the app's own shell by `lecodes app sync` (Step 7); the le.codes viewer app carries the first-party ones only | the app |
| The web (Web Lite, `lecodes web`) and the web player | the first-party plugins' web halves only — a project's plugin is unsupported there | — the app must degrade |
| Headless (`lecodes render`) | nothing native exists | — the app must degrade |

On **desktop** the native half is a plain `.dll` / `.so` loaded at startup — no host rebuild, so
this is also the fastest way to prototype a capability. Reference plugin:
`hosts/desktop/plugin-sdk/example/echo.c`. This recipe's desktop plugin was verified 2026-09-30 on
Windows, built in C and in Rust, on the windowless host (`render --sim`, `lecodes test --sim`), from
the project's `plugins/` folder and from a manifest's `desktop.library`. On **iOS and Android** a
plugin is a `contract.d.ts` plus halves generated by `lecodes plugin gen` — how the first-party
plugins (camera, map, …) are built (Step 7).

**The app never branches on platform.** It branches on `Service.isSupported(name)`, which is exactly
"did this host register a factory under that name". That is what keeps `lecodes render` (headless,
no plugins) and Web Lite working while the feature is desktop-only.

## Step 1 — design the channel before writing native code

Fix three things first, because both halves and the tests depend on them:

- **The name.** Vendor-prefixed: `acme.dial`, `app.battery`. Bare names (`geolocation`, `push`)
  belong to the SDK.
- **The methods.** Plain data in, plain data out, one round trip each: `open` is not a method (the
  session is the open), `read`, `write`, `start`, `stop` are.
- **The events.** What the native side pushes without being asked: `reading`, `connected`,
  `error`. A payload is one value.

Write it down as the TypeScript facade you wish you had — that file is the contract:

```ts
// src/native/dial.ts — the project's typed wrapper over one service
export interface DialReading { angle: number; button: boolean }

export const Dial = {
  get available(): boolean { return Service.isSupported('acme.dial') },

  open(): DialSession | null {
    if (!Service.isSupported('acme.dial')) return null
    return new DialSession()
  },
}

export class DialSession {
  private readonly svc = Service('acme.dial', { rate: 60 })

  onReading(cb: (r: DialReading) => void): this { this.svc.on('reading', cb); return this }
  async start(): Promise<void> { await this.svc.call('start') }
  async read(): Promise<DialReading> { return await this.svc.call('read') }
  close(): void { this.svc.close() }
}
```

## Step 2 — the app, with the plugin absent

Every screen that uses the capability must render without it. Gate once, keep a fallback path,
never call a service you didn't check.

```ts
// main.ts
import { Dial } from './src/native/dial'

const dial = Dial.open()                  // null on web / headless / a desktop without the plugin
const label = UIText(dial ? 'Dial: —' : 'Dial not available on this device')
UIScreen(label).open()

if (dial) {
  dial.onReading(r => { label.text = `Dial: ${r.angle.toFixed(0)}°` })
  dial.start().catch(e => { label.text = `Dial failed: ${e.message}` })
}
```

::: note Note
`Service(...)` itself never throws — the session opens on the first `call()`, and that
is what rejects when the host has no such service. `isSupported` is the cheap synchronous check;
use it for what the UI shows, and still catch around calls. Register listeners before `start()`:
an event can arrive before the promise's `then` runs.
:::

## Step 3 — the desktop plugin (C)

One header, one exported symbol. The plugin never links against the host, never sees the JS
engine, and may be built with any compiler or CRT: only borrowed strings and function pointers
cross the boundary, and the receiver copies whatever it keeps.

```c
#include <stdlib.h>
#include <string.h>
#include "lecodes_plugin.h"   /* hosts/desktop/plugin-sdk/lecodes_plugin.h */

static const LecodesHostApi* host;

typedef struct { int32_t id; /* your state, threads, handles */ } Session;

static void* dial_open(void* ctx, int32_t session_id, const char* params_json) {
    Session* s = calloc(1, sizeof(Session));
    s->id = session_id;
    return s;                      /* NULL refuses the session */
}

static void dial_call(void* ctx, void* session, int32_t session_id, uint64_t call_id,
                      const char* method, const char* args_json) {
    if (strcmp(method, "read") == 0) {
        host->service_resolve(session_id, call_id, "{\"angle\":12.5,\"button\":false}");
    } else if (strcmp(method, "start") == 0) {
        /* spawn a worker; it emits from its own thread */
        host->service_resolve(session_id, call_id, NULL);          /* NULL = undefined */
    } else {
        host->service_reject(session_id, call_id, "unknown method");
    }
}

static void dial_close(void* ctx, void* session, int32_t session_id) {
    /* stop AND JOIN every thread this session started, then free */
    free(session);
}

static const LecodesServiceVTable VT = { dial_open, dial_call, dial_close };

LECODES_PLUGIN_EXPORT int32_t lecodes_plugin_register(const LecodesHostApi* api) {
    if (api->abi_version != LECODES_PLUGIN_ABI_VERSION) return 1;   /* refuse an unknown host */
    host = api;
    return host->register_service("acme.dial", &VT, NULL);
}
```

The rules that make this safe:

- **Settle every call exactly once**, with `service_resolve` (a JSON value, or `NULL` for
  `undefined`) or `service_reject` (a message — the app's `Error` carries it). Whatever is still
  pending when the session closes is rejected by the host, so a forgotten settle is late, not a
  leak — but the app's promise hangs until then.
- **`service_resolve` / `service_reject` / `service_emit` are callable from any thread.** They
  enqueue onto the host's dispatch queue. Everything else — `open`, `call`, `close`, methods,
  `register_*` — runs on the JS thread.
- **`open` must be cheap.** No I/O, no port binding, no permission prompt: do that in the first
  `call()` that needs it. A world swap (`lecodes dev` reload) opens and closes sessions freely.
- **`close` must stop and join.** After it returns, the memory a late emit would touch is gone.
- Strings the host passes are valid **for the duration of the call only** — copy what you keep.
- `args_json` is a JSON array of the call's arguments. JSON has no bytes: a `Uint8Array` arrives
  as an array of numbers, a number that is not finite as `null`.

Emitting an event from a worker thread is the whole reason for the session id:

```c
host->service_emit(s->id, "reading", "{\"angle\":31.0,\"button\":true}");
```

Build it like any shared library — `hosts/desktop/plugin-sdk/example/CMakeLists.txt` is a
four-line template (or `cl /LD acme_dial.c /I <plugin-sdk>` from a Visual Studio prompt) — and
drop the result into `<project>/plugins/`.

## Step 4 — the same plugin in Rust

The ABI is plain C, so a `cdylib` works with no wrapper on the host side. `bindgen` over
`lecodes_plugin.h` gives the structs; the hand-written version is short enough to keep in the repo.

```rust
// Cargo.toml: [lib] crate-type = ["cdylib"]
use std::ffi::{c_char, c_void, CStr, CString};
use std::panic::catch_unwind;

static mut HOST: *const LecodesHostApi = std::ptr::null();
static VT: LecodesServiceVTable = LecodesServiceVTable { open, call, close };

struct Session { id: i32, worker: Option<std::thread::JoinHandle<()>>, stop: std::sync::Arc<std::sync::atomic::AtomicBool> }

unsafe extern "C" fn open(_ctx: *mut c_void, id: i32, _params: *const c_char) -> *mut c_void {
    Box::into_raw(Box::new(Session { id, worker: None, stop: Default::default() })) as *mut c_void
}

unsafe extern "C" fn call(_ctx: *mut c_void, session: *mut c_void, id: i32, call_id: u64,
                          method: *const c_char, args: *const c_char) {
    let _ = catch_unwind(|| {                       // a panic across FFI is undefined behaviour
        let _s = &mut *(session as *mut Session);   // this session's state
        let method = CStr::from_ptr(method).to_string_lossy().into_owned();
        let _args: serde_json::Value = serde_json::from_str(&CStr::from_ptr(args).to_string_lossy()).unwrap_or_default();
        match method.as_str() {
            "read" => resolve(id, call_id, &serde_json::json!({ "angle": 12.5, "button": false })),
            _ => reject(id, call_id, "unknown method"),
        }
    }).map_err(|_| reject(id, call_id, "plugin panicked"));
}

unsafe extern "C" fn close(_ctx: *mut c_void, session: *mut c_void, _id: i32) {
    let mut s = Box::from_raw(session as *mut Session);
    s.stop.store(true, std::sync::atomic::Ordering::Relaxed);
    if let Some(w) = s.worker.take() { let _ = w.join(); }        // join before returning
}

fn resolve(id: i32, call_id: u64, v: &serde_json::Value) {
    let json = CString::new(v.to_string()).unwrap();
    unsafe { ((*HOST).service_resolve)(id, call_id, json.as_ptr()) }
}

fn reject(id: i32, call_id: u64, message: &str) {
    let message = CString::new(message).unwrap();
    unsafe { ((*HOST).service_reject)(id, call_id, message.as_ptr()) }
}

#[no_mangle]
pub unsafe extern "C" fn lecodes_plugin_register(api: *const LecodesHostApi) -> i32 {
    if (*api).abi_version != 1 { return 1 }
    HOST = api;
    let name = CString::new("acme.dial").unwrap();
    ((*api).register_service)(name.as_ptr(), &VT, std::ptr::null_mut())
}
```

Two Rust-specific rules on top of the C ones: **never let a panic cross the boundary**
(`catch_unwind` in every `extern "C"` body, reject instead — the app gets `"plugin panicked"`), and
keep the `CString`s alive until the host call returns — `CString::new(x).unwrap().as_ptr()` as a
temporary is a dangling pointer.

## Step 5 — a synchronous value, without a session

When the app needs one number every frame and a promise is too much ceremony, register a **method**
at a dotted path of your own instead. It runs on the JS thread, synchronously.

```c
static void dial_angle(void* ctx, LecodesMethodCall* call, const char* args_json) {
    host->method_return(call, "12.5");
}
host->register_method("acme.dialAngle", dial_angle, NULL);   /* inside lecodes_plugin_register */
```

```ts
declare const acme: { dialAngle(): string }          // the global the host installed
const angle = JSON.parse(acme.dialAngle())           // the result arrives as a STRING
```

::: note Note
this is the escape hatch, not the default. It bypasses the typed wrapper, the result is
a string the app has to parse, its arguments are scalars only (an object or an array arrives as
`null`), and a slow method blocks the frame. Anything with state, events or I/O belongs in a
service.
:::

## Step 6 — ship it (desktop)

**Dev loop.** A `plugins/` folder in the project root is picked up automatically:

```
my-app/
  app.json
  plugins/acme_dial.dll      # or .so
  main.ts
```

`lecodes desktop run`, `lecodes render --desktop` / `--sim` and `lecodes test --desktop` / `--sim`
forward that folder to the host, which also scans `plugins/` next to the app.js and next to its own
exe. `CREATOR_PLUGIN_DIRS` (a PATH-style list) adds more.

**Distribution.** `lecodes app build desktop` copies every library into `plugins/` inside the
portable folder, so the built app carries its plugins. For a plugin that lives in its own repo,
declare it in `app.json` and let its manifest point at the prebuilt library per target:

```jsonc
// app.json — the key is the manifest's id
{ "plugins": { "acme-dial": "github:acme/lecodes-dial@v1.2.0" } }
```

```jsonc
// lecodes-plugin.json, in the plugin repo
{
  "id": "acme-dial",
  "version": "1.2.0",
  "desktop": { "library": { "win32-x64": "dist/acme_dial.dll", "linux-x64": "dist/libacme_dial.so" } },
  "ios": null,
  "android": null
}
```

Keys are `${process.platform}-${process.arch}`. A missing key means "no build for this machine" and
the plugin is skipped; a declared file that isn't there fails the build (a run warns and goes on
without it). The spec's `@tag` pins the version — without one the newest `v*` tag is taken — and
`LECODES_PLUGINS_DIR` points the resolver at a local checkout while you develop the plugin itself.

The `desktop` branch belongs to this desktop path only: the 2.x manifest of a mobile / web plugin
(Step 7) has no desktop branch, and `lecodes plugin gen` refuses one ("desktop plugins are another
system").

## Step 7 — iOS, Android and the web: a contract

A 2.x plugin is a folder whose `contract.d.ts` describes the wire — the methods, the params, the
events of one channel — and `lecodes plugin gen` writes every half's glue from it:

```ts
// acme-dial/contract.d.ts
import type { Service, f64 } from "lecodes-sdk/plugin"

export interface DialReading { angle: f64; button: boolean }
export interface DialParams { rate?: f64 }
export interface DialEvents { reading: DialReading }

export interface Dial extends Service<"acme.dial", DialParams, DialEvents> {
  /** @rejects unavailable */
  read(): Promise<DialReading>
  start(): Promise<void>
}
```

A call that needs an OS permission says so on the method — `/** @permission microphone */` — and
the generated native glue asks the host before the call (`denied` when refused): the half holds no
dialog code, and `lecodes plugin gen` refuses a manifest without the usage string the dialog shows.

```
acme-dial/
  contract.d.ts
  lecodes-plugin.json        id, name, version, sdk ("^2.0.0"); ios / android / web: { register, … } or null
  sdk/acme-dial.gen.ts       the app's wrapper: DialBase, and `Dial` itself when there is no sdk/acme-dial.ts
  ios/Dial.gen.swift         the types, DialCode, DialEvents, protocol DialPlugin, DialChannel.register
  android/src/main/java/com/acme/dial/Dial.gen.kt     the same in Kotlin
  web/acme-dial.gen.ts       the same for the web half
```

The folder's name is the plugin's id. A platform's file is generated where its folder exists;
`lecodes plugin gen --check` fails on a stale one. Each half implements the generated `DialPlugin`
(a method gets its arguments read off the wire and a reply to settle exactly once) and registers
through the generated `DialChannel.register` (`registerDial` on the web), called from the type the
manifest's `register` names. The first-party plugins (github.com/letary/lecodes-plugins — camera,
qr-scanner, map, geolocation, push) are the reference for every half.

::: note Note
a 2.x plugin runs in the app's own iOS / Android shell — `lecodes app sync` vendors it
(a folder of the project, or a `github:` spec — Traps below). The le.codes viewer app and the web
hosts carry the first-party halves only: there `isSupported` answers false (`lecodes web build`
warns "has no web half"). The generated `sdk/<id>.gen.ts` imports `lecodes-sdk/plugin`, which
the app compile resolves to the SDK's own plugin module.
:::

## Verify

Run the app natively with logs and read the loader's lines — the windowless host needs no window
(`--sim` takes a `--script`), `--desktop` renders pixels too:

```
bun <cli>/src/index.ts render --sim --script tests/dial.flow.json --logs
```

```
[svc] service registered: "acme.dial"
[plugin:acme_dial.dll] loaded (C:\…\my-app\plugins\acme_dial.dll)
[server] native plugins: 1 loaded           ([desktop] … on the desktop host)
```

What to check, in this order:

1. **`Service.isSupported('acme.dial')` is true** — if not, the library never registered: look for
   a `[plugin:…] skipped` (load failure, usually a missing dependency DLL) or
   `not a lecodes plugin` (the export is missing or misspelled) line.
2. **A call round-trips**, a reject arrives as a rejected promise, and an unknown method rejects.
3. **Events reach the app** from the plugin's own thread.
4. **Close and reopen** the session — the second `call()` must open a fresh one (`open` runs again).
5. **The app still runs with the folder empty** (that is what web and headless see).

Keep it as a flow test so it doesn't rot: log one line per step from the app and assert it with
`expect.log` in `tests/*.flow.json`, then `lecodes test --sim` (or `--desktop`).

## Traps

- **A stale host has no loader.** The plugin loader lives in the host binary; a globally installed
  `lecodes` or the cached `~/.lecodes/renderer` host may predate it and will ignore `plugins/`
  silently. Point `LECODES_DESKTOP_EXE` (and `LECODES_SERVER_EXE` for `--sim`, in the project's
  `.env`) at the host you actually built.
- **An unsettled call hangs the app's promise forever.** Every path through `call` ends in exactly
  one `service_resolve` or `service_reject`.
- **`close` that doesn't join is a crash.** The thread that emits after teardown touches freed
  memory. Stop, join, then return.
- **Don't do work in `open`.** Ports, prompts and I/O in the factory make world swaps and
  feature-detects expensive, and `isSupported` no longer means what it says.
- **Panics and exceptions must not cross the ABI.** Rust: `catch_unwind`. C++: `try/catch` at every
  `extern "C"` boundary.
- **A sync method returns a string to JS.** `JSON.parse` it, and keep it fast — it runs inside the
  frame.
- **Names collide once.** The first registration of a name wins and the second is refused with a
  log line; prefix with your vendor.
- **An ABI version mismatch skips the plugin**, it does not crash — check the log before assuming
  your code is wrong.
- **The windowless host runs frames unthrottled** — thousands of frames pass in well under a
  second — so a worker that paces itself by the wall clock may emit nothing before a short `--sim`
  run ends. Give the flow enough frames, or check timing on `--desktop`.
- **The app.json key must be the manifest's `id`.** `lecodes app plugin add github:acme/lecodes-dial`
  names the entry after the repo (`lecodes-dial`); an id that differs skips the plugin ("manifest
  declares id").
- **A plugin of the project itself needs no repo.** Keep it in a folder of the project
  (`plugins/acme-dial/` with its `lecodes-plugin.json`, `contract.d.ts`, `ios/`, `android/`), run
  `lecodes plugin gen plugins/acme-dial`, write the bodies, and point app.json at the folder:
  `lecodes app plugin add ./plugins/acme-dial` (`{ "acme-dial": "./plugins/acme-dial" }`). `lecodes
  app sync` vendors it into the shell like any other plugin — the folder's name is the id, the lock
  says `local`, `lecodes app update` leaves it alone. Publish it only to share it with another
  project. The app imports its wrapper by path: `import { AcmeDial } from
  "./plugins/acme-dial/sdk/acme-dial"` (the generated `sdk/<id>.gen.ts` when there is no
  hand-written `sdk/<id>.ts`).
- **A desktop-only plugin in app.json stops `lecodes app sync` for iOS / Android** — its manifest
  says `"ios": null`, which the shell's sync refuses.
- **macOS** loads `.dylib` plugins from the same folders, but `lecodes app build desktop` there
  stages none into the `.app`.
- **Never make a screen require a plugin.** Headless renders, flow tests and Web Lite have none,
  and that is where most of your feedback loop lives.

## See also

- [`Service`](/docs/api/plugins/service.md) — the app-side API in full.
- [`NativeView`](/docs/api/ui/native-view.md) — the visual sibling, and the typed wrappers built on it
  ([CameraView](/docs/api/plugins/camera-view.md), [QRScanner](/docs/api/plugins/qrscanner.md),
  [MapView](/docs/api/plugins/map-view.md) — a view plugin that links a third-party package: manifest
  `ios.packages` / `android.dependencies` / `web.dependencies`).
- [Geolocation](/docs/api/plugins/geolocation.md) — a first-party service, i.e. what a finished typed
  wrapper looks like.
