Files
logos-tutorial/logos-developer-guide.md
Dario LipicarandClaude Opus 5 d3c325e5d2 docs: stop documenting removed generator flags (#81)
* docs: --module-dir was removed from logos-cpp-generator

The generator now refuses the flag (exit 2) instead of accepting it, so both
places the guide spelled it were about to become instructions that hard-error.
Replaced with the supported form: `--general-only` plus one
`--dep <name>=<name>.lidl` per dependency, which generates each wrapper from
that dependency's contract rather than by loading its built plugin.

Tutorial Sync Rule; the code change is in logos-cpp-sdk
(cpp-generator/legacy/main.cpp).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(dev-boost): name an artifact the toolchain can still produce

The CMake block listed calc_wrap_qt_glue.h and calc_wrap_dispatch.cpp in
logos_module(SOURCES …). Those were emitted by --backend qt, which is deleted;
a module now emits <name>_cdylib_glue.{h,cpp} and Qt-plugin packaging is a
downstream step.

Nothing goes red — logos-tutorial has no dev-boost doctest — so this would have
stayed wrong silently. It was likely already stale (logos-dev-boost contains no
_qt_glue string at all), but the deletion turns "out of date" into "permanently
unbuildable", which the Tutorial Sync Rule exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:02:41 -03:00

76 KiB

Logos Module Developer Guide

A comprehensive guide to creating, building, testing, packaging, and distributing modules for the Logos platform.

Table of Contents


Overview

The Logos platform is a modular application framework built in C++ on top of Qt 6. Applications are composed of dynamically loaded modules (plugins) that communicate via an IPC layer. The platform provides:

  • Process isolation -- each module runs in its own host process (on desktop), communicating via Qt Remote Objects
  • Cross-platform support -- macOS (arm64, x86_64) and Linux (arm64, x86_64)
  • A package format (.lgx) for distributing modules with platform-specific variants
  • A desktop application shell (logos-basecamp) with a sidebar, tabbed workspace, and plugin management UI
  • A CLI runtime (logoscore) for running modules headlessly

Architecture

+---------------------------------------------------------------+
|                     Application Layer                          |
|   logos-basecamp (Desktop GUI)  or  logoscore (CLI Runtime)        |
+---------------------------------------------------------------+
        |                    |                    |
        v                    v                    v
+---------------+  +------------------+  +------------------+
|  Module A     |  |  Module B        |  | Package Manager  |
| (logos_host)  |  | (logos_host)     |  | Module           |
+-------+-------+  +--------+---------+  +--------+---------+
        |                    |                     |
        |        Qt Remote Objects (IPC)           |
        +--------------------------------------------+
                             |
                    +--------v---------+
                    |    liblogos      |  (Core Runtime)
                    | logos-liblogos   |
                    +--------+---------+
                             |
                    +--------v---------+
                    |  logos-cpp-sdk   |  (SDK: LogosAPI,
                    |                  |   Code Generator,
                    |                  |   Types, IPC)
                    +------------------+

Key components:

Component Repository Role
logos-module-builder logos-co/logos-module-builder Scaffolding and build system for new modules
logos-module logos-co/logos-module Plugin loading/introspection library + lm CLI
logos-cpp-sdk logos-co/logos-cpp-sdk C++ SDK, types, IPC layer, code generator
logos-liblogos logos-co/logos-liblogos Core library (logos_host, liblogos_core)
logos-logoscore-cli logos-co/logos-logoscore-cli Headless CLI runtime (logoscore)
logos-package logos-co/logos-package LGX package format library + lgx CLI
logos-package-manager logos-co/logos-package-manager Local package manager library + lgpm CLI
logos-package-downloader logos-co/logos-package-downloader Online catalog browser + lgpd CLI
logos-standalone-app logos-co/logos-standalone-app Minimal shell for running/testing UI modules in isolation
logos-basecamp logos-co/logos-basecamp Desktop application shell

Prerequisites

Required

  • Nix with flakes enabled. This is the primary build tool for the entire ecosystem. Install Nix from nixos.org, then enable flakes:

    # If you need experimental features enabled per-command:
    nix --extra-experimental-features "nix-command flakes" <command>
    
    # Or enable globally in ~/.config/nix/nix.conf:
    experimental-features = nix-command flakes
    
  • C++ (C++17)
  • Qt 6 basics (QObject, Q_INVOKABLE, Q_PLUGIN_METADATA, signals/slots)
  • Basic CMake
  • Basic Nix concepts (flakes, derivations)

Part 1: Creating a Module

1.1 Scaffold with logos-module-builder

The fastest way to create a new module is using the logos-module-builder template:

# Create a new directory for your module
mkdir logos-my-module && cd logos-my-module

# Scaffold a minimal core module (no external dependencies)
nix flake init -t github:logos-co/logos-module-builder

# Or scaffold a module that wraps an external C/C++ library
nix flake init -t github:logos-co/logos-module-builder#with-external-lib

# For ui_qml modules with C++ backend (process-isolated)
nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend

# For ui_qml modules (QML-only, no C++)
nix flake init -t github:logos-co/logos-module-builder#ui-qml

Note: The generated flake.nix uses an unpinned logos-module-builder URL. For reproducible builds, pin it to a specific commit — see the flake.nix examples in Section 3.2 and the tutorials.

Available templates:

Template Use Case
default Minimal core module (C++ backend, no UI)
with-external-lib Core module wrapping an external C/C++ library
ui-qml-backend ui_qml with C++ backend + QML view (process-isolated)
ui-qml ui_qml QML-only (in-process, no C++)

The ui-qml-backend and ui-qml templates automatically enable nix run to launch and test your UI plugin in isolation without the full logos-basecamp shell. The standalone app runner is bundled with logos-module-builder — no extra flake input is needed. All module dependencies declared in metadata.json are auto-bundled from their LGX packages.

This generates a ready-to-build project with all the boilerplate handled for you.

1.2 Project Structure

We will use the recommended pure-C++ pattern ("interface": "universal") for a core module. The scaffolding templates currently emit the older Qt-plugin layout; you replace their src/ files with the two *_impl files shown here (see Section 1.4).

A pure-C++ core module looks like this:

logos-my-module/
├── flake.nix              # Nix flake (build config, ~15 lines)
├── metadata.json          # Single source of truth: module metadata + build config (~30 lines)
├── CMakeLists.txt         # CMake build file (~25 lines)
└── src/
    ├── my_module_impl.h         # Plain C++ class — no Qt
    └── my_module_impl.cpp       # Implementation

The key insight: logos-module-builder reduces ~600 lines of configuration across 5+ files down to ~70 lines across 2-3 files, and the universal pattern collapses the three hand-written Qt source files into one plain C++ class. metadata.json serves as the single source of truth — it contains both the runtime metadata (embedded into the generated plugin binary) and the build configuration (read by the builder via the nix section).

The CMakeLists.txt is minimal -- it includes LogosModule.cmake (provided by the builder) and calls the logos_module() macro, which sets up the plugin target, runs logos-cpp-generator for universal modules, links the SDK, configures include paths, and compiles the generated glue. You just list your *_impl source files. See the C-library tutorial for a complete CMakeLists.txt.

1.3 The metadata.json Configuration

The metadata.json file is the single source of truth for your module. It is embedded into the generated plugin binary (for runtime metadata, read by lm), read by logos-module-builder to configure the Nix build, used by CMake to resolve external dependencies and link libraries (via the nix section), and used by nix-bundle-lgx to generate the LGX manifest. See the scaffolded metadata.json in the template.

The full set of available fields:

{
  "name": "my_module",
  "display_name": "My Module",
  "version": "1.0.0",
  "type": "core",
  "category": "general",
  "description": "My first Logos module",
  "icon": null,
  "main": "my_module_plugin",
  "interface": "universal",
  "dependencies": [],
  "include": [],

  "nix": {
    "packages": {
      "build": [],
      "runtime": []
    },
    "external_libraries": [],
    "cmake": {
      "find_packages": [],
      "extra_sources": [],
      "extra_include_dirs": [],
      "extra_link_libraries": []
    }
  }
}

Field reference:

Field Required Default Description
name Yes -- Module name (used for filenames and identifiers)
display_name No name Human-readable label shown in UIs (Package Manager, App Manager, lm metadata, lgx manifest). Consumers fall back to name when unset, so older packages keep working.
version No 1.0.0 Semantic version
type No core Module type (core, ui, ui_qml)
category No general Category (general, network, chat, wallet, integration)
description No "A Logos module" Human-readable description
icon No null Relative path to the module icon (used by UI modules). The build system includes it in the standalone app plugin directory.
main Yes (core/ui), optional (ui_qml) -- Plugin entry point. For core/ui modules: plugin name without extension (the generated <name>_plugin). For ui_qml: optional backend plugin name (omit if QML-only).
interface No -- Set to "universal" for the pure-C++ pattern: you write a plain src/<name>_impl.h/.cpp and the builder runs logos-cpp-generator --from-header to synthesize the Qt plugin. Omit for the older hand-written Qt-plugin pattern.
concurrency No "single" Dispatch mode. "single" (default): calls to this module are dispatched one at a time (event-loop semantics) — you need no thread-safety. "multi": handlers run concurrently on a worker pool, so one blocking handler (a slow download, a slow RPC) no longer stalls other callers — but you own thread-safety. See §1.6 Concurrent dispatch.
view Yes (ui_qml) -- Relative path to the QML entry file (e.g. Main.qml). Required for ui_qml modules.
dependencies No [] Other Logos module names this depends on. Each entry must match the name field in that dependency's metadata.json.
interface_dependencies No [] Header interfaces this module binds at runtime, decoupled from any concrete module. Each entry is { name, file, impl_class?, input? } — see Dependency interfaces and the tutorial.
dependency_overrides No {} Per-dependency LIDL-contract source overrides, keyed by dependency name → { file, input?, impl_class? }. Forces where a dependency's interface is read from; normally auto-resolved from the dep's lidl output. See §9.2 Module Dependencies.
include No [] Additional files (e.g. shared libraries like libwaku.so, libwaku.dylib) to bundle alongside the plugin in the output.
nix.packages.build No [] Nix packages for build time
nix.packages.runtime No [] Nix packages for runtime
nix.external_libraries No [] External C/C++ libraries to wrap. Each entry is an object — see configuration reference for fields (name, vendor_path, build_command, etc.).
nix.cmake.find_packages No [] CMake find_package() calls
nix.cmake.extra_sources No [] Additional source files to compile
nix.cmake.extra_include_dirs No [] Additional include directories
nix.cmake.extra_link_libraries No [] Additional libraries to link

1.4 Understanding the Module Code

The recommended way to write a core module is the pure-C++ pattern ("interface": "universal" in metadata.json). You write a single plain C++ class — src/<name>_impl.h and src/<name>_impl.cpp — with no Qt, no Q_OBJECT, no Q_PLUGIN_METADATA, no interface header. At build time logos-cpp-generator --from-header parses your header and generates the Qt plugin wrapper, the interface, and the inter-module glue into generated_code/. You never see or edit that generated code.

A minimal impl class looks like this:

// src/my_module_impl.h
#pragma once

#include <cstdint>
#include <string>

#include <logos_module_context.h>  // optional: events + inter-module calls

class MyModuleImpl : public LogosModuleContext {
public:
    // Every public method is exposed: discoverable by `lm`, callable by
    // `logoscore call`, and reachable from other modules.
    std::string greet(const std::string& name);
    int64_t add(int64_t a, int64_t b);

    // Events are declared like Qt signals. The generator emits the body;
    // calling the method delivers the typed args to subscribers.
logos_events:
    void greeted(const std::string& name);
};
// src/my_module_impl.cpp
#include "my_module_impl.h"

std::string MyModuleImpl::greet(const std::string& name) {
    greeted(name);                       // emit the event
    return "Hello, " + name + "!";
}

int64_t MyModuleImpl::add(int64_t a, int64_t b) { return a + b; }

How it works:

  1. Any public method is exposed — discoverable by lm, callable by logoscore call, and accessible from other modules. private members are not.
  2. Use the supported types so the generator can translate them onto the wire: void, bool, int64_t, uint64_t, double, std::string, std::vector<std::string>, std::vector<uint8_t>, LogosMap/LogosList (from <logos_json.h>), and StdLogosResult (from <logos_result.h>). Use int64_t for integers, not int.
  3. Events are declared in a logos_events: section (the class must inherit LogosModuleContext). Calling the event method routes the typed args to subscribers via the host's eventResponse channel — outside a host (unit tests) it's a safe no-op.
  4. Inter-module calls also go through LogosModuleContext: from a method body, modules().other_module.someMethod(arg) calls another module using std types, with no raw LogosAPI and no Qt. Declare the dependency in metadata.json's dependencies and as a flake input.

You do not write initLogos, name()/version() (read from metadata.json), Q_INVOKABLE, or the eventResponse signal — all are generated. name() is taken from metadata.json's name, so they can never drift out of sync.

Older Qt-plugin pattern. As of this writing the scaffolding templates still emit a hand-written Qt plugin (*_interface.h + *_plugin.h + *_plugin.cpp with QObject, Q_PLUGIN_METADATA, Q_INVOKABLE, and an initLogos(LogosAPI*) you store). That pattern still builds and is what ui_qml C++ backends use (see Building a C++ UI Module). For a new core module, prefer the pure-C++ pattern above — replace the template's src/ files with your *_impl.h/*_impl.cpp and add "interface": "universal" to metadata.json. The C-library tutorial walks through this end to end.

1.5 Building Your Module

# Nix requires all source files to be tracked by git
git init && git add -A

# Build everything (library + generated SDK headers)
nix build

# Build just the plugin shared library (.so / .dylib)
nix build .#lib

# Build just the generated SDK headers (for other modules to use)
nix build .#include

# Emit a ready-to-build codebase: runs every code generator that is part of the
# build and writes the module source + a fully-populated generated_code/ to
# result/. Build it from `nix develop` without re-running any generator.
nix build .#generate

# Enter the dev shell for manual CMake builds (see: https://nix.dev/tutorials/first-steps/dev-environment)
# The shell provides cmake, ninja, Qt, the Logos SDK, and all build dependencies.
nix develop
cmake -B build -GNinja && cmake --build build

Build outputs:

result/
├── lib/
│   └── my_module_plugin.so       # (or .dylib on macOS)
└── include/
    ├── my_module_api.h           # Generated type-safe wrapper header
    └── my_module_api.cpp         # Generated wrapper implementation

1.6 Concurrent dispatch

By default every call to a module is dispatched one at a time — the module's methods run on a single thread (the event loop), so you never have to think about thread-safety. This is the right default and stays the default. The downside: a handler that blocks — a download that runs for minutes, a slow RPC — stalls every other caller of that module until it returns.

Set "concurrency": "multi" in metadata.json to opt that module into concurrent dispatch: each incoming call runs on its own worker, so a blocking handler no longer holds up the others (e.g. a downloader can serve two downloads at once). In exchange, you own thread-safety — your handlers run in parallel, so any state they share must be synchronized.

The generated code enforces the contract differently per language:

  • Rust (interface: "cdylib", rust-first). In multi mode the generated trait takes &self (not &mut self) and is Send + Sync, and the instance is shared behind an Arc. The compiler makes the rule unavoidable: a handler that mutates a plain field won't build — wrap mutated state in interior mutability (Mutex, RwLock, Atomic*, DashMap, …). on_context_ready also becomes &self.

    pub trait Downloader: Send + Sync + 'static {
        fn fetch(&self, url: String) -> String;   // &self — runs concurrently
    }
    #[derive(Default)]
    struct Impl { jobs: std::sync::Mutex<Vec<String>> }   // guard shared state
    
  • C++ (interface: "universal" / "cdylib"). In multi mode each call runs on a worker thread, so your impl's methods may execute concurrently — treat them as re-entrant and guard any shared members (std::atomic, std::mutex). The LogosModuleContext accessors and the event-emit path are already thread-safe.

