This tutorial walks you through wrapping a C shared library (`.so` on Linux, `.dylib` on macOS) as a Logos module. By the end, you will have a module that compiles, loads, and responds to method calls via `logoscore`.
what_you_build:"A `calc_module` that wraps a tiny C calculator library (`libcalc`), exposing arithmetic functions to the Logos platform."
what_you_learn:
- How a Logos module wraps a C library
- The role of each file in the module project
- How to build, inspect, and test your module
- How `logoscore` discovers, loads, and calls your module
prerequisites:
- |
**Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:
- "**A C compiler** (gcc or clang) for building the C library. Only needed if you're building the `.so`/`.dylib` yourself rather than using a pre-built library."
Before writing any C code, scaffold the Logos module project using the official template. This gives you the correct `flake.nix`, `metadata.json`, directory structure, and build configuration out of the box.
steps:
- title:"Create the project using the module builder template"
This generates the skeleton files (`flake.nix`, `metadata.json`, `CMakeLists.txt`, etc.) pre-configured for the logos-module-builder. You then customize them for your specific library.
> **Note:** The generated `flake.nix` uses an unpinned `logos-module-builder` URL. Replace it with the pinned version shown in the flake.nix step below to ensure reproducible builds.
> **Alternative approach:** You can also create the C library as a separate project, build it there, then copy the resulting `.so`/`.dylib` and header files into the module's `lib/` directory. This can be cleaner for larger libraries with their own build systems.
# ── Step 2: Write the C library ────────────────────────────────────────────
You should see each symbol marked with `T` (text/code section). Addresses will vary:
```
0000000000001139 T calc_add
0000000000001179 T calc_factorial
00000000000011f5 T calc_fibonacci
0000000000001159 T calc_multiply
0000000000001299 T calc_version
```
> **Wrapping a third-party library?** If you're wrapping an existing library (e.g., from a system package or a GitHub repo), you don't need to write the C code — just place the pre-built `.so`/`.dylib` and its header file in `lib/`.
# ── Step 3: Configure the Logos Module ──────────────────────────────────────
The template generated skeleton files with placeholder names (`external_lib`, `example_lib`). Now rename and customize them for your library. You need to edit **every generated file**.
After editing, your project should look like this:
> **Edit:** Change `name`, `description`, `main`, `nix.external_libraries[].name`, and `nix.cmake.extra_include_dirs` to match your module and library.
This is the single source of truth for your module. It is embedded into the plugin binary by Qt's `Q_PLUGIN_METADATA` macro (for runtime metadata), 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.
file:
path:metadata.json
language:json
content:|
{
"name": "calc_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "Calculator module wrapping libcalc C library",
| `name` | Module name — must be a valid C identifier (used in filenames, method calls) |
| `nix.external_libraries` | Declares C/C++ libraries vendored in the repo. Each entry has a `name` (used for the Nix derivation and CMake target) and `vendor_path` (directory containing the source). The build system compiles the library and makes it available as a CMake target |
| `nix.cmake.extra_include_dirs` | Added to the CMake include path so your C++ code can `#include "libcalc.h"` |
- title:"`CMakeLists.txt` — Build File"
text:|
> **Edit:** Change `project()` name, `NAME`, `SOURCES` filenames, and `EXTERNAL_LIBS` to match your module and library.
file:
path:CMakeLists.txt
language:cmake
content:|
cmake_minimum_required(VERSION 3.14)
project(CalcModulePlugin LANGUAGES CXX)
# Include the Logos Module CMake helper (provided by logos-module-builder)
message(FATAL_ERROR "LogosModule.cmake not found")
endif()
# Define the module with its external library dependency
logos_module(
NAME calc_module
SOURCES
src/calc_module_interface.h
src/calc_module_plugin.h
src/calc_module_plugin.cpp
EXTERNAL_LIBS
calc
)
post_text:|
The template generates this with default names (e.g., `external_lib`). You **must** update:
- **`project()`** — rename to match your module (e.g., `CalcModulePlugin`)
- **`NAME`** — your module name (must match `name` in `metadata.json`, e.g., `calc_module`)
- **`SOURCES`** — your renamed source files
- **`EXTERNAL_LIBS`** — names of external libraries to link (must match `nix.external_libraries[].name` in `metadata.json`)
The `if/elseif/else` block above it is boilerplate — don't change it.
> **Common mistake:** If `NAME` doesn't match `name` in `metadata.json`, the build will succeed but the install phase will fail because it looks for `<name>_plugin.dylib` based on `metadata.json`.
**How `EXTERNAL_LIBS calc` works:** The `logos_module()` CMake function searches `lib/` for `libcalc.so` (Linux) or `libcalc.dylib` (macOS), links it to your plugin, and sets up RPATH so the library is found at runtime.
- title:"`flake.nix` — Nix Build Config"
text:|
Change `description`. Add flake inputs here if your module depends on other modules or fetches a library from source.
file:
path:flake.nix
language:nix
content:|
{
description = "Calculator module - wraps libcalc C library for Logos";
That's it — `mkLogosModule` handles all the Nix complexity (fetching Qt, the SDK, the code generator, setting up include paths, etc.). Note that `configFile` points to `metadata.json` (the single source of truth) and `flakeInputs = inputs` passes all flake inputs to the builder so that dependencies declared in `metadata.json` are resolved automatically.
> **Naming flake inputs:** When adding module dependencies, the flake input attribute name **must match** the `name` field in that dependency's `metadata.json`. For example, if you depend on a module whose `metadata.json` has `"name": "waku_module"`, your flake input must be `waku_module.url = "github:logos-co/logos-waku-module"`.
This declares the methods your module exposes. It inherits from `PluginInterface` (provided by the Logos C++ SDK). Every method you want callable by other modules must be `Q_INVOKABLE` and `virtual`.
file:
path:src/calc_module_interface.h
language:cpp
content:|
#ifndef CALC_MODULE_INTERFACE_H
#define CALC_MODULE_INTERFACE_H
#include <QObject>
#include <QString>
#include "interface.h"
class CalcModuleInterface : public PluginInterface
{
public:
virtual ~CalcModuleInterface() = default;
Q_INVOKABLE virtual int add(int a, int b) = 0;
Q_INVOKABLE virtual int multiply(int a, int b) = 0;
- `Q_PLUGIN_METADATA(IID ... FILE "metadata.json")` — embeds the metadata into the binary
- `Q_INTERFACES(CalcModuleInterface PluginInterface)` — registers both interfaces with Qt's plugin system
- `initLogos` must be `Q_INVOKABLE` but **not** `override` — the base class `PluginInterface` does not declare it as virtual; the Logos host calls it reflectively via `QMetaObject::invokeMethod`
- `eventResponse` signal is required for event forwarding between modules. Emit it to push data to subscribers (e.g., QML UIs listening via `logos.onModuleEvent()`)
- `name()` must return the same string as the `name` field in `metadata.json`
- **No `m_logosAPI` member variable** — the `LogosAPI*` pointer is stored in the global `logosAPI` variable defined in `liblogos`, not in a class member. See the `initLogos` implementation below.
Before staging files, create a `.gitignore` to exclude build artifacts:
file:
path:.gitignore
language:text
content:|
# Nix build output
result
result-*
# CMake build directory
build/
- text:"Then initialise the repo:"
run:"git init"
- run:"git add -A"
- run:"nix flake update"
- run:"git add flake.lock"
- title:"Build the plugin library"
text:|
Build just the plugin library (`.so` / `.dylib`):
run:"nix build '.#lib'"
post_text:|
> **Quoting matters:** Use `'.#lib'` (with quotes) rather than bare `nix build .#lib`. Some shells (especially zsh) may interpret the `#` as a comment character.
The first build takes a while (5–15 minutes) as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching.
`logoscore` expects modules in subdirectories, each with a `manifest.json`. Rather than copying files and writing the manifest manually, use the Nix derivation to create an LGX package and install it with the package manager:
> For inline (legacy) mode and other logoscore options, see the [Developer Guide -- Running with logoscore](logos-developer-guide.md#51-running-with-logoscore).
**What happens under the hood:**
1. `logoscore` scans `./modules/` for subdirectories containing `manifest.json`
2. It finds `calc_module` and extracts metadata from the plugin binary
3. It spawns a `logos_host` process that loads `calc_module_plugin.so`
4. `logos_host` calls `initLogos()` on the plugin, providing a `LogosAPI*` for inter-module communication
5. The call command is parsed: module name `calc_module`, method `add`, args `[3, 5]`
6. `logoscore` sends the call to `logos_host` via Qt Remote Objects (IPC)
7. `logos_host` invokes `CalcModulePlugin::add(3, 5)` which calls `calc_add(3, 5)` from libcalc
8. The result is returned via IPC to `logoscore`
You'll see debug output like:
```
Debug: Found plugin: "./modules/calc_module/calc_module_plugin.so"
Debug: Plugin Metadata:
Debug: - Name: "calc_module"
Debug: - Version: "1.0.0"
Debug: - Description: "Calculator module wrapping libcalc C library"
Debug: Loading plugin: "calc_module" in separate process
Debug: Executing call: "calc_module" . "add" with 2 params
Method call successful. Result: ...
```
# ── Package for Distribution (prose only) ──────────────────────────────────
- title:"Package for Distribution (Optional)"
text:|
The LGX package created in Step 5.2 is a **local** package — its libraries still reference `/nix/store` paths, so it only works on the machine that built it. To create a **portable** package that can be distributed to other machines:
```bash
nix build '.#lgx-portable'
```
Portable LGX packages are fully self-contained with no `/nix/store` references at runtime. These are the packages used by the Logos App Package Manager UI and published to [logos-modules](https://github.com/logos-co/logos-modules) releases.
To create both dev and portable variants (the dev variant works with local `nix build` of basecamp; the portable variant works with standalone basecamp builds), use `--out-link` to avoid overwriting the `result` symlink:
> For more bundling options (standalone bundler syntax, cross-platform packaging), see the [Developer Guide — Bundling with nix-bundle-lgx](logos-developer-guide.md#32-bundling-with-nix-bundle-lgx).
# ── Advanced: Wrapping a Library from a Flake Input (prose only) ──────────
- title:"Advanced: Wrapping a Library from a Flake Input"
text:|
Instead of pre-building the library and placing it in `lib/`, you can have Nix fetch and build it from source. This is useful for libraries hosted on GitHub.
### flake.nix with external library input
```nix
{
description = "Module wrapping libfoo from GitHub";
**Key difference:** The `externalLibInputs` key in flake.nix (`foo`) must match the `name` field in `nix.external_libraries` (`foo`). The builder will:
1. Clone the source from the flake input
2. Run `build_command` (`make shared`)
3. Search for output files matching `output_pattern`
4. Copy the resulting `.so`/`.dylib` and headers to `lib/`
5. Proceed with the normal module build
### For Go libraries
If the external library is written in Go with C bindings (`cgo`), set `go_build: true` in the `nix.external_libraries` entry within `metadata.json`:
```json
{
"nix": {
"external_libraries": [
{
"name": "mygolib",
"flake_input": "github:example/mygolib",
"go_build": true,
"output_pattern": "libmygolib.*"
}
]
}
}
```
Setting `go_build: true` enables the Go toolchain and sets `CGO_ENABLED=1`.
# ── Real-World Example (prose only) ──────────────────────────────────────
- title:"Real-World Example: logos-libp2p-module"
text:|
The [logos-libp2p-module](https://github.com/logos-co/logos-libp2p-module) is a production module that wraps the `nim-libp2p` library (compiled to a C shared library). Key files:
- `**flake.nix**` — Uses `externalLibInputs` to fetch the nim-libp2p C bindings from a GitHub flake
- `**metadata.json**` — Declares `nim_libp2p` as an external library with `go_build: false` in the `nix` section
- `**src/plugin.cpp**` — Wraps ~40 C functions (`libp2p_new`, `libp2p_start`, `libp2p_connect`, `libp2p_dial`, `libp2p_gossipsub_subscribe`, etc.) as `Q_INVOKABLE` methods
- `**tests/**` — Qt test suite that exercises every wrapped function
It follows the exact same pattern as this tutorial, just at a larger scale.
### `initLogos` marked 'override', but does not override
```
error: 'void MyPlugin::initLogos(LogosAPI*)' marked 'override', but does not override
```
**Fix:** Remove the `override` keyword from `initLogos`. The base `PluginInterface` class does not declare it as virtual. The Logos host calls it reflectively via `QMetaObject::invokeMethod`. Declare it as:
```cpp
Q_INVOKABLE void initLogos(LogosAPI* api); // No override!
```
### Library not found at runtime
```
Cannot load library calc_module_plugin.so: libcalc.so: cannot open shared object file
```
**Fix:** Ensure `libcalc.so` / `libcalc.dylib` is in the same directory as the plugin. The build system sets RPATH to `$ORIGIN` (Linux) / `@loader_path` (macOS) so the plugin looks for libraries in its own directory.
### `initLogos` stores API pointer in wrong variable
If inter-module calls or API features silently fail, check that `initLogos` assigns to the **global** `logosAPI` variable (defined in the Logos SDK / liblogos), not to a class member like `m_logosAPI`:
```cpp
// CORRECT — uses the global variable from liblogos
void MyPlugin::initLogos(LogosAPI* api)
{
logosAPI = api;
}
// WRONG — stores in a local member, API calls won't work
void MyPlugin::initLogos(LogosAPI* api)
{
m_logosAPI = api;
}
```
### Plugin not discovered by logoscore
**Check:**
1. The module is in a **subdirectory** of the modules dir (e.g., `modules/calc_module/`)
2. The subdirectory contains a `manifest.json` with a valid `main` object
3. The platform key in `main` matches your OS/arch (e.g., `linux-aarch64`, `darwin-arm64`)
### `nix build .#lib` does nothing or fails silently
Some shells (notably zsh) treat `#` as a comment character. Always quote the flake reference:
```bash
# Correct
nix build '.#lib'
# May fail in zsh
nix build .#lib
```
### First build is slow
The first `nix build` downloads Qt 6, the Logos C++ SDK, the code generator, and other dependencies. This is a one-time cost — subsequent builds use the Nix cache and are fast (usually under 30 seconds).
### Symbol not found errors
If you get "undefined symbol" errors for your C library functions:
1. Verify the `.so`/`.dylib` is in `lib/` before building
2. Verify the header has `extern "C"` guards
3. Check the symbols are exported: `nm -D lib/libcalc.so | grep calc`