# Tutorial: Wrapping a C Library as a Logos Module
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'll build:** A `calc_module` that wraps a tiny C calculator library (`libcalc`), exposing arithmetic functions to the Logos platform. You write a single **plain C++ class** — no Qt, no plugin boilerplate — and the build system generates the Qt plugin around it.
- **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.
This generates skeleton files (`flake.nix`, `metadata.json`, `CMakeLists.txt`, and a `src/` directory) pre-configured for the logos-module-builder. You then customize them for your specific library.
> **Heads up — the template is the older Qt-plugin style.** As of this writing, `nix flake init` scaffolds a hand-written Qt plugin (`*_interface.h` + `*_plugin.h` + `*_plugin.cpp`). This tutorial uses the newer and simpler **pure-C++ pattern** instead: you write one plain `*_impl.h` / `*_impl.cpp` class with no Qt in it, set `"interface": "universal"` in `metadata.json`, and the build generates the Qt plugin wrapper for you. So in the steps below we **replace** the template's `src/` files entirely. We still use `nix flake init` to get the `flake.nix` / `CMakeLists.txt` skeleton and directory layout.
> **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.
The `with-external-lib` template ships an example Qt plugin (`external_lib_*`). Delete those files — this tutorial supplies its own pure-C++ `src/` files:
> **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/`.
Now write the files that turn your C library into a Logos module. With the **pure-C++ (`universal`) pattern** you only hand-write a single C++ class — `metadata.json`, `CMakeLists.txt`, and `flake.nix` tell the build system the rest, and `logos-cpp-generator` synthesizes the Qt plugin wrapper.
> **Where did the `*_interface.h` / `*_plugin.h` / `*_plugin.cpp` files go?** The older pattern made you hand-write a Qt `QObject` plugin, an abstract interface, and the `Q_INVOKABLE` / `Q_PLUGIN_METADATA` boilerplate. With `interface: universal`, the generator derives all of that from your plain class — so those three files no longer exist in your source tree. They are emitted into `generated_code/` at build time.
This is the single source of truth for your module. It is embedded into the generated plugin binary (for runtime metadata via `lm`), read by `logos-module-builder` to configure the Nix build, used by CMake to resolve and link external libraries (via the `nix` section), and used by `nix-bundle-lgx` to generate the LGX manifest.
| `name` | Module name — must be a valid C identifier (used in filenames, method calls) |
| `main` | The generated plugin's name, `<name>_plugin`. You don't write this file; the builder produces `calc_module_plugin.so` / `.dylib` |
| `interface` | `"universal"` selects the pure-C++ pattern. The builder runs `logos-cpp-generator --from-header` over `src/calc_module_impl.h` and emits the Qt plugin glue, so you never touch Qt directly |
| `nix.external_libraries` | Declares C/C++ libraries vendored in the repo. Each entry has a `name` (the CMake target) and `vendor_path` (directory with the source/binary). The build compiles the library and links it into the plugin |
| `nix.cmake.extra_include_dirs` | Added to the include path so your C++ code can `#include "lib/libcalc.h"` |
> **Edit:** Set `project()` name, `NAME`, the `SOURCES` (your two impl files), and `EXTERNAL_LIBS`.
For a universal module you list only your plain C++ source files. The generated glue (`generated_code/*.cpp`) is picked up automatically by `LogosModule.cmake` — you don't reference it here.
> **Common mistake:** If `NAME` doesn't match `name` in `metadata.json`, the build may succeed but the install phase fails because it looks for `<name>_plugin.so`/`.dylib` based on `metadata.json`.
**How `EXTERNAL_LIBS calc` works:**`logos_module()` searches `lib/` for `libcalc.so` (Linux) / `libcalc.dylib` (macOS), links it to your plugin, and sets up RPATH so the library is found at runtime.
That's it — `mkLogosModule` handles all the Nix complexity (fetching Qt, the SDK, the code generator, running `logos-cpp-generator --from-header`, setting up include paths, etc.). `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 is the **only interface you write**, and it's plain C++ — no `QObject`, no `Q_INVOKABLE`, no plugin macros, no Qt headers at all. Every `public` method becomes a method other modules (and `logoscore`) can call. The code generator parses this header as text to derive the wire signatures, so keep it to the supported types (see the table below).
We also inherit `LogosModuleContext` so the class can emit events (the `logos_events:` block) and, if needed later, call other modules — without ever touching the raw `LogosAPI`.
- **Document methods with `///`.** A doc comment (`///` or `/** … */`) directly above a method becomes its `description` in the module's introspection, surfaced by `lm`, `logoscore module-info`, and Basecamp. Plain `//` comments are ignored, so only intentional docs are exposed — you'll see this in action in Step 5.
- Events are declared in a `logos_events:` section. The token is recognized by the generator before preprocessing; under a normal compile it just expands to `public`.
Each method calls the corresponding C function and converts the result. No Qt types appear anywhere — you work in plain C++ and the generated glue handles the wire conversion.
1. Call the C function (convert `int64_t` → `int` for libcalc's `int` API)
2. Convert the C result to a C++ type if needed (e.g., `const char*` → `std::string`)
3. Return it — the generated glue marshals it onto the wire
Notice what you **didn't** write: no `initLogos`, no `Q_INVOKABLE`, no `name()`/`version()` (read from `metadata.json`), no signal declaration. The generator produces all of it from the header.
> **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.
Build everything (library + generated SDK headers). For a `universal` module this is also where `logos-cpp-generator --from-header` runs over `src/calc_module_impl.h` to produce the Qt plugin glue under `generated_code/` before CMake compiles it:
- **Signatures are Qt-typed** (`int`, `QString`) even though you wrote `int64_t` / `std::string`. That's the generated glue: `lm` reports the wire types the synthesized Qt plugin exposes, so `int64_t add(int64_t, int64_t)` shows up as `add(int,int)`.
- **Each `Description` is your doc comment**, carried through the module's method introspection. Plain `//` comments (like the type-mapping note in the header) are deliberately ignored, so only intentional docs surface; an undocumented method simply omits it.
- **Line breaks are preserved** — a single-line comment renders inline; a multi-line comment (`factorial`, `libVersion`, `libVersionNotify`) keeps its breaks. The same descriptions appear in `logoscore module-info` and Basecamp's Methods list.
`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:
`module-info` lists each method **and event** with its signature and the doc-comment description you wrote — the same docs `lm` showed, here straight from the module's introspection:
> For inline (legacy) mode and other logoscore options, see the [Developer Guide -- Running with logoscore](logos-developer-guide.md#51-running-with-logoscore).
Because your module is a plain C++ class, you can unit-test it **directly** — no Qt, no running host, no IPC. The [Logos Test Framework](https://github.com/logos-co/logos-test-framework) adds two things on top of that: a tiny test runner (`LOGOS_TEST` / `LOGOS_ASSERT_*`) and **link-time mocking of your C library**, so each test can make `calc_add`, `calc_factorial`, … return whatever it wants and assert how your wrapper behaves.
You wire it up by pointing `mkLogosModule` at a `tests/` directory in `flake.nix`, then writing the test files. `nix build .#unit-tests` builds and runs them.
### 7.1 Enable tests in `flake.nix`
Add a `tests` block to the `mkLogosModule` call. `mockCLibs` lists the external libraries to replace with link-time mocks (so tests don't need the real `libcalc`):
```nix
{
description="Calculator module - wraps libcalc C library for Logos";
### 7.2 `tests/CMakeLists.txt` — wire up the test binary
The test harness configures and builds `tests/` as its own CMake project, so it needs a `tests/CMakeLists.txt`. It includes `LogosTest` (provided by the framework) and calls `logos_test()`, listing your impl source, the test sources, and the C-library mock:
```cmake
cmake_minimum_required(VERSION3.14)
project(CalcModuleTestsLANGUAGESCXX)
include(LogosTest)
logos_test(
NAMEcalc_module_tests
MODULE_SOURCES
../src/calc_module_impl.cpp
mocks/calc_module_events_stub.cpp
TEST_SOURCES
main.cpp
test_calc.cpp
MOCK_C_SOURCES
mocks/mock_libcalc.cpp
)
```
- **`MODULE_SOURCES`** — your impl `.cpp` (compiled into the test binary, not the real plugin), plus the events stub explained below
- **`TEST_SOURCES`** — the runner entry point plus your `test_*.cpp` files
- **`MOCK_C_SOURCES`** — the link-time replacement for libcalc, so the real library is never linked
`logos_test()` automatically puts the repo root and `../src` on the include path, so `#include "calc_module_impl.h"` and `#include "lib/libcalc.h"` both resolve.
### 7.3 `tests/mocks/calc_module_events_stub.cpp` — stub the event method
In a normal build, `logos-cpp-generator` emits `calc_module_events.cpp` containing the body of every `logos_events:` method (e.g. `versionReady`). The test harness runs the generator in a reduced mode that does **not** emit that file, so `libVersionNotify()` — which calls `versionReady(...)` — would fail to link. Provide a tiny no-op stub for unit tests:
```cpp
// Stub bodies for the impl's `logos_events:` methods.
// In the real build the codegen generates calc_module_events.cpp with
// bodies that route through LogosModuleContext. The test build skips
// that codegen, so we provide no-op stubs to satisfy the linker.
If you add more events to `logos_events:`, add a matching no-op line here. (A module with no events doesn't need this stub at all.)
### 7.4 Test runner entry point
Create `tests/main.cpp` — one line pulls in the framework's `main()`:
```cpp
#include<logos_test.h>
LOGOS_TEST_MAIN()
```
### 7.5 Mock the C library
When building tests, the real `libcalc` is **not** linked. Instead you provide functions with the same signatures backed by the framework's mock store. Each one records that it was called and returns a value the test set up. Create `tests/mocks/mock_libcalc.cpp`:
```cpp
// Link-time replacement for libcalc. Each function records the call
// and returns whatever the active test configured via mockCFunction().
#include<logos_clib_mock.h>
extern"C"{
#include"lib/libcalc.h"
}
extern"C"intcalc_add(inta,intb){
LOGOS_CMOCK_RECORD("calc_add");
returnLOGOS_CMOCK_RETURN(int,"calc_add");
}
extern"C"intcalc_multiply(inta,intb){
LOGOS_CMOCK_RECORD("calc_multiply");
returnLOGOS_CMOCK_RETURN(int,"calc_multiply");
}
extern"C"intcalc_factorial(intn){
LOGOS_CMOCK_RECORD("calc_factorial");
returnLOGOS_CMOCK_RETURN(int,"calc_factorial");
}
extern"C"intcalc_fibonacci(intn){
LOGOS_CMOCK_RECORD("calc_fibonacci");
returnLOGOS_CMOCK_RETURN(int,"calc_fibonacci");
}
extern"C"constchar*calc_version(void){
LOGOS_CMOCK_RECORD("calc_version");
returnLOGOS_CMOCK_RETURN_STRING("calc_version");
}
```
`LOGOS_CMOCK_RECORD(name)` logs the call; `LOGOS_CMOCK_RETURN(type, name)` / `LOGOS_CMOCK_RETURN_STRING(name)` hand back the value the test set with `mockCFunction(...).returns(...)`.
### 7.6 Write the tests
Create `tests/test_calc.cpp`. Each `LOGOS_TEST` constructs your impl directly, configures the C-function return values, calls a method, and asserts. `LogosTestContext` resets the mock store between tests:
- The tests construct `CalcModuleImpl` like any class — no Qt, no host, no `initLogos`. That's the payoff of the pure-C++ pattern.
-`libVersionNotify()` is safe to call here too: its `versionReady(...)` event resolves to the no-op stub you added, so it won't crash and simply does nothing in the test process.
-`LOGOS_ASSERT_EQ`, `LOGOS_ASSERT`, `LOGOS_ASSERT_TRUE/FALSE`, `LOGOS_ASSERT_NE/GT/GE/LT` are all available from `<logos_test.h>`.
### 7.7 Run the tests
Track the new files (nix only sees git-tracked files), then build and run:
```bash
git add tests/ flake.nix
```
```bash
nix build '.#unit-tests' -L
```
The build compiles your impl (`src/calc_module_impl.cpp`) against the mock library and the test sources, then runs every `LOGOS_TEST`. A passing run ends with a summary line; a failed assertion prints the file/line and fails the build.
> **From the workspace?** You can also run `ws test logos-calc-module` (after `ws sync-graph` picks up the new tests). See the workspace `CLAUDE.md`.
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:
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).
In the impl class you work entirely in std/C++ types — the generated glue handles the Qt/wire side. These are the conversions you write between the C library and your method signatures:
> Use `int64_t` (not `int`) in the public signatures — that's the integer type the generator recognizes. Narrow to the C library's `int` inside the method, as the calc example does.
## Advanced: Wrapping a Library from a Flake Input
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.
**Key difference:** The `externalLibInputs` key in flake.nix (`foo`) must match the `name` field in `nix.external_libraries` (`foo`). The builder will:
Setting `go_build: true` enables the Go toolchain and sets `CGO_ENABLED=1`.
## Real-World Example: logos-libp2p-module
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:
1. Make sure it's in the `public:` section (not `private:`).
2. Use supported types only — notably `int64_t` (not `int`), `std::string` (not `char*` or `QString`), `std::vector<std::string>`, `bool`, `double`, `LogosMap`/`LogosList`, `StdLogosResult`. See the type table in [Step 3](#step-3-configure-the-logos-module).
3. Keep the signature on as few lines as the parser expects — one declaration per method.
### Build error: unknown type / generator can't parse a method
The `--from-header` parser reads your `*_impl.h` as text. Pulling Qt types or unusual templates into a *public method signature* will confuse it. Keep Qt out of the impl header entirely, and move any helper that needs exotic types into the `private:` section or the `.cpp`.
**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.
1. The event must be declared in a `logos_events:` section of the impl header, and your class must inherit `LogosModuleContext`.
2. The event only fires when the module is loaded by a host (logoscore / basecamp). Constructed standalone (unit tests), emission is a safe no-op — that's expected.
3. The subscriber must use the exact event name string, e.g. `logos.onModuleEvent("calc_module", "versionReady")`.
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: