# Tutorial: Composing Modules with the Module Context
This tutorial builds `calc_aggregator`, a **core module that depends on another module** (`calc_module` from [Part 1](tutorial-wrapping-c-library.md)). It does no arithmetic of its own — instead it *composes*`calc_module`'s primitives into a single call, and along the way showcases everything the SDK's `LogosModuleContext` base class gives a universal module. There is no UI: you drive the whole thing from `logoscore` on the command line.
**What you'll build:** A `calc_aggregator` core module that, through the `LogosModuleContext` base class:
- reads the three host-injected properties — `modulePath()`, `instanceId()`, `instancePersistencePath()`
- persists state in its per-instance data directory (a run counter that survives restarts), wired up in the `onContextReady()` hook
- calls `calc_module` with the generated, type-safe `modules().calc_module` wrappers — **synchronously** (five calls composed into one `computeReport`) and **asynchronously** (`fibonacciAsync` with a callback)
- subscribes to `calc_module`'s `versionReady`**event** with a typed callback
No Qt, no `LogosAPI`, no plugin boilerplate — one plain C++ class, exactly like Part 1.
**What you'll learn:**
- How one module declares another as a dependency (`metadata.json` + `flake.nix` input)
- How `LogosModuleContext` exposes `modulePath` / `instanceId` / `instancePersistencePath` to a universal module
- How to use the per-instance persistence directory for durable state, set up in `onContextReady()`
- How `modules().<dep>` gives you typed **sync** and **async** callers — no raw `LogosAPI`, no `QVariant`
- How to subscribe to another module's `logos_events:` with a typed callback
- How to load two modules in `logoscore` and chain calls to observe events and async replies
## Prerequisites
- Completed [Part 1](tutorial-wrapping-c-library.md) — you have a working `calc_module` whose shared library is built (`libcalc.so`/`.dylib` in `logos-calc-module/lib/`). This tutorial only needs `calc_module`; the UI tutorials (Parts 2–3) are not required.
- Nix with flakes enabled
- Basic familiarity with C++
---
## Step 1: Scaffold the Module Project
Create a new directory and initialise it from the minimal module template:
`mkdir logos-calc-aggregator-module && cd logos-calc-aggregator-module`
This scaffolds a `flake.nix`, `metadata.json`, `CMakeLists.txt`, and a `src/` directory pre-wired for `logos-module-builder`. As in Part 1 we use the newer **pure-C++ (`interface: universal`) pattern**, so we replace the template's example `src/` files with a single plain `*_impl.h` / `*_impl.cpp` class.
### 1.2 Remove the template's example sources
The minimal template ships an example Qt plugin (`minimal_*`). Delete those — this tutorial supplies its own pure-C++ `src/` files:
The one field that matters here is `dependencies`: listing `calc_module` tells the builder to read `calc_module`'s published LIDL interface contract and generate a typed wrapper for it — without building `calc_module` itself. The dependency name **must match**`calc_module`'s own `metadata.json``name`.
`NAME` must match `name` in `metadata.json` (`calc_aggregator`). No `EXTERNAL_LIBS` here — the only dependency is another module, resolved via `metadata.json` + `flake.nix`, not CMake.
### 2.3 `flake.nix` — add the dependency input
Declare `calc_module` as a flake input. The input attribute name **must match** the dependency name in `metadata.json`. The `path:/path/to/your/calc_module` value is a placeholder — you lock it to your real Part 1 checkout in the build step with `--override-input` (Nix won't accept a relative `../` path written directly into `flake.nix`).
```nix
{
description="Aggregator core module - composes calc_module and showcases LogosModuleContext";
`flakeInputs = inputs` hands every input (including `calc_module`) to the builder, which resolves the `calc_module` dependency declared in `metadata.json` and runs `logos-cpp-generator` to emit the typed wrapper.
---
## Step 3: Write the Module Class
The whole module is one plain C++ class that inherits `LogosModuleContext`. Inheriting that base is what unlocks the context getters (`modulePath()` / `instanceId()` / `instancePersistencePath()`), the `onContextReady()` hook, and `modules()` — typed access to declared dependencies. No Qt anywhere.
### 3.1 `src/calc_aggregator_impl.h` — the class
Every `public` method becomes callable over IPC. The methods fall into four groups: the context getters, the persistence demo, the sync/async composition of `calc_module`, and the event subscription.
```cpp
#pragma once
#include<cstdint>
#include<string>
#include<logos_json.h>// LogosMap (QVariantMap on the wire)
#include<logos_module_context.h>// LogosModuleContext base class
// A core module that depends on calc_module. It does no arithmetic of
// its own — it *composes* calc_module's primitives and showcases what
// the SDK's LogosModuleContext base class gives a universal module:
//
// • modulePath() — where the plugin was loaded from
// • instanceId() — host-assigned, stable per persistence dir
// • instancePersistencePath() — per-instance writable data directory
// • onContextReady() — one-time setup hook
// • modules() — typed access to declared dependencies
// (sync callers, async callers, events)
//
// Because metadata.json sets "interface": "universal", the builder
// generates the Qt plugin wrapper from this plain class.
classCalcAggregatorImpl:publicLogosModuleContext{
public:
CalcAggregatorImpl()=default;
~CalcAggregatorImpl()=default;
// ── The three host-injected context properties ─────────────────
/// Directory the plugin file was loaded from (modulePath()).
std::stringmoduleDir()const;
/// Host-assigned instance ID (instanceId()).
std::stringinstanceID()const;
/// True iff the host populated a non-empty instance ID. A bool
/// return distinguishes "host wired it" from the empty-string
/// default a plain string getter can't tell apart over the CLI.
boolhasInstanceID()const;
/// Per-instance writable data directory (instancePersistencePath()).
std::stringpersistenceDir()const;
/// Increments a counter stored under persistenceDir() and returns
/// the new value. The persistence dir is host-owned and durable, so
/// the count keeps climbing across restarts — it is loaded back in
/// onContextReady().
int64_tbumpRunCount();
// ── Compose calc_module: five sync calls into one result ───────
// ── Compose calc_module: an async call ─────────────────────────
/// Fires calc_module.fibonacci(n) *asynchronously* and returns
/// right away ("queued"). The reply lands later in a callback that
/// stashes it; read it back with asyncResult().
std::stringstartAsyncFibonacci(int64_tn);
/// The most recent value delivered by startAsyncFibonacci()'s
/// callback, or -1 if none has arrived yet.
int64_tasyncResult()const;
// ── Subscribe to a calc_module event ───────────────────────────
/// Subscribes to calc_module's `versionReady` event with a typed
/// callback. Returns "ok" once registered. Trigger it by calling
/// calc_module.libVersionNotify().
std::stringsubscribeVersion();
/// The last version string delivered by the versionReady
/// subscription, or empty until one fires.
std::stringlastVersionEvent()const;
protected:
// One-time hook the framework fires once the context getters above
// are populated, before any method dispatch — the canonical place
// for setup that needs the persistence path.
voidonContextReady()override;
private:
int64_tm_runCount=0;
int64_tm_asyncResult=-1;
std::stringm_lastVersionEvent;
boolm_subscribed=false;
};
```
A few things to notice:
- The class inherits **`LogosModuleContext`** — that's the opt-in that gives it the context getters and `modules()`.
-`onContextReady()` is `protected` (an override of the base hook), so it is **not** exposed over IPC — only the `public` methods are.
-`hasInstanceID()` returns `bool` on purpose: the CLI prints a `Result:` line for any string (even empty), so a boolean is the unambiguous way to assert "the host populated the ID".
### 3.2 `src/calc_aggregator_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 group of methods maps one-to-one onto the bullets in the class comment.
```cpp
#include"calc_aggregator_impl.h"
#include<fstream>
// Generated at build time by logos-cpp-generator. Defines `LogosModules`
// with one std-typed accessor per metadata.json dependency — here
// `calc_module`. Included only in the .cpp so the impl header the
// generator parses stays free of Qt and codegen types.
#include"logos_sdk.h"
namespace{
// The run-count file lives inside the host-provisioned persistence dir.
// An empty dir means the module was constructed outside a host (e.g. a
// unit test) — treat that as "nothing to persist".
That's the entire module. The three capabilities the SDK base class enables are all here:
1.**Context properties** — `moduleDir()`, `instanceID()`, `persistenceDir()` just return the base getters; `bumpRunCount()` + `onContextReady()` show the persistence dir used for real, durable state.
2.**Typed dependency calls** — `computeReport()` uses the **sync** wrappers (`calc.add(...)`, …); `startAsyncFibonacci()` uses the **async** wrapper (`fibonacciAsync(..., callback)`).
3.**Typed event subscription** — `subscribeVersion()` registers a callback on `calc_module`'s `versionReady` event via the generated `onVersionReady(...)` accessor.
---
## Step 4: Build the Module
### 4.1 Add a `.gitignore` and init the repo
Nix flakes require a git repository. Exclude build artifacts first:
```text
# Nix build output
result
result-*
# CMake build directory
build/
```
Initialise the repo and stage the files:
```bash
git init && git add -A
```
### 4.2 Make sure `calc_module` is built
The dependency must be built with its shared library present in `lib/` (from [Part 1](tutorial-wrapping-c-library.md#15-build-the-shared-library)). Verify it:
```bash
ls ../logos-calc-module/lib/libcalc.so # Linux
ls ../logos-calc-module/lib/libcalc.dylib # macOS
```
If it is missing, build it (as in Part 1, Step 1.5):
Lock `calc_module` to your local Part 1 checkout. `--override-input` resolves `../logos-calc-module` to an absolute path and records it in `flake.lock`, replacing the placeholder in `flake.nix`:
Now build the full package. For a universal module with a dependency, this is where `logos-cpp-generator` runs over both `src/calc_aggregator_impl.h` and `calc_module`'s published LIDL contract, emitting the plugin glue **and** the typed `modules().calc_module` wrapper under `generated_code/` — note `calc_module`'s own plugin is not built here, only its LIDL is read:
Every `public` method on the impl is here, published in the **LIDL contract** vocabulary rather than in C++ or Qt names: `int64_t` shows up as `int`, `std::string` as `tstr`, and `LogosMap` (from `computeReport`) as `{tstr: any}`. `lm` is reporting what the module says about itself, and what a module publishes is its contract — the same words the generated `.lidl` uses, and the same words a Rust or Nim module implementing this contract would answer with.
Now the payoff: run `calc_aggregator`**and** its `calc_module` dependency under `logoscore` and exercise every capability. We use the `logoscore`**daemon** (`-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. (This is the same daemon flow as [Part 1](tutorial-wrapping-c-library.md#step-6-test-with-logoscore).)
### 6.1 Build the runtime and package both modules
Build `logoscore` and the package manager, then install **both** modules into a `modules/` directory `logoscore` can scan. The aggregator comes from this project; `calc_module` from your Part 1 checkout:
`modules/` now holds `calc_aggregator/` and `calc_module/`, each with its plugin, libraries, and `manifest.json`.
### 6.4 Create a persistence directory and start the daemon
The host only provisions a per-instance persistence path when you pass `--persistence-path`. Create a directory for it — we reuse the **same** directory across restarts so the instance ID (and therefore the persisted state) is stable:
```bash
mkdir -p calc-data
```
Start `logoscore` as a background daemon, pointed at the modules directory and the persistence directory:
Load both modules. The daemon keeps each module's process alive between `call` commands, which is what lets an event subscription (or an async reply) survive from one call to the next:
```bash
./logos/bin/logoscore load-module calc_module
```
```bash
./logos/bin/logoscore load-module calc_aggregator
```
### 6.5 Read the context properties
`moduleDir()` / `hasInstanceID()` / `persistenceDir()` return the values the host stamped onto the module. We can't predict the absolute path, but `moduleDir()` must contain the module name:
`hasInstanceID()` returns a bool, so a non-empty instance ID shows as `"result":true` — an unambiguous signal the host populated `instanceId()` (a plain string getter would read as empty either way):
`sum = 3+5`, `product = 3*5`, `factorial = 10!`, `fibonacci = fib(10)`, and `libVersion` read straight from `calc_module` — all through the generated `modules().calc_module` sync wrappers.
### 6.7 Compose calc_module asynchronously
`startAsyncFibonacci(n)` fires `calc_module.fibonacciAsync(n)` and returns `"queued"` immediately. The reply arrives on the daemon's event loop; the next call, `asyncResult()`, reads what the callback stashed. With `n=20`, `fib(20) = 6765`:
`startAsyncFibonacci` returned before the answer existed; by the time `asyncResult()` runs, the async callback has fired and stored `6765`. That's the typed **async** caller — same wrapper, `<method>Async(..., callback)`.
### 6.8 Subscribe to a calc_module event
The typed event subscription. `subscribeVersion()` registers the callback, `calc_module.libVersionNotify()` makes `calc_module` emit its `versionReady` event, and `lastVersionEvent()` reads what the subscription captured. Because the daemon keeps both modules loaded, the event fires between the calls:
`subscribeVersion()` returned `ok`; the event fired in between; `lastVersionEvent()` returned `1.0.0` — the payload `calc_module` emitted, delivered to the typed callback you registered with `modules().calc_module.onVersionReady(...)`.
### 6.9 Persist state across a restart
`bumpRunCount()` increments a counter saved in the persistence directory. Call it twice — `1`, then `2`:
Now stop the daemon and start a **brand-new** one against the same persistence directory. `onContextReady()` loads the persisted `2` from disk, so the next bump is `3`:
The count survived a full process restart — proof the persistence directory is host-owned and durable, and that `onContextReady()` is the right place to rehydrate per-instance state.
```bash
./logos/bin/logoscore stop
```
That completes the tour: context properties, durable persistence, sync **and** async typed dependency calls, and a typed event subscription — every capability of `LogosModuleContext`, driven entirely from `logoscore`.
---
## Recap
| Capability | In the code | Seen via `logoscore` |
| Typed **event** subscription | `subscribeVersion()` → `onVersionReady(cb)` | captured payload `1.0.0` after the event fires |
Everything flowed through `modules().calc_module`, the wrapper the builder generated from the `calc_module` dependency — no raw `LogosAPI`, no `QVariant`, no Qt in your code.
**Next:** give this module a UI by following [Part 2 (QML-only)](tutorial-qml-ui-app.md) or [Part 3 (C++ backend)](tutorial-cpp-ui-app.md), or package it for distribution with `nix build '.#lgx-portable'` (see [Part 1 — Package for Distribution](tutorial-wrapping-c-library.md#package-for-distribution-optional)).