Skip to main content

Use the Logos Delivery module API from an app

Get started integrating Logos messaging into a C++ module.

Version

This document is accurate for Testnet v0.2.1.

This procedure covers building a Logos module that calls the Logos Delivery API to subscribe to content topics, send messages, react to delivery events, and exchange messages over a reliable channel. It gives application developers a working pattern for integrating Logos messaging into their C++ modules. A complete, runnable reference implementation is available in logos-delivery-demo (tag v0.2.0).

The two repositories used in this tutorial are logos-delivery-module (pinned to v0.2.0) and logos-delivery, which is a transitive dependency resolved and linked statically by Nix.

Prerequisites
  • A supported OS:

    • Linux: aarch64 or x86_64
    • macOS: aarch64 or x86_64
  • ~1 GB of RAM

  • 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

What to expect

  • You can subscribe to a content topic and receive messages.
  • You can send a message and confirm delivery by tracking messageSent and messagePropagated events.
  • You can open a reliable channel and exchange messages on it with automatic retransmission and acknowledgements.
  • You can integrate the full Logos Delivery lifecycle—create, start, subscribe, send, stop—into your C++ module.

Step 1: Create a Logos module

Scaffold a new module using logos-module-builder. For a full walkthrough, see the Build a Logos C++ UI module tutorial.

  1. Create and enter the project directory:

    mkdir your-module-name && cd your-module-name
  2. Initialise from the template:

    nix flake init -t github:logos-co/logos-module-builder/0.2.5#ui-qml-backend
  3. Initialise a Git repository and stage all generated files:

    git init && git add -A

    The scaffold builds as it stands. It gives you:

    • src/ui_example.rep - the view contract;
    • src/ui_example_backend.{h,cpp} - the class you write;
    • src/qml/Main.qml - your module's UI entry point.
    info

    You are free to rename the files and classes later. In this doc we'll refer to them as generated by default.

Step 2: Declare delivery_module as a dependency

Add delivery_module to both metadata.json and flake.nix, pinning to the released tag so your app stays stable as the module API evolves.

info

The flake input name (delivery_module) must exactly match the dependency name in metadata.json. logos-module-builder uses this name to generate the typed wrapper at build time.

  1. In metadata.json, add delivery_module to the dependencies array:

    {
    "dependencies": ["delivery_module"],
    ...
    }
  2. In flake.nix, add a matching pinned input:

    inputs = {
    logos-module-builder.url = "github:logos-co/logos-module-builder/0.2.5";
    delivery_module = {
    url = "github:logos-co/logos-delivery-module/v0.2.0";
    # Your module and delivery_module must be built by the same
    # logos-module-builder: the event emitter and the consumer have to agree
    # on the binary event wire format.
    inputs.logos-module-builder.follows = "logos-module-builder";
    };
    };
    warning

    Use logos-module-builder 0.2.5 or newer. On older builders, binary event payloads (messageReceived, channelMessageReceived) arrive empty.

Step 3: Subscribe to events in your backend

Add onContextReady() to the backend the template scaffolded, and arm the delivery subscriptions there.

  1. Override onContextReady() in ui_example_backend.h:

    #pragma once

    #include "rep_ui_example_source.h"
    #include "logos_ui_plugin_context.h"

    class UiExampleBackend : public UiExampleSimpleSource,
    public LogosUiPluginContext
    {
    public:
    int add(int a, int b) override;

    void onContextReady() override;
    };
  2. Arm the node and Messaging API subscriptions in ui_example_backend.cpp:

    #include "ui_example_backend.h"
    #include "logos_sdk.h" // generated umbrella — exposes modules() with your module's dependency: `.delivery_module`

    void UiExampleBackend::onContextReady()
    {
    auto& delivery = modules().delivery_module;

    delivery.onConnectionStateChanged([](QString status, qint64 timestamp) {});

    delivery.onMessageReceived(
    [this](QString messageHash, QString contentTopic, QByteArray payload, qint64 timestamp) {
    // payload is raw bytes, not text.
    });

    delivery.onMessageSent([](QString requestId, QString messageHash, qint64 timestamp) {});
    delivery.onMessagePropagated([](QString requestId, QString messageHash, qint64 timestamp) {});
    delivery.onMessageError(
    [](QString requestId, QString messageHash, QString error, qint64 timestamp) {});
  3. Arm the Reliable Channels subscriptions too, if your module uses channels (Step 6):

    delivery.onChannelMessageReceived(
    [this](QString channelId, QString senderId, QByteArray payload, qint64 timestamp) {
    });

    delivery.onChannelMessageSent([](QString channelId, QString requestId, qint64 timestamp) {});
    delivery.onChannelMessageError(
    [](QString channelId, QString requestId, QString error, qint64 timestamp) {});
    }

Step 4: Create and start the node

createNode builds the node from a JSON configuration and start() connects it to the network. Both are synchronous and return a LogosResult—always check success before continuing and surface getError() on failure.

info

The node is a singleton per Logos Core instance: call createNode exactly once per context, and expect the node to already exist when another module created it first.

  1. Initialise the node with createNode. For a complete list of node configuration keys, see the Module Interface section of the README.

    auto& delivery = modules().delivery_module;

    // "mode": "Core" (relay node) or "Edge" (light node).
    // "preset": the network to join — "logos.test" or "logos.dev"
    LogosResult r = delivery.createNode(R"({"mode":"Core","preset":"logos.test"})");
    if (!r.success) {
    qWarning() << "createNode failed:" << r.getError();
    return;
    }
    warning

    Keep the config to mode, preset, messagingOverrides, and channelsOverrides. Any other top-level key (logLevel, for example) selects the legacy flat configuration shape.

  2. Connect to the network with start(). The connectionStateChanged event fires once the node connects to peers:

    r = delivery.start();
    if (!r.success) {
    qWarning() << "start failed:" << r.getError();
    return;
    }

    The node is not connected the moment start() returns. Wait for connectionStateChanged before sending on either API.

Step 5: Use the Messaging API

The Messaging API is publish/subscribe on a content topic: you subscribe to the topics you care about and send raw payloads to them.

  1. Subscribe to a content topic using a LIP-23 content-topic string. Call subscribe() before any messages are sent on that topic:

    LogosResult r = delivery.subscribe(contentTopic);
    if (!r.success) {
    qWarning() << "subscribe failed:" << r.getError();
    }
  2. Send a message. On success, getString() returns the request ID; track it through messagePropagatedmessageSent events (or messageError):

    // payload is a QByteArray of raw bytes, the same type messageReceived delivers.
    LogosResult r = delivery.send(contentTopic, payload);
    if (!r.success) {
    qWarning() << "send failed:" << r.getError();
    return;
    }
    const QString requestId = r.getString();
  3. Receive messages through the onMessageReceived subscription you armed in Step 3. It fires for every message on a subscribed topic, including messages sent by you.

  4. Stop receiving on a topic with delivery.unsubscribe(contentTopic). Subscriptions are node-wide, so unsubscribing a topic another module subscribed to stops its traffic too.

Step 6: Use the Reliable Channels API

Reliable Channels add end-to-end reliability on top of the Messaging API: it tracks acknowledgements with SDS and retransmits what peers did not acknowledge.

warning

The Reliable Channels API is a Developer Preview. It is not feature-complete and is still under testing—expect gaps in behaviour, and expect the API surface to change before general availability.

A channel is addressed by an application-chosen channelId, so you never hold a channel object—every call takes the id. All four calls are synchronous and return a LogosResult; delivery outcomes arrive through the channel subscriptions from Step 3.

info

Reliable channels need the full stack, which is what createNode mounts by default ("entryLayer": "channels"). On a kernel-only node, every channel* call fails with no reliable channel manager.

  1. Create a channel with channelCreate(). Every participant creates the channel with the same channelId and contentTopic, and its own senderId:

    // senderId: this participant's SDS identifier — any string unique per participant
    LogosResult r = delivery.channelCreate(channelId, contentTopic, senderId);
    if (!r.success) {
    qWarning() << "channelCreate failed:" << r.getError();
    return;
    }

    Creating a channel subscribes its content topic for you—you don't call subscribe() for a channel topic.

  2. Check whether a channel is currently open.

    LogosResult r = delivery.channelExists(channelId);
    const bool open = r.success && r.getString() == QLatin1String("true");
  3. Send on the channel. channelSend() returns a request ID—track it through channelMessageSent or channelMessageError.

    // payload is a QByteArray of raw bytes, same as channelMessageReceived delivers.
    LogosResult r = delivery.channelSend(channelId, payload);
    if (!r.success) {
    qWarning() << "channelSend failed:" << r.getError();
    return;
    }
    const QString requestId = r.getString();
  4. Receive channel messages through the onChannelMessageReceived subscription. Unlike onMessageReceived, it fires only for the other participants' messages—your own channel sends come back as channelMessageSent, not as a received message.

  5. Close the channel when you are done with it. This stops the channel's SDS loops and unsubscribes its content topic, unless another open channel still uses that topic:

    LogosResult r = delivery.channelClose(channelId);
    if (!r.success) {
    qWarning() << "channelClose failed:" << r.getError();
    }

To tune reliability, pass a channelsOverrides object in the createNode config of Step 4. Unset keys keep the defaults shown here:

{
"mode": "Core",
"preset": "logos.test",
"channelsOverrides": {
"sdsAcknowledgementTimeoutMs": 5000,
"sdsMaxRetransmissions": 5,
"sdsCausalHistorySize": 2
}
}
warning

