Legion L4 — Application / Consumer SDK (building apps)
Status: DRAFT (Foundation 09). The first visible cut of the L4 application API. A draft, not a release candidate — names and signatures may change. Start with
architecture.md(the layer model) andabi.md(the exported ABI). The L3 side — providing a capability — is inl3_modules.md.
What L4 is
L4 is the consumer surface: you build an app that runs a Legion node,
discovers capabilities the cohort advertises, and consumes them — calling
a remote ai.stt.transcribe, a peer's display.gui, a storage cap, etc. Apps
never advertise capabilities (that's L3); they consume.
L4 is the dynamic ABI — the ~131 exported legion_* symbols. It is the
native-C surface any consumer links against: native desktop apps, embedded /
microcontroller targets, and language bindings alike (the Android NDK voice-loop
app was the first proof-of-concept, not a constraint). See abi.md.
#include <legion/legion.h> /* umbrella — pulls the whole L4 surface */
legion.h transitively includes the library base, l4_node.h, the
legion_ffi_*.h family, and (for convenience) sdk.h.
Two flavors of the L4 surface
L4 exposes the same node two ways; pick by how you're building:
Native C API (l4_node.h) | Binding / FFI API (legion_ffi_*.h) | |
|---|---|---|
| Handle | typed legion_node_t * | opaque intptr_t |
| Create | legion_node_create_from_file(<JSON config>) | legion_ffi_node_start(node_id, cohort, master_pk, seed, cert, cert_len, multicast) |
| Consume a cap | via the plugin manager + L3 dispatch (legion_node_get_plugins → legion_module_dispatch_to_cap_async, see l3_modules.md) | self-contained legion_ffi_call_cap(...) |
| Best for | native apps that want typed pointers + config files | language bindings, embedding, simple consumers |
Both are L4 (both exported). Don't mix handles between the two. The worked example below uses the FFI flavor because it's the most self-contained and is exactly what the voice-loop app does.
Library base (legion.h)
| Function | Purpose |
|---|---|
legion_version / _version_major / _minor / _patch | Version string + components. |
legion_build_id | Git hash + build metadata. |
legion_free(ptr) | Free a buffer the library allocated and handed back. |
legion_log_set_callback(fn, ud) / legion_log_set_level(lvl) | Logging sink (level, component, msg, ud) + minimum level. |
legion_config_find(cli_path, …) | Resolve a config file: CLI path → $LEGION_CONFIG → ./ → user dir → system dir. |
Node lifecycle
Native (l4_node.h): legion_node_create_from_file (read a JSON config →
legion_node_t *) → legion_node_start → … → legion_node_stop →
legion_node_destroy. Plus legion_node_reload and read-only getters:
get_node_id, get_pk_hex, get_epoch, get_cohort_name, get_peers,
get_plugins, format_status (a human-readable status report).
FFI (legion_ffi_node.h): legion_ffi_node_start(node_id, cohort_name, master_pk_hex, seed_hex, service_cert, service_cert_len, multicast_enabled)
returns an intptr_t handle (0 on failure) with discovery / heartbeat /
transport threads already running; legion_ffi_node_stop(handle);
legion_ffi_node_is_running(handle). All FFI calls are thread-safe. The
member-enrolment params anchor the cohort for a member device: master_pk_hex +
seed_hex identify the cohort, and service_cert / _len is an optional
master-issued member certificate (pass NULL / 0 for a self-master node).
Worked example — discover + consume a remote capability
This is the pattern the voice-loop app uses to drive a remote ai.stt.transcribe
provider, using the real, verified legion_ffi_node.h surface.
#include <legion/legion.h>
#include <string.h>
#include <stdio.h>
/* 1. Start an in-process node (discovery + transport threads run in the bg).
* master_pk_hex + seed_hex anchor the cohort for a member device; the cert
* is an optional master-issued member cert (NULL/0 for a self-master node). */
intptr_t node = legion_ffi_node_start("my-app", "personal",
/*master_pk_hex=*/ NULL, /*seed_hex=*/ NULL,
/*service_cert=*/ NULL, /*service_cert_len=*/ 0,
/*multicast=*/ 1);
if (!node) return 1;
/* 2. DISCOVER — what peers are visible, and what caps do they advertise?
* JSON: [{"node_id":"...","did":"...","caps":["ai.stt.transcribe"],"trust":0.9}, ...] */
char peers[8192];
if (legion_ffi_node_get_peers_json(node, peers, sizeof(peers)) > 0)
printf("peers: %s\n", peers);
/* 3. (LEGACY FALLBACK — usually NOT needed.) A full cohort member catches up
* via consensus sync (cert-validated discovery) and resolves any_one caps on
* its own, so skip this. Only a device that can't converge on consensus needs
* to pin a module's delegation to a provider discovered in step 2. */
/* legion_ffi_node_delegate_pin(node, "stt", "balrog-ai-node"); */
/* 4. CONSUME — call `transcribe` on ANY live provider of `ai.stt.transcribe`
* (fungible any_one delegation). The single binary param carries the audio. */
uint8_t out[64 * 1024];
size_t out_len = 0;
int rc = legion_ffi_call_cap(node,
/*cap=*/ "ai.stt.transcribe",
/*module=*/ "stt",
/*method=*/ "transcribe",
/*param_key=*/ "audio",
/*param_data=*/ pcm_bytes, pcm_len,
/*timeout_ms=*/ 15000,
out, sizeof(out), &out_len);
if (rc == LEGION_OK) {
fwrite(out, 1, out_len, stdout); /* the transcript */
} else if (rc == LEGION_ERR_NOMEM && out_len > sizeof(out)) {
/* out_len is the FULL result size — grow the buffer and retry. */
} else if (rc == LEGION_ERR_NO_DELEGATE) {
/* No live provider of the cap (loud, never silently swallowed). */
}
/* 5. Done. */
legion_ffi_node_stop(node);
Notes:
legion_ffi_call_captargets any provider of the cap (any_onesemantic);*out_lenis always set to the full result size, soLEGION_ERR_NOMEMwith*out_len > out_capmeans "grow and retry," andLEGION_ERR_NO_DELEGATEmeans no provider is live.legion_ffi_node_delegate_pinis a legacy fallback, not the current class-B path: a class-B device now catches up via consensus sync (cert-validated discovery), so a full member resolvesany_onecaps without pinning. It still exists for a device that can't converge on consensus — pin the call to a peer discovered via the beacon — but a full cohort member should skip it.
Reference
Node + consume (legion_ffi_node.h, 24 fns)
| Group | Functions |
|---|---|
| Lifecycle | legion_ffi_node_start, _stop, _is_running |
| Discovery | legion_ffi_node_peer_count, _get_peers_json |
| Caps introspection | legion_ffi_node_provided_caps (enumerate the caps THIS build would provide, without starting a node — so a joining device can declare its own caps) |
| Consume a cap | legion_ffi_call_cap (any provider), legion_ffi_node_delegate / _delegate_async (a specific peer), legion_ffi_node_delegate_pin (legacy fallback — pin to a discovered peer; superseded by consensus-sync catch-up) |
| Liveness | legion_ffi_node_ping_peer_start / _poll / _cancel (non-blocking) |
| Byte streams | legion_ffi_node_stream_open / _status / _write / _close |
| Events | legion_ffi_node_poll_events (drain focus/content events — poll, not callback) |
| OS process | legion_ffi_osp_activate (ask a peer to launch a signed app) |
| Transport | legion_ffi_node_attach_mobile_bridge (class-B WAN tunnel), legion_ffi_node_network_changed (signal an OS network change to drive reconnect — Spec 34), legion_ffi_node_transport_status (poll mesh link-state — Spec 34) |
| Routing | legion_ffi_node_route_query (resolve a multi-hop route to a target node before delegating — Spec 33.04) |
| Storage | legion_ffi_node_enable_persistent_store (turn the objectstore file-backed) |
FFI subsystem families
Each FFI header bridges one subsystem to apps/bindings (opaque handles, explicit memory ownership). Consume what your app needs:
| Header | Fns | What it's for |
|---|---|---|
legion_ffi_audio.h | 12 | Mic capture + playback: audio_in_start/_stop/_get_utterance/_poll_events, audio_out_play/_poll_events. |
legion_ffi_focus.h | 4 | Follow-focus targeting: focus_get/_set/_set_for/_clear (which node a follow_focus cap routes to). |
legion_ffi_display.h | 11 | GUI surface: display_create/_destroy/_get_html/_open_shell/_pop_event/_pop_handoff. |
legion_ffi_identity.h | 17 | Keys + certs: generate_seed, derive_did, derive_master_pk, did_to_pk_hex, issue_auth_cert, issue_service_cert; plus device enrolment — enroll_request_build / _open (a joining device self-signs an enrolment request with proof-of-possession; the master verifies it before issuing a cert — channel-agnostic, QR or file). |
legion_ffi_js.h | 11 | JS app runtime: js_create/_destroy/_eval/_eval_string/_deliver_event. |
legion_ffi_objectstore.h | 13 | Content store: objectstore_create/_get/_get_bytes/_fetch_bytes/_list/_delete/_gc_cid. |
legion_ffi_bridge.h | 8 | Class-B WAN transport: mobile_bridge_connect/_connect_with_seed/_send/_recv/_add_route/_close/_is_connected. |
legion_ffi_connection.h | 1 | Connection hints: connection_manager_resume_hint. |
FFI helpers (legion_ffi.h)
legion_result_t + legion_result_free; the params builder
legion_params_create/_destroy/_set_string/_set_int/_set_bytes (for languages
without native maps); legion_string_len.
Key rules & gotchas
- Apps consume, never advertise. Advertising is purely a provider (L3)
concern — a certified module declaring
provided_caps. The L4 surface has no advertise call. - Three ways to invoke:
legion_ffi_call_cap→ any live provider (any_one);legion_ffi_node_delegate→ a named peer;legion_ffi_node_delegate_pin→ pin a module to a discovered peer (legacy fallback — superseded by consensus-sync catch-up; a full member needn't pin).LEGION_ERR_NO_DELEGATEis the loud "no provider" signal. - Events are polled, not pushed.
legion_ffi_node_poll_events,legion_ffi_audio_*_poll_events, etc. return pending events on your cadence — there is no C callback fired into your runtime (a deliberate choice; a blocking callback once stalled a cellular isolate). - Result buffers:
*out_lenis the full size;LEGION_ERR_NOMEMmeans grow and retry. Free library-allocated buffers withlegion_free/legion_pal->free, never libcfree. - Don't mix flavors: a
legion_node_t *(native) and anintptr_t(FFI) handle are not interchangeable. - Native apps invoke caps via the plugin manager (
legion_node_get_plugins- L3 dispatch) — see
l3_modules.md.
- L3 dispatch) — see
Binding liblegion from another language (Go, Rust, Python, …)
Everything below is the contract a language binding needs. The FFI family was designed for exactly this: opaque handles, fixed-size caller buffers, integer return codes, and no callbacks into your runtime on the consume path.
Link + headers
Link liblegion.so (a legion.pc is installed, so pkg-config --cflags --libs legion works). Include via <legion/…>; 17 headers are installed — start
with legion.h, legion_types.h, and the legion_ffi_*.h family.
Do not bind against the internal headers — legion_wire.h, legion_vm.h,
legion_lnmp.h, legion_route_reply.h, legion_op_registry.h. They are present
in a source checkout but deliberately not installed, carry below-L4
internals, and are explicitly outside the stability contract. See
abi.md. The ABI is exactly 131 exported legion_* symbols and
nothing else — no libsodium, no internal Legion symbols.
Calling convention
- Most FFI functions return
int:0= success (LEGION_OK), negative = error. The codes are theLEGION_ERR_*enum inlegion_types.h(installed):-1NOMEM,-2INVALID_ARG,-3NOT_FOUND,-4EXISTS,-5IO,-6NETWORK,-7TIMEOUT, … Map these to your language's error type; do not invent your own numbering. - Handles are
intptr_t, not pointers. Zero means failure. A nativelegion_node_t *and an FFIintptr_thandle are not interchangeable — pick one flavor and stay in it.
Memory ownership — three patterns, in order of how often you will meet them
- Caller-supplied fixed buffer (the common case). Functions taking
(char *out, size_t out_cap)— you allocate, you free, liblegion only fills. Nothing to release on the library side. This is the dominant shape across the FFI surface. - Library-allocated, variable size. Where a call yields a buffer whose size
you cannot predict,
*out_lenis the full required size andLEGION_ERR_NOMEMmeans grow and retry. Release these withlegion_free(orlegion_pal->free) — never your libcfree, and never your language's allocator. - Borrowed pointers. A few calls (notably in
legion_ffi_js.h) return a pointer that is valid only until the next call or destroy on that handle. Copy immediately. For a GC'd language this matters twice over: never retain the raw pointer past the call.
Handle lifecycle is explicitly paired — legion_ffi_display_destroy,
legion_ffi_js_destroy, legion_ffi_mobile_bridge_close,
legion_ffi_node_stream_close, plus legion_node_destroy /
legion_params_destroy / legion_result_free on the native flavor.
⚠️ Threading — thread-safe is not the same as non-blocking
Every FFI call is safe from any thread. The node runs its own background threads. That is a concurrency guarantee, not a latency guarantee, and the distinction is the single most common way to get this wrong.
These block on the network — the call does not return until the round trip completes or times out:
| Call | Why |
|---|---|
legion_ffi_call_cap, …_activate, …_fetch | capability round trip to a remote provider |
legion_ffi_bridge recv | blocks up to timeout_ms for the next inbound payload |
legion_ffi_audio stream playback | blocks until playback drains |
legion_ffi_assistant_last_response | cap-routed blocking call |
These are fast and local: put / get / poll / gc, the stream
open / send / close trio, and PING (explicitly non-blocking — poll for the
result).
For Go specifically: a blocking cgo call occupies its OS thread for the
whole duration — the scheduler cannot preempt it, and the runtime may have to
spawn another M to keep other goroutines moving. Put the blocking calls on a
dedicated goroutine and treat them as I/O, not as function calls. Do not call
them from anything latency-sensitive.
This is not theoretical. The Dart binding hit exactly this: a synchronous ping blocked the UI isolate, which caused the carrier to batch outbound TCP segments and produced RTTs that looked like timeouts. Its bindings now carry the rule inline — "call_cap/activate/fetch block on the network … put/get/poll/gc are fast/local."
Events are polled, not pushed
The consume path fires no C callback into your runtime — a deliberate choice
(a blocking callback once stalled a cellular isolate). You call
legion_ffi_node_poll_events, legion_ffi_audio_*_poll_events etc. on your own
cadence. For a cgo binding this removes the whole //export + C-calling-into-Go
problem: there is nothing to register.
(The one exception is the display family, which takes an optional render
callback if you use it — see legion_ffi_display.h.)
Reference implementation
legion-app ships a working Dart FFI binding against this exact ABI — the
most useful thing to read alongside this page:
lib/src/ffi/legion_bindings.dart— symbol lookup + the blocking/fast splitlib/src/ffi/legion_node.dart— handle lifecycle, buffer alloc/free, theIsolate.runoffload for blocking calls (the analogue of your worker goroutine)lib/src/ffi/bridge_bindings.dart— the blockingrecvloop
Status
DRAFT — surfaced for review; expect changes. With this, the API-doc series is
architecture.md → abi.md →
l3_modules.md → this. Next phase: build real Legion apps on
this surface.
Note: the
examples/*.ctree predates this L4 surface and needs a refresh to match it; treat the docs above (not those examples) as canonical for now.