How it works (and its limits). multi is realized entirely by the code generator — there is no new transport and, crucially, no change to the provider/host ABI. A multi module's generated glue does not block in its dispatch entry point: it hands the handler to a worker and immediately returns a small pending marker, then pushes the real result back as a completion event once the worker finishes. The consumer side awaits that completion transparently, so generated clients are unchanged. The decomposition is serialized dispatch + concurrent processing + serialized responses, and it works over the default transport (QtRO) as well as the plain transport. Because the host merely forwards the marker and the completion, an existing (older) daemon or app loads and runs a multi module unmodified — this is logos-protocol 0.2, an additive, backward-compatible minor bump (same MAJOR ⇒ still compatible). Caveats: (1) a single module is unchanged and pays zero overhead; (2) events your handlers emit are delivered safely but their order relative to method replies is not guaranteed — don't rely on "event X always arrives before method Y returns"; (3) a caller built against logos-protocol < 0.2 will see the raw pending marker instead of the result — rebuild callers against ≥ 0.2 (still compatible with every existing module) to consume a multi module concurrently.


Part 2: Inspecting Your Module

2.1 The lm CLI Tool

The lm tool (from logos-module) lets you inspect compiled module binaries without loading them into the full runtime. It reads metadata and enumerates methods via Qt's meta-object system.

