logos-module-client is being archived: nothing declares it as a flake input, nothing lists it as a dependency in the workspace dep-graph, and no source or header outside its own tree references it. These three generated outputs were the last thing implying otherwise. Two were prose describing it as a repo that pins the SDK independently; three were a live `--override-input logos-module-client/logos-cpp-sdk ...` line inside a command a reader is invited to run. Overriding an input that does not exist is only a nix WARNING, not an error, so the command would appear to work while doing nothing at that path. The live specs in doctests/*.yaml are already clean — they emit no such override. Only outputs/*.md drifted, which is expected: they are hand-pinned and CI never diffs them against the generator, so nothing was going to catch this. Docs only; no spec, code or lock changes. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
20 KiB
Composing Two Modules Built Against This C++ SDK
The whole point of logos-cpp-sdk is that a module can call another
module without ever touching the raw LogosAPI: you declare a dependency,
and the SDK's code generator emits a typed modules().<dep> wrapper with
synchronous callers, asynchronous callers, and event subscribers. This
doc-test proves that cross-module path works end-to-end on the SDK commit
under test — by building both sides from scratch against it.
It is fully self-contained — no pre-existing module, no requires: chain:
- Create
greeter_module, a small callee with a couple of methods (greet,addInts,greetCount) and agreetedevent. - Create
orchestrator_module, a caller that declaresgreeter_moduleas a dependency and composes it through the generatedmodules().greeter_modulewrapper — synchronously, asynchronously, and by subscribing to its event. - Build both modules'
.lgxpackages against the C++ SDK commit under test, so the generated wrappers, the plugin glue, and the IPC layer all come from this SDK. - Build
logoscore(also against this SDK), install both modules, load them together, and call the orchestrator's methods — each of which calls into the greeter across the process boundary.
Because the caller, the callee, the generated cross-module wrappers, and the runtime are all built from the SDK commit under test, a green run is real evidence that this change keeps inter-module composition — the SDK's core promise — working.
What you'll build: Two modules — a greeter_module callee and an orchestrator_module caller — built against this SDK commit and run together in logoscore, with the caller driving the callee over IPC.
What you'll learn:
- How one module declares another as a dependency (
metadata.json+flake.nixinput) - How the SDK generates a typed
modules().<dep>wrapper with sync, async, and event APIs - How to build a module — and its module dependency — against a specific
logos-cpp-sdkcommit - How to load two modules in
logoscoreand chain calls so the caller drives the callee - How async replies and event subscriptions survive between
callcommands under the daemon
Prerequisites
- Nix with flakes enabled. Install from nixos.org, then enable flakes:
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
Verify: nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"
- git — nix flakes only see files tracked by git.
- A Linux or macOS machine.
Step 1: Create the callee: greeter_module
greeter_module is an ordinary core module written in the pure-C++
(interface: universal) style: you write one plain class, and the builder
generates the Qt plugin glue. Every public method becomes callable over
IPC, and the logos_events: block declares an event other modules can
subscribe to.
1.1 metadata.json
dependencies is empty — the greeter calls no one. interface: universal selects the pure-C++ pattern.
{
"name": "greeter_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "A callee module: greets, counts greetings, and emits an event",
"main": "greeter_module_plugin",
"interface": "universal",
"dependencies": [],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": []
}
}
}
1.2 CMakeLists.txt
For a universal module you list only your plain C++ sources; the generated glue is compiled automatically.
cmake_minimum_required(VERSION 3.14)
project(GreeterModulePlugin LANGUAGES CXX)
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()
logos_module(
NAME greeter_module
SOURCES
src/greeter_module_impl.h
src/greeter_module_impl.cpp
)
1.3 flake.nix
A minimal flake that hands every input to mkLogosModule. The
`` on the builder URL is pinned by the doc-test runner; the
builder owns the module's logos-cpp-sdk pin, which we override to the
commit under test in the build step.
{
description = "Greeter core module - a callee for the composition doc-test";
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;
};
}
1.4 src/greeter_module_impl.h — the class
Plain C++ inheriting LogosModuleContext. The /// doc comments
become each method's description; the logos_events: block declares
the greeted event (the token expands to public under a normal
compile, and the generator emits the event body).
#pragma once
#include <cstdint>
#include <string>
#include <logos_module_context.h> // LogosModuleContext base + logos_events
// A simple callee module. The orchestrator composes these methods over
// IPC. It also emits a `greeted` event so the caller can exercise a
// typed event subscription.
class GreeterModuleImpl : public LogosModuleContext {
public:
GreeterModuleImpl() = default;
~GreeterModuleImpl() = default;
/// Returns a greeting for the given name.
std::string greet(const std::string& name);
/// Adds two integers and returns the sum.
int64_t addInts(int64_t a, int64_t b);
/// Returns how many times greet() has been called on this instance.
int64_t greetCount() const;
/// Greets the name and also emits a `greeted` event carrying it.
void greetNotify(const std::string& name);
logos_events:
/// Emitted by greetNotify() with the produced greeting string.
void greeted(const std::string& greeting);
private:
int64_t m_greetCount = 0;
};
1.5 src/greeter_module_impl.cpp — the implementation
Plain C++ — no Qt, no IPC plumbing. greetNotify fires the generated event.
#include "greeter_module_impl.h"
std::string GreeterModuleImpl::greet(const std::string& name)
{
++m_greetCount;
return "Hello, " + name + "!";
}
int64_t GreeterModuleImpl::addInts(int64_t a, int64_t b)
{
return a + b;
}
int64_t GreeterModuleImpl::greetCount() const
{
return m_greetCount;
}
void GreeterModuleImpl::greetNotify(const std::string& name)
{
// Emit the event declared in logos_events:. When loaded by a host
// this reaches every subscriber; constructed outside a host it is a
// safe no-op.
greeted("Hello, " + name + "!");
}
Step 2: Create the caller: orchestrator_module
orchestrator_module declares greeter_module as a dependency. That one
line in metadata.json is what makes the builder run the SDK's code
generator over the greeter's exported interface and emit a typed
modules().greeter_module wrapper — with sync callers, async callers, and
event subscribers — that the orchestrator uses without any raw LogosAPI.
2.1 metadata.json — declare the dependency
The dependencies entry must match greeter_module's own
metadata.json name.
{
"name": "orchestrator_module",
"version": "1.0.0",
"type": "core",
"category": "general",
"description": "A caller module: composes greeter_module via typed sync/async calls and an event subscription",
"main": "orchestrator_module_plugin",
"interface": "universal",
"dependencies": ["greeter_module"],
"nix": {
"packages": {
"build": [],
"runtime": []
},
"external_libraries": [],
"cmake": {
"find_packages": [],
"extra_sources": []
}
}
}
2.2 CMakeLists.txt
cmake_minimum_required(VERSION 3.14)
project(OrchestratorModulePlugin LANGUAGES CXX)
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()
logos_module(
NAME orchestrator_module
SOURCES
src/orchestrator_module_impl.h
src/orchestrator_module_impl.cpp
)
2.3 flake.nix — add the dependency input
Declare greeter_module as a flake input; the input name must
match the dependency name in metadata.json. The path: value is a
placeholder — we lock it to the real greeter checkout in the build step
with --override-input (Nix won't accept a relative ../ path written
directly into flake.nix).
{
description = "Orchestrator core module - calls greeter_module";
inputs = {
logos-module-builder.url = "github:logos-co/logos-module-builder";
# The module this one depends on. Placeholder path — locked to the
# real checkout in the build step via --override-input.
greeter_module.url = "path:/path/to/your/greeter_module";
};
outputs = inputs@{ logos-module-builder, greeter_module, ... }:
logos-module-builder.lib.mkLogosModule {
src = ./.;
configFile = ./metadata.json;
flakeInputs = inputs;
};
}
2.4 src/orchestrator_module_impl.h — the class
Three composition paths, all through modules().greeter_module:
greetThrough/greetReport (sync), startAsyncGreet/asyncGreeting
(async), and subscribeGreeted/lastGreetedEvent (event).
#pragma once
#include <cstdint>
#include <string>
#include <logos_json.h> // LogosMap
#include <logos_module_context.h> // LogosModuleContext base + modules()
// A caller module. It does nothing on its own — it composes
// greeter_module through the typed modules().greeter_module wrapper the
// builder generates from the dependency declared in metadata.json.
class OrchestratorModuleImpl : public LogosModuleContext {
public:
OrchestratorModuleImpl() = default;
~OrchestratorModuleImpl() = default;
/// Calls greeter_module.greet(name) and returns its result verbatim.
std::string greetThrough(const std::string& name);
/// Composes several greeter_module calls into one map: a greeting,
/// an integer sum, and the greeter's current greet count.
LogosMap greetReport(const std::string& name, int64_t a, int64_t b);
/// Fires greeter_module.greetAsync(name) asynchronously and returns
/// "queued" immediately; the reply lands in a callback. Read it back
/// with asyncGreeting().
std::string startAsyncGreet(const std::string& name);
/// The greeting delivered by startAsyncGreet()'s callback, or empty
/// until it arrives.
std::string asyncGreeting() const;
/// Subscribes to greeter_module's `greeted` event with a typed
/// callback. Returns "ok" once registered.
std::string subscribeGreeted();
/// The last greeting captured by the `greeted` subscription, or
/// empty until the event fires.
std::string lastGreetedEvent() const;
private:
std::string m_asyncGreeting;
std::string m_lastGreetedEvent;
bool m_subscribed = false;
};
2.5 src/orchestrator_module_impl.cpp — the implementation
The .cpp includes the generated logos_sdk.h (which defines
LogosModules) — that's why the cross-module calls live here and not in
the header the generator parses. Each method drives greeter_module
through the generated wrapper: .greet(...) (sync), .greetAsync(..., cb) (async), .onGreeted(cb) (event).
#include "orchestrator_module_impl.h"
// Generated at build time by logos-cpp-generator. Defines LogosModules
// with one std-typed accessor per metadata.json dependency — here
// greeter_module. Included only in the .cpp so the impl header the
// generator parses stays free of Qt and codegen types.
#include "logos_sdk.h"
std::string OrchestratorModuleImpl::greetThrough(const std::string& name)
{
// The simplest cross-module round-trip: one typed sync call.
return modules().greeter_module.greet(name);
}
LogosMap OrchestratorModuleImpl::greetReport(const std::string& name,
int64_t a, int64_t b)
{
// Three typed sync calls into greeter_module, composed into one map.
auto& greeter = modules().greeter_module;
LogosMap report;
report["greeting"] = greeter.greet(name);
report["sum"] = greeter.addInts(a, b);
report["greetCount"] = greeter.greetCount();
return report;
}
std::string OrchestratorModuleImpl::startAsyncGreet(const std::string& name)
{
// The generated async overload returns immediately; the reply is
// delivered to the callback on this module's event loop.
modules().greeter_module.greetAsync(name, [this](const std::string& g) {
m_asyncGreeting = g;
});
return "queued";
}
std::string OrchestratorModuleImpl::asyncGreeting() const
{
return m_asyncGreeting;
}
std::string OrchestratorModuleImpl::subscribeGreeted()
{
if (m_subscribed) return "ok";
// Typed subscriber generated from greeter_module's logos_events:
// greeted(const std::string&). The accessor is `on` + the
// capitalized event name.
m_subscribed = modules().greeter_module.onGreeted(
[this](const std::string& greeting) {
m_lastGreetedEvent = greeting;
});
return m_subscribed ? "ok" : "failed";
}
std::string OrchestratorModuleImpl::lastGreetedEvent() const
{
return m_lastGreetedEvent;
}
Step 3: Build both modules against this SDK
Nix flakes only see files tracked by git, so initialise a repo in each
module first. Then build each module's .lgx, overriding logos-cpp-sdk
to the commit under test so the generated wrappers, plugin glue, and IPC
come from this SDK.
Each override URL carries a `` placeholder the doc-test runner expands to a concrete ref: locally that is this
logos-cpp-sdkcheckout'sHEAD(seerun.sh); in CI it is the commit being tested. With no pin it falls back to latestmaster.
3.1 Initialise git repos
The greeter is a dependency of the orchestrator, so both must be tracked.
(cd greeter_module && git init -q && git add -A)
(cd orchestrator_module && git init -q && git add -A)
3.2 Build the greeter's .lgx against this SDK
The greeter has no module dependency, so only its builder's
logos-cpp-sdk needs overriding.
# From inside the greeter clone this is simply:
# nix build '.#lgx' --override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk'
nix build 'path:./greeter_module#lgx' \
--override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
-o greeter-lgx
The greeter package is under ./greeter-lgx/:
ls greeter-lgx/*.lgx
3.3 Build the orchestrator's .lgx against this SDK
The orchestrator pulls in greeter_module as a dependency, so we lock
that input to the local greeter checkout and override
logos-cpp-sdk in both the orchestrator's builder and the greeter's
builder — so the dependency wrapper the generator emits, and both
plugins, are built against one consistent SDK.
nix build 'path:./orchestrator_module#lgx' \
--override-input greeter_module 'path:./greeter_module' \
--override-input logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input greeter_module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
-o orchestrator-lgx
The orchestrator package is under ./orchestrator-lgx/:
ls orchestrator-lgx/*.lgx
Step 4: Build the runtime and install both modules
Build logoscore (against this SDK, the same way as the runtime doc-test)
and lgpm, then install both modules into a ./modules directory the
daemon can scan.
4.1 Build logoscore against this SDK
nix build 'github:logos-co/logos-logoscore-cli' \
--override-input logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-liblogos/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--override-input logos-capability-module/logos-module-builder/logos-cpp-sdk 'github:logos-co/logos-cpp-sdk' \
--out-link ./logos
4.2 Build lgpm
nix build 'github:logos-co/logos-package-manager#cli' -o lgpm
4.3 Seed the modules directory with the capability module
Loading a module goes through the host's capability layer, so the
modules directory needs the capability_module that ships with
logoscore (rebuilt against this SDK). Copy it across first.
mkdir -p modules
cp -RL ./logos/modules/. ./modules/
4.4 Install the greeter
./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file greeter-lgx/*.lgx
4.5 Install the orchestrator
./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file orchestrator-lgx/*.lgx
4.6 Confirm both modules are installed
./lgpm/bin/lgpm --modules-dir ./modules list
Step 5: Load both modules and drive the caller
Start logoscore in daemon mode (-D) — it keeps each module's process
alive between call commands, so an event subscription registered by one
call is still active when a later call triggers it, and an async reply
lands before the call that reads it. Load both modules, then call the
orchestrator's methods — each one reaches across the process boundary into
the greeter.
5.1 Start the daemon
logoscore -D -m ./modules > logs.txt &
sleep 3
5.2 Load the greeter (the dependency first)
logoscore load-module greeter_module
5.3 Load the orchestrator
logoscore load-module orchestrator_module
5.4 Confirm both report loaded
logoscore status
5.5 Synchronous cross-module call
greetThrough(name) forwards straight to greeter_module.greet(name)
and returns the result — one typed sync call across the process
boundary:
logoscore call orchestrator_module greetThrough World
5.6 Compose several calls into one result
greetReport(name, a, b) fans out to three greeter calls — greet,
addInts, and greetCount — and returns them as one map. The greeter
has now been greeted twice (once above, once here), so greetCount is
2:
logoscore call orchestrator_module greetReport Logos 3 5
5.7 Asynchronous cross-module call
startAsyncGreet(name) fires greeter_module.greetAsync(name) and
returns "queued" immediately. The reply arrives on the daemon's event
loop; the next call, asyncGreeting(), reads what the callback stashed:
logoscore call orchestrator_module startAsyncGreet Async
sleep 1
5.8 Read the async reply
logoscore call orchestrator_module asyncGreeting
5.9 Subscribe to the greeter's event
subscribeGreeted() registers a typed callback on greeter_module's
greeted event. Then greeter_module.greetNotify(...) makes the
greeter emit it, and lastGreetedEvent() reads what the subscription
captured. Because the daemon keeps both modules loaded, the event fires
between the calls:
logoscore call orchestrator_module subscribeGreeted
5.10 Trigger the event from the greeter
logoscore call greeter_module greetNotify Events
sleep 1
5.11 Read the captured event payload
logoscore call orchestrator_module lastGreetedEvent
5.12 Stop the daemon
logoscore stop
sleep 2
5.13 Confirm the daemon has stopped
logoscore status
Recap
| Composition path | In the orchestrator | Driven via logoscore |
|---|---|---|
| Typed sync call | greetThrough() → greeter.greet(...) |
"Hello, World!" |
| Composed sync calls | greetReport() → greet + addInts + greetCount |
one map of three results |
| Typed async call | startAsyncGreet() → greetAsync(..., cb) |
queued, then "Hello, Async!" |
| Typed event subscription | subscribeGreeted() → onGreeted(cb) |
captured "Hello, Events!" |
Every path went through modules().greeter_module, the wrapper the SDK's
code generator emitted from the greeter_module dependency — and both
modules, the wrapper, and the runtime were built against the SDK commit
under test. A green run means inter-module composition still works on this
SDK, end to end.