Wrap a C library as a Logos module
Expose functions from a C shared library through a Logos core 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 calc_module that compiles, loads, and responds to method calls via logoscore. You write one plain C++ class — no Qt, no plugin boilerplate — and the build system generates the Qt plugin around it.
For an example used in production, refer to logos-lib2p2-module - a module that wraps the nim-libp2p library (compiled to a C shared library).
You need:
-
OS: Linux (x86_64 or aarch64) or macOS (x86_64 or aarch64). Tested on Ubuntu 22.04+ and recent macOS.
-
RAM: 4 GB minimum, 8 GB recommended.
-
Disk: ~2 GB free for the application + installed modules.
-
Nix with flakes enabled. Install from nixos.org, then enable flakes:
mkdir -p ~/.config/nixecho 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.confVerify:
nix flake --help >/dev/null 2>&1 && echo "Flakes enabled" -
A C compiler (gcc or clang) for building the C library. Only needed if you are building the
.so/.dylibyourself rather than using a pre-built library. -
Basic familiarity with C and C++.
What to expect
- You will write a
calc_modulethat exposes arithmetic functions to Logos using the pure C++ (interface: universal) pattern. - You will build, inspect, and call the module with
lmandlogoscore, seeing yourint64_tmethods appear as Qt-typed signals. - You will unit-test the module directly against a link-time mock of the C library.
Step 1: Scaffold the module project
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.
-
Create the project directory and run the module builder template:
mkdir logos-calc-module && cd logos-calc-module# To wrap an external C librarynix flake init -t github:logos-co/logos-module-builder/0.2.0#with-external-lib# Or for a plain module (no external library):# nix flake init -t github:logos-co/logos-module-builder/0.2.0This generates skeleton files (
flake.nix,metadata.json,CMakeLists.txt, and asrc/directory) pre-configured for thelogos-module-builder. You then customise them for your specific library.infoAs the time of writing,
nix flake initscaffolds a hand-written Qt plugin (*_interface.h+*_plugin.h+*_plugin.cpp). This tutorial uses the newer pure-C++ pattern instead: you write one plain*_impl.h/*_impl.cppclass with no Qt, set"interface": "universal"inmetadata.json, and the build generates the Qt plugin wrapper for you. The steps below replace the template'ssrc/files entirely. Thenix flake initcommand is still used to get theflake.nix/CMakeLists.txtskeleton and directory layout. -
Remove the template's example sources. The
with-external-libtemplate ships an example Qt plugin (external_lib_*). Delete those files — this tutorial supplies its own pure-C++src/files:rm -f src/external_lib_interface.h src/external_lib_plugin.h src/external_lib_plugin.cpp
Step 2: Write the C library
Create the C library that your module will wrap. Place the header and implementation in the lib/ directory.
-
Create the
libdirectory:mkdir -p lib -
Create
lib/libcalc.h:#ifndef LIBCALC_H#define LIBCALC_H#ifdef __cplusplusextern "C" {#endif/** Add two integers. */int calc_add(int a, int b);/** Multiply two integers. */int calc_multiply(int a, int b);/** Compute factorial of n (n must be >= 0). Returns -1 on error. */int calc_factorial(int n);/** Compute the nth Fibonacci number (n must be >= 0). Returns -1 on error. */int calc_fibonacci(int n);/** Return the library version string. Caller must NOT free. */const char* calc_version(void);#ifdef __cplusplus}#endif#endif /* LIBCALC_H */The
extern "C"block is essential — it prevents C++ name mangling so the Logos module can find the symbols. -
Create
lib/libcalc.c:#include "libcalc.h"int calc_add(int a, int b){return a + b;}int calc_multiply(int a, int b){return a * b;}int calc_factorial(int n){if (n < 0) return -1;if (n <= 1) return 1;int result = 1;for (int i = 2; i <= n; i++) {result *= i;}return result;}int calc_fibonacci(int n){if (n < 0) return -1;if (n == 0) return 0;if (n == 1) return 1;int a = 0, b = 1;for (int i = 2; i <= n; i++) {int tmp = a + b;a = b;b = tmp;}return b;}const char* calc_version(void){return "1.0.0";} -
Build the shared library:
cd lib# Linuxgcc -shared -fPIC -o libcalc.so libcalc.c# macOS# gcc -shared -fPIC -o libcalc.dylib libcalc.ccd .. -
Verify the symbols are exported:
# Linuxnm -D lib/libcalc.so | grep calc# macOS# nm -gU lib/libcalc.dylib | grep calcEach symbol should be marked with
T(text/code section). Addresses will vary:0000000000001139 T calc_add (or _calc_add on macOS)0000000000001179 T calc_factorial00000000000011f5 T calc_fibonacci0000000000001159 T calc_multiply0000000000001299 T calc_versioninfoIf you are wrapping an existing library (for example, from a system package or a GitHub repo), you don't need to write the C code — just place the pre-built
.so/.dyliband its header file inlib/.
Step 3: Configure the Logos module
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 synthesises the Qt plugin wrapper.
After this step, your project will look like this:
logos-calc-module/
├── flake.nix # Nix build configuration (~10 lines)
├── metadata.json # Module metadata, build settings, and runtime config
├── CMakeLists.txt # CMake build file
├── lib/
│ ├── libcalc.h # C library header
│ └── libcalc.c # C library source (compiled by CMake)
└── src/
├── calc_module_impl.h # Plain C++ class
└── calc_module_impl.cpp # Implementation (wrapping logic)
- Create
metadata.json. Setname,description,main, add"interface": "universal", and declare your library undernix.external_libraries. This file is the single source of truth: it is embedded into the generated plugin binary, read bylogos-module-builderto configure the Nix build, used by CMake to resolve and link external libraries, and used bynix-bundle-lgxto generate the LGX manifest.
To fetch and build external libraries from source, add "build_command": "make shared" and "output_pattern": "build/<libname>" to "external_libraries".
{
"name": "calc_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "Calculator module wrapping libcalc C library",
"main": "calc_module_plugin",
"interface": "universal",
"dependencies": [],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [
{
"name": "calc",
"vendor_path": "lib"
}
],
"cmake": {
"find_packages": [],
"extra_sources": [],
"extra_include_dirs": ["lib"],
"extra_link_libraries": []
}
}
}
Key fields explained:
| Field | What it does |
|---|---|
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, 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 use #include "lib/libcalc.h" |
-
Create
CMakeLists.txt. Setproject()name,NAME, theSOURCES(your two implementation files), andEXTERNAL_LIBS. For a universal module you list only your plain C++ source files; the generated glue (generated_code/*.cpp) is picked up automatically byLogosModule.cmake.cmake_minimum_required(VERSION 3.14)project(CalcModulePlugin LANGUAGES CXX)# Include the Logos Module CMake helper (provided by logos-module-builder)if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")include(cmake/LogosModule.cmake)else()message(FATAL_ERROR "LogosModule.cmake not found")endif()# Define the module with its external library dependency.# Because metadata.json sets `interface: universal`, the builder runs# logos-cpp-generator over src/calc_module_impl.h before configuring,# and LogosModule.cmake compiles the generated glue automatically.logos_module(NAME calc_moduleSOURCESsrc/calc_module_impl.hsrc/calc_module_impl.cppEXTERNAL_LIBScalc)Keep these three fields in sync with
metadata.json:NAME— your module name (must matchnameinmetadata.json, e.g.,calc_module)SOURCES— your implementation files (src/calc_module_impl.h,src/calc_module_impl.cpp)EXTERNAL_LIBS— external libraries to link (must matchnix.external_libraries[].nameinmetadata.json)
The
if/elseif/elseblock is boilerplate — don't change it. -
Create
flake.nixand changedescription. External libraries can be added ininputs, allowingnixto fetch and build them from source.{description = "Calculator module - wraps libcalc C library for Logos";inputs = {logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.0";# Fetch the library source (non-flake)# libfoo-src = {# url = "github:example/libfoo";# flake = false;# };};outputs = inputs@{ logos-module-builder, ... }:logos-module-builder.lib.mkLogosModule {src = ./.;configFile = ./metadata.json;flakeInputs = inputs;};}infoWhen adding module dependencies, the flake input attribute name must match the
namefield in that dependency'smetadata.json. For example, if you depend on a module whosemetadata.jsonhas"name": "waku_module", your flake input must bewaku_module.url = "github:logos-co/logos-waku-module". -
Create
src/calc_module_impl.h. This is the only interface you need to write. It allows everypublicmethod becomes callable by other modules and bylogoscore. The code generator parses this header as text to derive the wire signatures, so keep it to the supported types (see the table below). InheritingLogosModuleContextlets the class emit events and call other modules without touching the rawLogosAPI.#pragma once#include <cstdint>#include <string>#include <logos_module_context.h> // LogosModuleContext base + `logos_events:`// Include the C library header (extern "C" already in the header).extern "C" {#include "lib/libcalc.h"}class CalcModuleImpl : public LogosModuleContext {public:CalcModuleImpl() = default;~CalcModuleImpl() = default;// ── Public API — every method here is callable over IPC ──────────// The generator maps C++ types onto the wire automatically:// int64_t ↔ int std::string ↔ QString bool ↔ boolint64_t add(int64_t a, int64_t b);int64_t multiply(int64_t a, int64_t b);int64_t factorial(int64_t n);int64_t fibonacci(int64_t n);std::string libVersion();// Fire-and-forget: looks up the version, then emits it as an event// instead of returning it. Used by the QML tutorial (Part 2).void libVersionNotify();// ── Events ───────────────────────────────────────────────────────// Declared like Qt signals. The generator emits the body (in// calc_module_events.cpp) that routes the typed args to subscribers// via the host's `eventResponse` mechanism. QML subscribes with// logos.onModuleEvent("calc_module", "versionReady").logos_events:void versionReady(const std::string& version);};Supported parameter and return types:
C++ type Qt voidvoidboolboolint64_tintuint64_tuintdoubledoublestd::stringQStringstd::vector<std::string>QStringListstd::vector<uint8_t>QByteArrayLogosMap/LogosListQVariantMap/QVariantList(from<logos_json.h>)StdLogosResultLogosResult(from<logos_result.h>) —{ success, value, error } -
Create
src/calc_module_impl.cpp. 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 conversion.#include "calc_module_impl.h"int64_t CalcModuleImpl::add(int64_t a, int64_t b){return calc_add(static_cast<int>(a), static_cast<int>(b));}int64_t CalcModuleImpl::multiply(int64_t a, int64_t b){return calc_multiply(static_cast<int>(a), static_cast<int>(b));}int64_t CalcModuleImpl::factorial(int64_t n){return calc_factorial(static_cast<int>(n));}int64_t CalcModuleImpl::fibonacci(int64_t n){return calc_fibonacci(static_cast<int>(n));}std::string CalcModuleImpl::libVersion(){return std::string(calc_version());}void CalcModuleImpl::libVersionNotify(){// Emit the event declared in `logos_events:`. When the module is// loaded by a host, this reaches every subscriber. When the class// is constructed outside a host (e.g. in unit tests), it is a// safe no-op.versionReady(std::string(calc_version()));}The wrapping pattern is always the same: call the C function (converting
int64_t→intfor libcalc'sintAPI), convert the C result to a C++ type if needed (for example,const char*→std::string), and return it.
Step 4: Build the module
-
Initialise the Git repository. Nix flakes require a git repository. First create a
.gitignoreto exclude build artifacts:# Nix build outputresultresult-*# CMake build directorybuild/Then initialise the repo and stage all files:
git initgit add -Anix flake updategit add flake.lock -
Build just the plugin library (
.so/.dylib):nix build '.#lib'infoThe first build takes 5–15 minutes as Nix downloads Qt, the Logos SDK, and other dependencies. Subsequent builds are fast due to caching.
-
Build everything - both the library and generated SDK headers. For a
universalmodule this is also wherelogos-cpp-generator --from-headerruns oversrc/calc_module_impl.hto produce the Qt plugin glue undergenerated_code/before CMake compiles it:nix build -
Inspect the output:
ls -la result/lib/You should see two files (extensions depend on your platform):
# Linuxcalc_module_plugin.so # Your Logos module pluginlibcalc.so # The C library (copied alongside)# macOScalc_module_plugin.dyliblibcalc.dylibBoth library files are placed together so the plugin can find the C library at runtime via RPATH.
Step 5: Inspect the module
Use the lm CLI tool (from logos-module) to inspect the compiled module binary.
-
Build the
lmtool:nix build 'github:logos-co/logos-module/0.2.0#lm' --out-link ./lm -
View metadata:
# Linux./lm/bin/lm metadata result/lib/calc_module_plugin.so# macOS./lm/bin/lm metadata result/lib/calc_module_plugin.dylibExpected output:
Plugin Metadata:================Name: calc_moduleVersion: 1.0.0Description: Calculator module wrapping libcalc C libraryAuthor:Type: coreDependencies: (none) -
List methods:
# Linux./lm/bin/lm methods result/lib/calc_module_plugin.so# macOS./lm/bin/lm methods result/lib/calc_module_plugin.dylib# Add --json for scripting and CI# Linux./lm/bin/lm methods result/lib/calc_module_plugin.so --json# macOS./lm/bin/lm methods result/lib/calc_module_plugin.dylib --jsonExpected output:
Plugin Methods:===============int add(int a, int b)Signature: add(int,int)Invokable: yes...Expected output with
--json:[{"isInvokable": true,"name": "add","parameters": [{ "name": "a", "type": "int" },{ "name": "b", "type": "int" }],"returnType": "int","signature": "add(int,int)"},...]
Step 6: Test with logoscore
-
Build logoscore:
nix build 'github:logos-co/logos-logoscore-cli/0.2.0' --out-link ./logos -
Set up the modules directory.
logoscoreexpects modules in subdirectories, each with amanifest.json. Use the Nix derivation to create an LGX package and install it with the package manager:nix build '.#lgx'nix build 'github:logos-co/logos-package-manager/0.2.0#cli' --out-link ./pmmkdir -p modules./pm/bin/lgpm --modules-dir ./modules install --file result/*.lgxThis extracts the plugin, external libraries, and manifest into the correct directory structure:
modules/calc_module/├── calc_module_plugin.dylib # (or .so on Linux)├── libcalc.dylib # (or .so on Linux)├── manifest.json # Auto-generated by lgx└── variant # Platform variant identifier -
Start the daemon and call methods:
./logos/bin/logoscore -D -m ./modules &sleep 3./logos/bin/logoscore load-module calc_module./logos/bin/logoscore call calc_module add 3 5./logos/bin/logoscore call calc_module factorial 5./logos/bin/logoscore call calc_module fibonacci 10./logos/bin/logoscore call calc_module libVersion./logos/bin/logoscore stop
Step 7: Unit-test the module
Because your module is a plain C++ class, you can unit-test it directly. The Logos Test Framework adds a tiny test runner (LOGOS_TEST and LOGOS_ASSERT_*) and link-time mocking of your C library, so each test can make functions return whatever you want and allows you to assert how your wrapper should behave.
-
Enable tests in
flake.nix. Add atestsblock to themkLogosModulecall.mockCLibslists the external libraries to replace with link-time mocks:{description = "Calculator module - wraps libcalc C library for Logos";inputs = {logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.0";};outputs = inputs@{ logos-module-builder, ... }:logos-module-builder.lib.mkLogosModule {src = ./.;configFile = ./metadata.json;flakeInputs = inputs;tests = {dir = ./tests;mockCLibs = [ "calc" ];};};} -
Create
tests/CMakeLists.txt. The test harness configures and buildstests/as its own CMake project. It includesLogosTest(provided by the framework) and callslogos_test(), listing your impl source (MODULE_SOURCES), the test sources (TEST_SOURCES), and the C-library mock (MOCK_C_SOURCES):cmake_minimum_required(VERSION 3.14)project(CalcModuleTests LANGUAGES CXX)include(LogosTest)logos_test(NAME calc_module_testsMODULE_SOURCES../src/calc_module_impl.cppmocks/calc_module_events_stub.cppTEST_SOURCESmain.cpptest_calc.cppMOCK_C_SOURCESmocks/mock_libcalc.cpp)logos_test()automatically puts the repo root and../srcon the include path, so#include "calc_module_impl.h"and#include "lib/libcalc.h"both resolve. -
Create
tests/mocks/calc_module_events_stub.cpp. In a normal build,logos-cpp-generatoremitscalc_module_events.cppcontaining the body of everylogos_events:method. The test harness runs the generator in a reduced mode that does not emit that file, solibVersionNotify()would fail to link. Provide a no-op stub:// 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.#include "calc_module_impl.h"void CalcModuleImpl::versionReady(const std::string&) {}If you add more events to
logos_events:, add a matching no-op line here. A module with no events does not need this stub. -
Create
tests/main.cpp:#include <logos_test.h>LOGOS_TEST_MAIN() -
Create
tests/mocks/mock_libcalc.cpp. When building tests, the reallibcalcis not linked. Instead you provide functions with the same signatures backed by the framework's mock store:// 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" int calc_add(int a, int b) {LOGOS_CMOCK_RECORD("calc_add");return LOGOS_CMOCK_RETURN(int, "calc_add");}extern "C" int calc_multiply(int a, int b) {LOGOS_CMOCK_RECORD("calc_multiply");return LOGOS_CMOCK_RETURN(int, "calc_multiply");}extern "C" int calc_factorial(int n) {LOGOS_CMOCK_RECORD("calc_factorial");return LOGOS_CMOCK_RETURN(int, "calc_factorial");}extern "C" int calc_fibonacci(int n) {LOGOS_CMOCK_RECORD("calc_fibonacci");return LOGOS_CMOCK_RETURN(int, "calc_fibonacci");}extern "C" const char* calc_version(void) {LOGOS_CMOCK_RECORD("calc_version");return LOGOS_CMOCK_RETURN_STRING("calc_version");} -
Create
tests/test_calc.cpp. EachLOGOS_TESTconstructs your impl directly, configures the C-function return values, calls a method, and asserts.LogosTestContextresets the mock store between tests:#include <logos_test.h>#include "calc_module_impl.h"LOGOS_TEST(add_forwards_to_calc_add) {auto t = LogosTestContext("calc_module");t.mockCFunction("calc_add").returns(8);CalcModuleImpl calc;LOGOS_ASSERT_EQ(calc.add(3, 5), 8);LOGOS_ASSERT(t.cFunctionCalled("calc_add"));}LOGOS_TEST(multiply_forwards_to_calc_multiply) {auto t = LogosTestContext("calc_module");t.mockCFunction("calc_multiply").returns(42);CalcModuleImpl calc;LOGOS_ASSERT_EQ(calc.multiply(6, 7), 42);LOGOS_ASSERT(t.cFunctionCalled("calc_multiply"));}LOGOS_TEST(factorial_returns_mocked_value) {auto t = LogosTestContext("calc_module");t.mockCFunction("calc_factorial").returns(120);CalcModuleImpl calc;LOGOS_ASSERT_EQ(calc.factorial(5), 120);}LOGOS_TEST(libVersion_converts_cstring_to_string) {auto t = LogosTestContext("calc_module");t.mockCFunction("calc_version").returns("1.0.0");CalcModuleImpl calc;LOGOS_ASSERT_EQ(calc.libVersion(), std::string("1.0.0"));}LOGOS_ASSERT_EQ,LOGOS_ASSERT,LOGOS_ASSERT_TRUE/FALSE, andLOGOS_ASSERT_NE/GT/GE/LTare all available from<logos_test.h>. -
Track the new files (Nix only sees git-tracked files), then build and run:
git add tests/ flake.nixnix build '.#unit-tests' -LThe build compiles your impl 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.
Troubleshooting Logos module wrapping
A method doesn't appear in lm or can't be called
The generator only exposes public methods whose parameter and return types it recognises. Check that the method is in the public: section, that all types are supported (notably int64_t not int, std::string not char* or QString), and that each signature is on one line.
Build error: unknown type or generator can't parse a method
The --from-header parser reads *_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 helpers that need exotic types into the private: section or the .cpp.
"Cannot load library"
Ensure libcalc.so / libcalc.dylib is in the same directory as the plugin. The build system sets RPATH to $ORIGIN (Linux) or @loader_path (macOS) so the plugin looks for libraries in its own directory.
Events never reach subscribers
Check that the event is declared in a logos_events: section and that the class inherits LogosModuleContext. Events only fire when the module is loaded by a host; constructed standalone (for example in unit tests), emission is a safe no-op. The subscriber must use the exact event name string, for example logos.onModuleEvent("calc_module", "versionReady").
Plugin not discovered by logoscore
Verify that the module is in a subdirectory of the modules dir (for example modules/calc_module/), that the subdirectory contains a manifest.json with a valid main object, and that the platform key in main matches your OS/arch (for example linux-aarch64, darwin-arm64).
nix build .#lib does nothing or fails silently
Some shells (notably zsh) treat # as a comment character. Always put the flake reference in quotes, like so: 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, verify that the .so/.dylib is in lib/ before building, that the header has extern "C" guards, and that the symbols are exported: nm -D lib/libcalc.so | grep calc.