Building lm

nix build 'github:logos-co/logos-module#lm' --out-link ./lm

Viewing Metadata

# Human-readable metadata
./lm/bin/lm metadata ./result/lib/my_module_plugin.so

# JSON output
./lm/bin/lm metadata ./result/lib/my_module_plugin.so --json

Example JSON output:

{
  "name": "my_module",
  "display_name": "My Module",
  "version": "1.0.0",
  "description": "My first Logos module",
  "author": "",
  "type": "core",
  "dependencies": []
}

display_name is omitted when unset; consumers fall back to name.

Viewing Methods

# Human-readable method list
./lm/bin/lm methods ./result/lib/my_module_plugin.so

# JSON output
./lm/bin/lm methods ./result/lib/my_module_plugin.so --json

Example JSON output:

[
  {
    "name": "initLogos",
    "signature": "initLogos(LogosAPI*)",
    "returnType": "void",
    "isInvokable": true,
    "parameters": [{ "name": "logosAPIInstance", "type": "LogosAPI*" }]
  },
  {
    "name": "doSomething",
    "signature": "doSomething(QString)",
    "returnType": "QString",
    "isInvokable": true,
    "parameters": [{ "name": "input", "type": "QString" }]
  }
]

2.2 The logos-module-viewer

The logos-module-viewer is a graphical tool for inspecting loaded modules.

# Build the viewer
nix build 'github:logos-co/logos-module-viewer#app' --out-link ./logos-viewer

# Run it with your module
./logos-viewer/bin/logos-module-viewer -m ./result/lib/my_module_plugin.so

This opens a window showing the module's metadata, methods, and allows interactive method invocation.


Part 3: Testing UI Modules

For ui_qml modules (both QML-only and C++ backend), logos-module-builder provides automatic integration testing using the logos-qt-mcp QML inspector.

3.1 How It Works

The test infrastructure has three layers:

  1. QML Inspector — a TCP server compiled into logos-standalone-app that exposes the QML object tree
  2. MCP Server — a Node.js bridge that translates test commands into inspector calls
  3. Test Framework — a JavaScript API for writing UI assertions (expectTexts, click, waitFor, etc.)

When you run nix build .#integration-test, the builder:

  • Launches logos-standalone-app with your plugin in headless mode (QT_QPA_PLATFORM=offscreen)
  • Connects to the QML inspector
  • Runs all .mjs test files in your tests/ directory

3.2 Writing Tests

Create .mjs files in tests/. Each file imports the test framework and defines test cases:

import { resolve } from "node:path";

// CI sets LOGOS_QT_MCP automatically; for interactive use: nix build .#test-framework -o result-mcp
const root =
  process.env.LOGOS_QT_MCP ||
  new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(
  resolve(root, "test-framework/framework.mjs")
);

test("my_module: loads UI", async (app) => {
  await app.waitFor(
    async () => {
      await app.expectTexts(["Hello"]);
    },
    { timeout: 15000, interval: 500, description: "UI to load" },
  );
});

test("my_module: click button", async (app) => {
  await app.click("Submit");
  await app.expectTexts(["Result:"]);
});

run();

Key test APIs:

  • app.expectTexts(["text1", "text2"]) — assert text is visible in the UI
  • app.click("Button Text") — find an element by text and click it
  • app.waitFor(fn, opts) — retry an assertion until it passes or times out
  • app.screenshot() — capture the current UI state (returns a base64 PNG; write it to a file to embed it in docs)