Reliable channels give you delivery reliability, not confidentiality: channel payloads travel unencrypted unless your application encrypts them. For end-to-end encrypted conversations, use the Logos Chat module.

Step 7: Shut down

Shut down cleanly with stop(). This tears down the underlying node and drops every active subscription, channel, and event listener:

LogosResult r = delivery.stop();
if (!r.success) {
qWarning() << "stop failed:" << r.getError();
}

Because the node is shared by every module on the host, stop() also ends the traffic of any other module using it.

Step 8: Build and run

  1. Build the module:

    nix build
  2. Preview the module using logos-standalone-app (for ui_qml modules):

    nix run
  3. Package as .lgx for installation into logos-basecamp:

    nix build .#lgx

Step 9: Verify a two-instance message exchange

Messaging is only proven end to end when a payload travels between two running nodes. Start two copies of your module, each with its own session directory:

# terminal A
nix run . -- --user-dir ~/.local/share/ui_example_a
# terminal B
nix run . -- --user-dir ~/.local/share/ui_example_b
  1. In both instances, run createNode with the same preset, then start(), and wait until each reports a connected state through connectionStateChanged.
  2. In both instances, call subscribe(contentTopic) on the same content topic.
  3. In instance A, call send(contentTopic, payload). A's messagePropagated and messageSent events fire, carrying the request id the call returned.
  4. Instance B fires messageReceived with the same payload bytes on that content topic.
  5. Send in the other direction and confirm instance A receives it.

A messageReceived event on the instance that did not send confirms the whole path: node creation, network connection, subscription, and publish.

Step 10: Verify a two-instance reliable channel exchange

Reliability shows only between two participants of the same channel. Use the same two instances as in Step 9, both connected.

  1. In both instances, call channelCreate(channelId, contentTopic, senderId) with the same channelId and contentTopic, and a senderId unique to each instance.
  2. In instance A, call channelSend(channelId, payload). A's channelMessageSent event fires once the send is confirmed, carrying the request id the call returned.
  3. Instance B fires channelMessageReceived with A's senderId and the payload bytes.
  4. Send from instance B and confirm instance A receives it, attributed to B's senderId.

Messages arriving on both sides, each attributed to the sending participant, confirms the channel journey: channel creation on a shared content topic and acknowledgement through SDS.

tip

logos-delivery-demo drives both verifications from its UI: every API call is a button, and its event log renders every event described above.

Troubleshooting the Logos Delivery module

createNode returns an unsuccessful LogosResult?

The JSON may be malformed, or an internal initialisation error occurred. Validate that the JSON is well-formed and that key names are camelCase. Set "messagingOverrides": {"logLevel": "DEBUG"} for verbose output and inspect result.getError() for details.

send() returns an unsuccessful LogosResult?

The node was not started, or contentTopic is empty or invalid. Call start() first and verify it returned success. Confirm the content topic follows the LIP-23 format.

messageSent never fires after a successful send()?

The node may not be connected to peers yet, or the network layer rejected the message (for example, an RLN proof failure). Wait for connectionStateChanged before sending. If messageError fires, its error argument carries the reason.

messageReceived never fires?

subscribe() was not called before messages were sent, or the payload was sent on a different content topic. Call subscribe(topic) before any messages are sent on that topic.

The node process dies when I re-open a channel I closed?

Known issue in this release: (logos-delivery#4116). Calling channelCreate with the id of a channel you closed with channelClose crashes the delivery node, if that channel had received a message from a peer. The module reports the call as failed and the node is gone—every later call fails until the module is reloaded and the node re-created. Use a fresh channel id rather than re-opening a closed one.

channelCreate returns channel already exists?

The channel id is already open on this node—channel ids are node-wide, so another module may hold it. Call channelExists(channelId) first, and reuse the open channel or pick an application-specific id.

channelMessageReceived never fires on the other participant?

The participants disagree on the channel: both sides must call channelCreate() with the same channelId and the same contentTopic, each with its own senderId. Reusing one senderId for two participants breaks SDS bookkeeping.

channelMessageReceived fires but the payload is empty?

Your module was built with a logos-module-builder older than 0.2.5, where binary event payloads arrive empty. Pin logos-module-builder to 0.2.5 or newer and make delivery_module follow the same input, as in Step 2.

channelSend returns no reliable channel manager?

The node was created as kernel-only ("entryLayer": "kernel"). Reliable channels need the default full stack—omit entryLayer, or set it to "channels".

Two instances of the same app on one host fail to start?

Both instances bound the same port. With the layered config (mode, preset, and the *Overrides objects only), ports default to 0 and the OS assigns a free one per instance. Adding any other top-level key switches the config to the legacy flat shape, whose kernel defaults pin port 60000. Keep the config layered, or assign distinct explicit port values per instance.

createNode succeeds but a second call without stop() causes undefined behaviour?

createNode must be called exactly once per context. Call stop() and destroy the context before calling createNode again.