In the executable-tutorial specs (tests/*.test.yaml), you don't call app.screenshot() directly — add a screenshot: "name.png" field to any UI-test action and the runner captures the headless app to outputs/images/ and embeds it in the generated tutorial. See docs/spec.md.

3.3 Running Tests

# Hermetic CI test (builds everything, no display needed)
nix build .#integration-test -L

# Interactive: build the test framework locally (one-time)
nix build .#test-framework -o result-mcp

# Start the app (inspector listens on localhost:3768)
nix run .

# Run tests against the running app (in another terminal)
node tests/ui-tests.mjs

Multiple test files in tests/ are discovered and run automatically. You can organize tests by concern (e.g., tests/smoke.mjs, tests/interactions.mjs).

Note: The integration test infrastructure requires logos-standalone-app with QML inspector support. This is provided automatically by logos-module-builder — no extra flake inputs needed.


Part 4: Packaging Your Module

Before you can run your module with logoscore or install it into logos-basecamp, you need to package the build output into an .lgx package and install it into a modules/ directory.

4.1 The LGX Package Format

Logos modules are distributed as .lgx packages. An LGX file is a gzip-compressed tar archive with a specific internal structure:

mymodule.lgx (tar.gz)
├── manifest.json          # Package metadata
├── variants/
│   ├── linux-amd64/
│   │   └── my_module_plugin.so
│   ├── darwin-arm64/
│   │   └── my_module_plugin.dylib
│   └── darwin-arm64-dev/
│       └── my_module_plugin.dylib
├── docs/                  # Optional
└── licenses/              # Optional

The manifest.json is auto-generated from your module's metadata.json by the bundler. It maps each variant to its main entry point.

4.2 Building LGX Packages

There are two ways to create .lgx packages. The preferred approach uses the built-in Nix derivation that comes with logos-module-builder. Alternatively, you can use the nix bundle command directly.

Built-in Nix Derivation (Preferred)

When your module uses logos-module-builder, LGX package outputs are automatically available as part of your flake (the builder includes nix-bundle-lgx internally):

# Dev variant (uses /nix/store references, for local development)
nix build .#lgx

# Portable variant (self-contained, all dependencies bundled)
nix build .#lgx-portable

This produces a my_module-<version>.lgx file in the result/ directory.

This works because logos-module-builder includes nix-bundle-lgx as its own dependency and both mkLogosModule and mkLogosQmlModule automatically create the lgx and lgx-portable package outputs. No extra configuration is needed — it is part of the standard module template:

{
  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder";
  };

  outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosModule {
      src = ./.;
      configFile = ./metadata.json;
      flakeInputs = inputs;
    };
}

Using nix bundle (Alternative)

You can also create .lgx packages using the nix bundle command directly. This is useful if your module does not use logos-module-builder, or if you need the dual bundling mode (both dev and portable in a single .lgx file) which is only available via the nix bundle command:

# Dev variant
nix bundle --bundler github:logos-co/nix-bundle-lgx .#lib

# Portable variant
nix bundle --bundler github:logos-co/nix-bundle-lgx#portable .#lib

# Dual variant (both dev and portable in one .lgx file)
nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib

This produces a my_module-<version>.lgx file in the current directory.

Bundling modes:

Mode Built-in Command nix bundle Command Variant Created Use Case
Dev nix build .#lgx nix bundle --bundler ...#default .#lib darwin-arm64-dev Local development (requires Nix store)
Portable nix build .#lgx-portable nix bundle --bundler ...#portable .#lib darwin-arm64 Distribution (self-contained, no Nix needed)
Dual (not available as built-in) nix bundle --bundler ...#dual .#lib Both dev and portable One package for both environments

Variant naming:

Nix System Dev Variant Portable Variant
aarch64-darwin darwin-arm64-dev darwin-arm64
x86_64-darwin darwin-amd64-dev darwin-amd64
aarch64-linux linux-arm64-dev linux-arm64
x86_64-linux linux-amd64-dev linux-amd64
x86_64-windows windows-x86_64-dev windows-x86_64

Important: The variant type matters when installing into logos-basecamp. A dev build of basecamp expects dev variants, and a portable build expects portable variants. Use the dual bundler to produce packages that work with both.

Windows is cross-built only. x86_64-windows is a pseudo-system: there is no Nix daemon for Windows, so the package is produced on a Linux (or macOS) machine targeting x86_64-w64-mingw32 and copied across. Note the variant is spelled windows-x86_64, not windows-amd64 — unlike Linux, it has no alias, so a package labelled windows-amd64 will not install.


Part 5: Installing and Managing Modules

5.1 The lgpm CLI

The lgpm CLI (Logos Package Manager) installs, searches, and manages module packages. Installing a package extracts it into a modules/ directory that logoscore and logos-basecamp can load from.

Building lgpm

nix build 'github:logos-co/logos-package-manager#cli' --out-link ./package-manager

Commands

lgpm manages locally-available .lgx packages. It does not download packages from the network — use lgpd (logos-package-downloader) for that.

# Install from a local .lgx file
./package-manager/bin/lgpm --modules-dir ./modules install --file ./my_module.lgx

# Install all .lgx files in a directory
./package-manager/bin/lgpm --modules-dir ./modules install --dir ./packages/

# List installed packages
./package-manager/bin/lgpm --modules-dir ./modules list

# Show installed package details
./package-manager/bin/lgpm --modules-dir ./modules info my_module

Global Options

Option Description
--modules-dir <path> Target directory for installed core modules
--ui-plugins-dir <path> Target directory for UI plugins
--json Output in JSON format
--platform <variant> Install for a platform other than this machine (see below)
-h, --help Show help

Installing for another platform

By default lgpm derives the variant from the machine it is running on, and refuses a package that does not provide it:

Error: Package does not contain variant for platform: linux-x86_64-dev
       (package provides: windows-x86_64-dev)

That refusal is the protection against installing a package built for one platform onto another, so it is deliberately fail-closed. Cross-building needs an explicit opt-out — the Nix install bundler, for instance, runs lgpm on a Linux builder to lay out a Windows package:

lgpm --modules-dir ./modules install --platform windows-x86_64 --file ./my_module.lgx

--platform applies to install, list and info alike, so all three agree on which platform is being managed, and lgpm prints the override to stderr when it is in effect — a silent platform switch would defeat the very check it bypasses.

Do not reach for --platform to resolve a dev/portable variant mismatch. If a package provides darwin-arm64 and your basecamp wants darwin-arm64-dev, the fix is to build the right variant (or use the dual bundler), not to override the platform — forcing it installs a package the runtime cannot load, turning a clear install-time error into a confusing load-time one.

5.2 Installing from Local Files

# Install a locally built .lgx package into a modules/ directory
./package-manager/bin/lgpm --modules-dir ./modules install --file ./my_module-1.0.0.lgx

After installation, the modules/ directory contains your extracted module:

modules/
└── my_module/
    ├── manifest.json
    ├── my_module_plugin.dylib   # (or .so on Linux)
    └── variant

5.3 Downloading and Installing from a Registry

To download packages from the online catalog and then install them locally, use lgpd (logos-package-downloader) followed by lgpm:

# Build lgpd
nix build 'github:logos-co/logos-package-downloader#cli' --out-link ./downloader

# Search for packages
./downloader/bin/lgpd search waku

# List all available packages
./downloader/bin/lgpd list

# Download a package
./downloader/bin/lgpd download my_module -o ./packages/

# Download from a specific release
./downloader/bin/lgpd --release v2.0.0 download my_module -o ./packages/

# Install the downloaded package locally
./package-manager/bin/lgpm --modules-dir ./modules install --file ./packages/my_module.lgx

lgpd handles the network side (browsing, searching, downloading), while lgpm handles local installation.


Part 6: Running Your Module

Once your module is packaged and installed into a modules/ directory (see Parts 3 and 4), you can run it with logoscore.

6.1 Running with logoscore

The logoscore CLI (from logos-liblogos) is a headless runtime that can load modules and invoke their methods from the command line.

Building logoscore

nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos

Daemon Mode

logoscore runs as a daemon that stays alive to host modules. Start it with -D:

# Start the daemon with a modules directory
./logos/bin/logoscore -D -m ./modules

Once the daemon is running, use commands from another terminal:

# Load a module
./logos/bin/logoscore load-module my_module

# Call a method on a loaded module
./logos/bin/logoscore call my_module doSomething hello

# List loaded modules
./logos/bin/logoscore list-modules --loaded

# Show module details
./logos/bin/logoscore module-info my_module

# Watch events from a module
./logos/bin/logoscore watch my_module

# Show daemon and module health
./logos/bin/logoscore status

# Stop the daemon
./logos/bin/logoscore stop

One-shot execution

There is no separate single-process mode — start a clean daemon, load the module(s) with load-module, call methods with call, then stop the daemon:

# Start a clean daemon (loads nothing on its own)
./logos/bin/logoscore -D -m ./modules &

# Wait until the daemon is accepting commands
until ./logos/bin/logoscore status >/dev/null 2>&1; do sleep 0.2; done

# Load the module(s) you need (dependencies resolved automatically)
./logos/bin/logoscore load-module my_module

# Call methods (positional args; @file reads a parameter from a file)
./logos/bin/logoscore call my_module doSomething hello
./logos/bin/logoscore call my_module init @config.json
./logos/bin/logoscore call my_module start

# Stop the daemon when done
./logos/bin/logoscore stop

Note: The legacy inline mode (-c "module.method(args)" / --quit-on-finish, which ran calls in one short-lived process) has been removed, as has the -l/--load-modules autoload flag — the daemon starts clean and modules are loaded with load-module. -m/--persistence-path configure daemon startup (-D).

Daemon startup flags:

Flag Description
-D Start the daemon
-m, --modules-dir <dir> Directory containing module libraries (repeatable)
--persistence-path <dir> Base directory for module instance persistence
--config-dir <dir> Isolate this daemon's config/state/tokens dir (run multiple instances; the client must use the same --config-dir)
@file.json (as a call arg) Pass a file's contents as a method argument

Daemon commands:

Command Description
status Show daemon and module health
load-module <name> Load a module into the daemon
unload-module <name> Unload a module
reload-module <name> Reload (unload + load) a module
list-modules [--loaded] List available or loaded modules
module-info <name> Show detailed module information
call <module> <method> [args] Call a method on a loaded module
watch <module> [--event] Watch events from a module
stats Show module resource usage
stop Stop the daemon

Part 7: Running in logos-basecamp

7.1 Building logos-basecamp

logos-basecamp produces two binary variants:

  • development (depends on /nix/store)
  • portable (self-contained, used in distributed builds and .app bundles)
# Build the development version
nix build 'github:logos-co/logos-basecamp#app' --out-link ./logos-basecamp

# Run the dev binary
./logos-basecamp/bin/logos-basecamp

# Build the portable/distributed version
nix build 'github:logos-co/logos-basecamp#portable' --out-link ./logos-basecamp-portable

# Or build platform-specific distributions:
nix build 'github:logos-co/logos-basecamp#bin-bundle-dir'     # Flat directory bundle
nix build 'github:logos-co/logos-basecamp#bin-appimage'       # Linux AppImage
nix build 'github:logos-co/logos-basecamp#bin-macos-app'      # macOS .app bundle

Note: When installing modules into logos-basecamp, the LGX variant type must match the build type. Dev builds of basecamp expect dev LGX variants (e.g., darwin-arm64-dev), while portable builds expect portable variants (e.g., darwin-arm64). Use the dual bundler (see 3.2) to produce packages that work with both.

7.2 Module Types in logos-basecamp

The application supports three types of modules:

Core Modules (Backend)

These are non-UI modules that provide backend functionality. They run in isolated logos_host processes and communicate via Qt Remote Objects.

  • Loaded via logos_core_load_plugin()
  • Placed in the modules directory (--modules-dir)
  • Have "type": "core" in metadata

ui_qml with C++ Backend (Process-Isolated)

These have "type": "ui_qml" with both "main" (backend plugin) and "view" (QML entry point) in metadata.json. The C++ backend runs in a separate ui-host process; the QML view loads in the host app.

The remote interface is defined in a .rep file (Qt Remote Objects definition):

class CalcUiCpp
{
    PROP(QString status READWRITE)    // auto-synced to QML replica
    SLOT(int add(int a, int b))       // callable from QML, returns via Promise
    SIGNAL(errorOccurred(QString msg)) // one-shot events
}

The .rep file is the single source of truthrepc generates:

  • CalcUiCppSimpleSource — base class the C++ backend inherits
  • CalcUiCppReplica — typed replica the QML view uses via logos.module()
  • A separate _replica_factory plugin for typed remoting

The C++ plugin inherits from the generated source + ViewPluginBase:

class CalcUiCppPlugin : public CalcUiCppSimpleSource,
                        public CalcUiCppInterface,
                        public CalcUiCppViewPluginBase { ... };

QML accesses the backend via a typed replica:

readonly property var backend: logos.module("calc_ui_cpp")
// Properties auto-sync:
Text { text: backend.status }
// Return values via Promise:
logos.watch(backend.add(1, 2), function(v) { ... })
  • Scaffold: nix flake init -t github:logos-co/logos-module-builder#ui-qml-backend
  • See Tutorial Part 3 for a complete walkthrough

ui_qml QML-Only (In-Process)

These have "type": "ui_qml" with "view" but no "main" — pure QML, no C++ compilation, no process isolation:

  • QML view loads directly in the host app (basecamp / standalone)
  • No .rep file needed
  • Call core modules via the logos bridge: logos.callModule("module", "method", [args])
  • Network access denied, filesystem restricted to module directory
  • Scaffold: nix flake init -t github:logos-co/logos-module-builder#ui-qml
  • See Tutorial Part 2 for a complete walkthrough

Part 8: Inter-Module Communication

8.1 The LogosAPI

Every module receives a LogosAPI* pointer when initLogos() is called. This is your gateway to communicating with other modules.

void MyModulePlugin::initLogos(LogosAPI* logosAPIInstance)
{
    logosAPI = logosAPIInstance;

    // Get a client for calling another module
    LogosAPIClient* client = logosAPI->getClient("other_module");

    // Synchronous call (blocks until result is returned)
    QVariant result = client->invokeRemoteMethod(
        "other_module",  // target module name
        "someMethod",    // method name
        arg1, arg2       // arguments (up to 5 positional args)
    );

    // Async call (preferred -- non-blocking, result delivered via callback)
    client->invokeRemoteMethodAsync(
        "other_module",
        "someMethod",
        [](QVariant result) {
            // Handle result (called on the main thread)
            if (result.isValid()) {
                qDebug() << "Got result:" << result;
            }
        },
        arg1, arg2
    );
}

Prefer async calls. Synchronous invokeRemoteMethod blocks the caller's thread until the remote module responds. Use invokeRemoteMethodAsync to avoid blocking, especially in UI modules.

8.2 The C++ SDK Code Generator

The logos-cpp-generator tool (from logos-cpp-sdk) inspects a compiled module and generates typed C++ wrapper classes, so you get compile-time type safety instead of raw invokeRemoteMethod calls.

Getting logos-cpp-generator

The generator is bundled with logos-cpp-sdk. It is automatically available:

  • In nix develop -- the module dev shell includes the SDK on PATH
  • Build it directly:
    nix build 'github:logos-co/logos-cpp-sdk#cpp-generator' --out-link ./cpp-gen
    ./cpp-gen/bin/logos-cpp-generator --help
    

Generating Wrappers

# Generate wrappers for a single module
logos-cpp-generator /path/to/my_module_plugin.so --output-dir ./generated

# Generate a wrapper per dependency, each from that dependency's LIDL contract
logos-cpp-generator --metadata metadata.json --general-only --output-dir ./generated \
  --dep waku_module=/path/to/waku_module.lidl

# Generate only module files (no umbrella headers)
logos-cpp-generator /path/to/plugin.so --module-only --output-dir ./generated

# Generate only umbrella SDK files (assumes module files exist)
logos-cpp-generator --metadata metadata.json --general-only --output-dir ./generated

Using Generated Wrappers

After generation, you get typed wrapper classes with both synchronous and asynchronous methods:

#include "logos_sdk.h"  // Umbrella header

// In your module's initLogos():
void MyModulePlugin::initLogos(LogosAPI* api) {
    logosAPI = api;

    // Create the typed SDK wrapper
    LogosModules* logos = new LogosModules(api);

    // Synchronous call (blocks until result)
    QString result = logos->other_module.doSomething("hello");

    // Async call (preferred -- non-blocking)
    logos->other_module.doSomethingAsync("hello", [](QVariant result) {
        qDebug() << "Got:" << result;
    });
}

The generated LogosModules struct provides a member for each module, with methods matching the module's Q_INVOKABLE methods. For every method foo(), an async variant fooAsync() is also generated that takes a callback parameter.

Prefer async wrappers. Use doSomethingAsync(...) instead of doSomething(...) to avoid blocking the caller's thread. Synchronous calls can cause hangs if the target module is slow to respond.

Dependency Interfaces

A regular dependency couples a module to one concrete provider: you list other_module in dependencies, and the generated modules().other_module wrapper bakes that name into every call. A dependency interface instead lets a module declare a contract — a list of methods and events — that any module exposing a superset of it can satisfy, and bind that contract to a concrete module chosen at runtime.

Declare interfaces in metadata.json under interface_dependencies, alongside (or instead of) dependencies:

"interface_dependencies": [
  { "name": "calculator", "file": "interfaces/calculator.h", "impl_class": "ICalculator" }
]
Field Required Meaning
name Yes Interface identifier → bound wrapper class (Calculator) and the bind_<name> factory
file Yes Path to the contract: a pure-C++ .h (methods + a logos_events: block) or a .lidl file
impl_class For .h files The class inside the header whose signatures define the contract
input No A flake-input name hosting the interface (same wiring as dependencies); omit for a local file

The contract is written in the module's own language — for a universal module, a plain header:

// interfaces/calculator.h
class ICalculator {
public:
    int64_t add(int64_t a, int64_t b);
    std::string libVersion();
logos_events:
    void versionReady(const std::string& version);
};

The generator emits a bound wrapper whose target module is a constructor argument (not baked in), exposed on LogosModules as a bind_<name>(moduleName) factory. Bind once, then call as usual:

#include "logos_sdk.h"

// moduleName is chosen at runtime — config, discovery, user pick, etc.
auto calc = modules().bind_calculator("calc_module");
int64_t  sum = calc.add(3, 5);                 // synchronous
calc.fibonacciAsync(20, [](int64_t v){ ... });  // async (generated alongside)
calc.onVersionReady([](const std::string& v){ ... });  // typed event subscription

Binding is not validated: a module that does not satisfy the interface surfaces an ordinary remote-call error (a default-valued result), never a crash — so you can swap providers just by changing the bound name. The provider must be loaded at runtime; declaring it in dependencies is one way to ensure that, but the interface itself names no module.

See the Dependency Interfaces tutorial for an end-to-end walkthrough, and Composing Modules for the concrete-dependency counterpart.

8.3 LogosResult

Many module methods return LogosResult for structured success/error handling:

LogosResult result = logos->my_module.someMethod();

if (result.success) {
    // Access the value
    QString value = result.getString();
    int number = result.getInt();
    bool flag = result.getBool();
    QVariantMap map = result.getMap();
    QVariantList list = result.getList();

    // Access nested values by key (for map results)
    QString name = result.getString("name");
    int count = result.getInt("count", 0);  // with default

    // Generic typed access
    auto custom = result.getValue<MyType>();
} else {
    // Access the error
    QString error = result.getError();
}

To return a LogosResult from your module:

Q_INVOKABLE LogosResult MyModulePlugin::fetchData(const QString& id) {
    if (id.isEmpty()) {
        return {false, QVariant(), "ID cannot be empty"};
    }

    QVariantMap data;
    data["id"] = id;
    data["name"] = "Example";
    data["count"] = 42;
    return {true, data};
}

8.4 Communication Modes

The SDK supports two communication modes:

Mode Use Case Mechanism
Remote (default) Desktop apps Qt Remote Objects (IPC between processes)
Local Mobile apps, single-process In-process PluginRegistry

Set the mode before creating any LogosAPI instances:

// For mobile / embedded (all modules in one process)
LogosModeConfig::setMode(LogosMode::Local);

// For desktop (each module in its own process) -- this is the default
LogosModeConfig::setMode(LogosMode::Remote);

Part 9: Advanced Topics

9.1 Tutorials

For hands-on walkthroughs of module development patterns, see the dedicated tutorials:

  • Wrapping a C Library — create calc_module wrapping a vendored C library. Covers external library configuration in metadata.json.
  • Building a QML UI App — create calc_ui, a QML-only UI plugin that calls a core module via the logos.callModule() bridge.
  • Building a C++ UI Module — build calc_ui_cpp, a C++ + QML view module that combines a QML frontend with a C++ backend. The backend exposes Q_INVOKABLE methods using the generated typed SDK; the QML view calls them via logos.callModuleAsync().

9.2 Module Dependencies

Declare dependencies in your metadata.json:

{
  "name": "my_module",
  "dependencies": ["package_manager", "waku_module"]
}

Each entry in dependencies must match the name field in that module's own metadata.json. When adding a dependency as a flake input, the input attribute name must also match the dependency name — e.g., waku_module.url = "github:logos-co/logos-waku-module". The URL can point to any repo, but the attribute name is how the builder resolves dependencies.

When your module is installed via lgpm, its dependencies are automatically resolved and installed first. When loaded via logos-basecamp, core module dependencies are loaded before your module.

How dependencies are consumed — the LIDL contract

Each module publishes a small, language-neutral LIDL interface contract as a cheap flake output (packages.<system>.lidl), generated from its source with no plugin compile. When you depend on a module, the builder generates the typed modules().<dep> wrapper from that published LIDL — so building (or packaging) your module does not build the dependency module. The only step that still builds and bundles dependency plugins is the standalone-app run (nix run / #run), which has to, because it loads them.

This is the same logos-cpp-generator from §8.2, just driven by the dependency's LIDL contract — the same kind of .lidl/.h contract interface_dependencies uses — instead of inspecting a compiled plugin. Inspecting a compiled plugin (as §8.2 describes) is the manual/standalone path; for declared module dependencies the builder uses the contract path, which is why no dependency plugin is built.

Because the contract is LIDL, the dependency's implementation language doesn't matter: the pipeline is source → LIDL → C++ for a C++ module today, and Rust → LIDL → C++ for a Rust module tomorrow — the same generated modules().<dep> wrapper either way.

Transitional fallback. A dependency built by an older logos-module-builder won't expose a lidl output yet; for those the builder falls back to the previous behavior (build the dependency and copy its generated headers), so mixed dependency graphs keep working.

To force a specific contract source for a dependency — a committed .lidl, a header in another repo, etc. — add a dependency_overrides entry keyed by the dependency name:

"dependencies": ["calc_module"],
"dependency_overrides": {
  "calc_module": { "file": "interfaces/calc.lidl" }
}

Each override is { file, input?, impl_class? }: file is the .lidl/.h path (relative to this repo, or to the flake input if given), and impl_class is required for a .h file. Most modules never need this — auto-resolution from the dependency's lidl output is the default.

9.3 Exposing OpenMetrics / Prometheus Metrics

Infra operators monitor logos.dev nodes with Prometheus. The openmetrics module serves an OpenMetrics /metrics HTTP endpoint by querying a configured set of modules — it does not discover modules or read platform stats, it only calls the modules you list.

To make your module scrapeable, implement one method by convention:

collectMetrics() -> LogosMap

returning openmetrics-like fields:

{
  "metrics": [
    { "name": "storage_blocks_total", "type": "counter", "help": "Total blocks stored", "value": 42 },
    { "name": "storage_peers_connected", "type": "gauge", "help": "Connected peers", "value": 7, "labels": { "protocol": "libp2p" } }
  ]
}
Field Meaning
name metric name (for counters, the OpenMetrics _total sample suffix is handled)
type counter, gauge, histogram, or summary (unknown/missing → unknown)
help short description
value number (bools map to 1/0; numeric strings pass through)
labels optional string→string label pairs

The metrics server adds a module="<name>" label to every series automatically. Modules that don't implement collectMetrics (or that error/time out) are skipped, so one module never breaks a scrape.

Universal (plain C++) module:

// in <module>_impl.h:   LogosMap collectMetrics();
LogosMap MyModuleImpl::collectMetrics() {
    LogosMap metrics = LogosMap::array();
    metrics.push_back({
        {"name", "storage_blocks_total"}, {"type", "counter"},
        {"help", "Total blocks stored"},  {"value", m_blockCount}
    });
    return {{"metrics", metrics}};
}

Legacy (Qt) module — add Q_INVOKABLE QVariantMap collectMetrics(); returning the same { "metrics": [...] } shape as a QVariantMap/QVariantList.

Then run the metrics server alongside your module and point it at you (daemon mode passes the JSON arg intact):

# --config-dir isolates this daemon instance (config/state/tokens) from the
# default ~/.logoscore, so it can run alongside others. -D runs in the
# foreground, so background it and wait for it to be ready.
logoscore -D -m <modules-dir> --config-dir /tmp/om &
until logoscore --config-dir /tmp/om status >/dev/null 2>&1; do sleep 0.2; done
logoscore --config-dir /tmp/om load-module my_module
logoscore --config-dir /tmp/om load-module openmetrics
logoscore --config-dir /tmp/om call openmetrics start '{"port":9090,"modules":["my_module"]}'
curl http://localhost:9090/metrics

Reference: Repository Map

Repository What It Provides Key Outputs
logos-module-builder Build system / scaffolding mkLogosModule, mkLogosQmlModule Nix functions, LogosModule.cmake, templates
logos-module Plugin introspection liblogos_module.a (static lib), lm (CLI)
logos-cpp-sdk SDK + code generator LogosAPI, LogosResult, logos-cpp-generator, PluginInterface
logos-liblogos Core library logos_host, liblogos_core
logos-logoscore-cli Headless CLI runtime logoscore (CLI)
logos-package Package format lgx (CLI), liblgx (library)
logos-package-manager Local package management lgpm (CLI)
logos-package-downloader Online catalog + downloads lgpd (CLI)
logos-standalone-app Minimal UI module runner logos-standalone-app (loads a single UI plugin for testing)
logos-basecamp Desktop app shell LogosApp (GUI), MDI workspace, plugin loader

Reference: CLI Tools Summary

lm -- Module Inspector

lm <plugin-file>                              # Show metadata + methods
lm metadata <plugin-file> [--json]            # View module metadata
lm methods <plugin-file> [--json]             # List Q_INVOKABLE methods

logoscore -- Headless Runtime

# Daemon mode
logoscore -D -m <modules-dir>                 # Start daemon
logoscore load-module <name>                  # Load a module
logoscore call <module> <method> [args]       # Call a method
logoscore list-modules [--loaded]             # List modules
logoscore module-info <name>                  # Show module details
logoscore status                              # Daemon health
logoscore stop                                # Stop daemon

lgpm -- Local Package Manager

./package-manager/bin/lgpm --modules-dir <path> install --file <path.lgx>   # Install from local .lgx file
./package-manager/bin/lgpm --modules-dir <path> install --dir <dir>         # Install all .lgx files in a directory
./package-manager/bin/lgpm --modules-dir <path> list                        # List installed packages
./package-manager/bin/lgpm --modules-dir <path> info <pkg>                  # Show installed package details

lgpd -- Package Downloader

./downloader/bin/lgpd search <query>                       # Search packages by name/description
./downloader/bin/lgpd list [--category <cat>]              # List available packages
./downloader/bin/lgpd categories                           # List available categories
./downloader/bin/lgpd releases                             # List recent GitHub releases (up to 30)
./downloader/bin/lgpd info <pkg>                           # Show package details from catalog
./downloader/bin/lgpd download <pkg> [-o <dir>]            # Download .lgx package
./downloader/bin/lgpd --release <tag> download <pkg>       # Download from specific release

logos-cpp-generator -- SDK Code Generator

logos-cpp-generator <plugin-file> [--output-dir <dir>] [--module-only]
logos-cpp-generator --metadata <metadata.json> --general-only --dep <name>=<name>.lidl [--output-dir <dir>]
logos-cpp-generator --metadata <metadata.json> --general-only [--output-dir <dir>]

nix-bundle-lgx -- LGX Bundler

# Preferred: built-in derivation (logos-module-builder includes nix-bundle-lgx)
nix build .#lgx                                                       # Dev variant
nix build .#lgx-portable                                              # Portable variant

# Alternative: nix bundle command
nix bundle --bundler github:logos-co/nix-bundle-lgx .#lib            # Dev variant
nix bundle --bundler github:logos-co/nix-bundle-lgx#portable .#lib   # Portable variant
nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib       # Both variants

Troubleshooting

"experimental features" error with Nix

If you see errors about experimental features, either pass the flag:

nix --extra-experimental-features "nix-command flakes" build

Or add to ~/.config/nix/nix.conf:

experimental-features = nix-command flakes

Module loads but LogosAPI is not available

This happens when running a module outside the full Logos runtime (e.g., in the module viewer). The LogosAPI is only available when the module is loaded by logoscore or logos-basecamp.

Module not discovered by logos-basecamp

Check that:

  1. The module binary is in the correct directory (modules dir for core, plugins dir for UI)
  2. The metadata.json file is present alongside the binary
  3. The name field in metadata matches the binary name (e.g., my_module_plugin.so for module named my_module)

lgpm install fails

  • Check your internet connection (lgpm fetches from GitHub Releases)
  • Try specifying a release: ./package-manager/bin/lgpm --release v1.0.0 install my_module
  • For local files: ./package-manager/bin/lgpm install --file ./my_module.lgx
  • Check the target directory is writable: ./package-manager/bin/lgpm --modules-dir ./modules install my_module

Checking if a module loaded successfully

Use logoscore to verify your module loads and its methods are callable:

# Start the daemon (runs in the foreground, so background it and wait)
./logos/bin/logoscore -D -m ./modules &
until ./logos/bin/logoscore status >/dev/null 2>&1; do sleep 0.2; done

# Check if the module is listed as loaded
./logos/bin/logoscore list-modules --loaded

# Inspect the module
./logos/bin/logoscore module-info my_module

# Quick check: load the module, call a method, then stop the daemon
./logos/bin/logoscore load-module my_module
./logos/bin/logoscore call my_module greet test
./logos/bin/logoscore stop

UI module nix run fails to load dependencies

When running a UI module with nix run, the standalone app automatically bundles all module dependencies declared in metadata.json. If dependencies fail to load, check the following requirements:

Requirements for auto-bundled dependencies:

  1. Module type must be "ui" or use mkLogosQmlModule — only UI modules get apps.default wired up with the standalone app.

  2. Dependencies must be listed in metadata.json under the "dependencies" array:

    {
      "name": "my_ui_module",
      "type": "ui",
      "dependencies": ["calc_module", "storage_module"]
    }
    
  3. Each dependency must have a matching flake input — the flake input name must exactly match the dependency name in metadata.json:

    inputs = {
      logos-module-builder.url = "github:logos-co/logos-module-builder";
      calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
      storage_module.url = "github:logos-co/logos-storage-module";
    };
    
  4. Module names must be consistent — the "name" field in each dependency's metadata.json must match its flake input name. The build system uses this name to locate the plugin binary ({name}_plugin.so / {name}_plugin.dylib).

What changed (no more logos-standalone-app input):

  • logos-standalone-app is now bundled inside logos-module-builder — UI module flakes no longer need it as a separate input.
  • No logosStandalone parameter is needed in mkLogosQmlModule, mkLogosModule, or mkLogosQmlModule calls.
  • Dependencies (including transitive ones) are automatically resolved from the flake input tree, bundled as LGX packages at build time, and extracted into the modules directory at runtime.
  • The standalone app uses logos_core_load_plugin_with_dependencies() which resolves the full transitive dependency graph via metadata.json files.

Example C++ UI module flake.nix (view module — C++ backend + QML view):

{
  description = "My UI module";
  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder";
    calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
  };
  outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosQmlModule {
      src = ./.;
      configFile = ./metadata.json;
      flakeInputs = inputs;
    };
}

Example QML UI module flake.nix:

{
  description = "My QML UI module";
  inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder";
    calc_module.url = "github:logos-co/logos-tutorial?dir=logos-calc-module";
  };
  outputs = inputs@{ logos-module-builder, ... }:
    logos-module-builder.lib.mkLogosQmlModule {
      src = ./.;
      configFile = ./metadata.json;
      flakeInputs = inputs;
    };
}

If the module doesn't appear, check:

  1. The modules/ directory contains a subdirectory for your module with manifest.json and the plugin binary
  2. The variant in the manifest matches your platform (e.g., darwin-arm64-dev for dev builds on Apple Silicon)
  3. Use lm to verify the plugin binary is a valid Qt plugin: ./lm/bin/lm ./modules/my_module/my_module_plugin.dylib

Capability module not found

logos-basecamp requires the capability module to be installed. It is bundled with basecamp and installed on first launch. If you see errors about it:

  1. Check that the modules/ and plugins/ directories exist next to bin/ and lib/ in the basecamp build output
  2. Check that the capability module was extracted to the modules directory
  3. Verify the LGX variant type matches your basecamp build (dev variant for dev build, portable for portable build)

LGX variant mismatch

If a module installs but fails to load, the variant type may not match:

  • Dev build of logos-basecamp needs dev LGX variants (darwin-arm64-dev)
  • Portable build needs portable variants (darwin-arm64)
  • Use nix build .#lgx and nix build .#lgx-portable to produce each variant separately, or nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib for a single package with both variants

Cross-platform builds

Build on each target platform separately to create .lgx packages:

# On each platform, the built-in derivation produces the correct variant automatically:
nix build .#lgx-portable

# Or using nix bundle for dual variant:
nix bundle --bundler github:logos-co/nix-bundle-lgx#dual .#lib

# Then merge platform-specific .lgx files into one:
./lgx/bin/lgx merge my_module-linux.lgx my_module-macos.lgx -o my_module.lgx

Windows is the exception: it is cross-built, never built natively. There is no Nix daemon for Windows, so "build on the target platform" does not apply. Build the x86_64-windows target from a Linux machine instead:

nix build .#packages.x86_64-windows.lgx-portable

Two consequences worth knowing before you try it:

  • The resulting package declares windows-x86_64. Because lgpm on the builder is a Linux binary, laying that package out on the builder needs the explicit --platform windows-x86_64 opt-out described in 5.1.
  • A Windows developer runs the same cross-build inside WSL2 and copies the artifacts out to a Windows path — the toolchain is Linux either way.