Skip to main content

Build a Logos module that uses the Chat module API

Get started with private 1:1 and group end-to-end encrypted messaging in your own Logos module.​

Version

This document is accurate for Testnet v0.3.

This procedure covers how to build a Logos module that calls the logos-chat-module API (tag v0.3.0) to exchange addresses, open private 1:1 (or direct) and group conversations, and send and receive end-to-end encrypted messages on the Logos network. It is intended for application developers who want to integrate private messaging without taking direct dependencies on logos-chat or logos-delivery.

Each launch is a new chat installation with a new address. Conversations and their messages are saved in the instance directory and come back after a restart, but only as read-only history: you can read them, but not send into them.

Prerequisites
  • A supported OS:

    • Linux
    • macOS
  • Network access.

  • 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
  • An understanding of Logos modules

What to expect​

  • You can initialise a chat client, connect to the Logos network, and exchange end-to-end encrypted messages with another instance.
  • You can open a private 1:1 conversation by exchanging addresses out of band and calling create_conversation from the initiating side.
  • You can create a group conversation with create_group_conversation, grow it with add_group_member, and exchange messages with all group members.
  • You can read back conversations and messages from earlier sessions as history.
  • You can integrate the full chat lifecycle—init, subscribe to events, share your address, open conversations, send, shut down—into any Logos C++ module.

Step 1: Scaffold a new 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.3.1#ui-qml-backend
  3. Initialise a Git repository and stage all generated files:

    git init && git add -A
  4. Keep the generated files—you build on them, you don't delete them. The template is a working ui_example module. Its main files are:

    FileWhat it isCan you edit it?
    src/ui_example_backend.{h,cpp}The C++ backendYes—your chat code goes here (Steps 3–7)
    src/ui_example.repThe interface between the backend and the viewLater—add the slots and properties your view needs
    src/qml/Main.qmlThe example viewLater—replace with your own UI
    metadata.json, flake.nixBuild configStep 2 only

    You add your chat code in UiExampleBackend::onContextReady(). Leave the example add / status UI as is for now.

info

Keep the module named ui_example. The name is referenced in metadata.json, CMakeLists.txt, the src/ui_example* files, and Main.qml (logos.module("ui_example"))—they must all match, or nix build fails. To use a different name, change it in every one of these.

Step 2: Declare chat_module as a dependency​

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

info

The flake input name (chat_module) must match the dependency name in metadata.json. logos-module-builder automatically generates the typed chat_module wrapper at build time.

  1. In metadata.json, replace the empty dependencies array with chat_module and delivery_module:

    {
    "name": "ui_example",
    "dependencies": [
    { "name": "chat_module", "version": ">=0.3.0" },
    { "name": "delivery_module", "version": ">=0.3.0" }
    ],
    ...
    }
  2. In flake.nix, pin chat_module, take its transport dependency delivery_module from it, then map the delivery input to the delivery_module dependency so the builder can resolve its runtime:

    inputs = {
    # Follow chat_module's own builder so the codegen chain matches the one the
    # module was released against.
    logos-module-builder.follows = "chat_module/logos-module-builder";
    # Pin chat_module to the released tag so its API can't shift under `nix flake update`.
    chat_module.url = "github:logos-co/logos-chat-module/v0.3.0";
    # Build against the exact delivery_module that chat_module v0.3.0 is built against.
    logos-delivery-module.follows = "chat_module/logos-delivery-module";
    };

    outputs = inputs@{ logos-module-builder, logos-delivery-module, ... }:
    logos-module-builder.lib.mkLogosQmlModule {
    src = ./.;
    configFile = ./metadata.json;
    # Map the delivery input to the `delivery_module` dependency name.
    flakeInputs = { delivery_module = logos-delivery-module; } // inputs;
    };
info

chat_module reaches delivery_module over IPC, but the builder still resolves each metadata.json dependency's runtime from a matching flake input. delivery_module therefore needs the logos-delivery-module input, mapped in through flakeInputs. Without it, nix build cannot resolve delivery_module. This mirrors how logos-chat-ui wires the two modules together.

Step 3: Subscribe to chat_module events​

The template's backend derives LogosUiPluginContext, which provides modules(): typed calls and event subscriptions for each module in dependencies. modules() is ready only once the framework calls onContextReady(), so subscribe there, and subscribe before calling init() so you do not miss early events or the first incoming messages.

  1. Override onContextReady() in the header src/ui_example_backend.h:

    class UiExampleBackend : public UiExampleSimpleSource,
    public LogosUiPluginContext
    {
    public:
    int add(int a, int b) override;
    void onContextReady() override;
    };
  2. In src/ui_example_backend.cpp, include the generated umbrella header and subscribe to the module's push events. Each on<Event> method takes a callback with the event's arguments in the order declared in chat_module.lidl:

    #include "logos_sdk.h" // generated umbrella—exposes modules().chat_module

    void UiExampleBackend::onContextReady()
    {
    auto& chat = modules().chat_module;

    // A new message arrived in a conversation. sender is the sender's account address.
    chat.onMessage_received([](const QString& convoId, const QString& content,
    qlonglong timestampMs, const QString& sender) { });
    // One of your own messages was handed to delivery and recorded.
    chat.onMessage_sent([](const QString& convoId, const QString& content,
    qlonglong timestampMs) { });
    // A conversation was created—incoming from a peer, or your own outgoing one.
    // kind is "direct" (1:1) or "group"; name and desc are empty for a direct one.
    chat.onConversation_created([](const QString& convoId, bool isOutgoing,
    const QString& peerLabel, const QString& kind,
    const QString& name, const QString& desc) { });
    chat.onConversation_updated([](const QString& convoId) { });
    chat.onMembers_changed([](const QString& convoId) { });
    chat.onConversation_deleted([](const QString& convoId) { });
    // Delivery/connection state changed—drives your "connected" indicator.
    // state is "initialising" | "online" | "stopped" | "error".
    chat.onDelivery_state_changed([](const QString& state, const QString& detail,
    bool deliveryAdopted) { });

    // Step 4 continues here: init().
    }

    See the Chat module API reference for the exact argument list of every method and event. It is generated from rust-lib/chat_module.lidl, the contract this tutorial's client is built from.

Step 4: Initialise the chat client​

Status-bearing methods return their result synchronously as a LogosResult.

  • res.success tells you whether the call succeeded;
  • res.getError<QString>() carries the failure reason;
  • res.getValue<QString>() carries the returned value (for example, the new conversation's id).

Ongoing activity—incoming messages, new conversations, delivery-state changes—arrives asynchronously through the push events you subscribed to in Step 3.

info

init() starts delivery asynchronously, so the client is not connected the moment init() returns. Watch delivery_state_changed for the online state before creating conversations or sending messages.

init() takes a single ChatConfig record, generated as the ChatModule::ChatConfig struct. Both fields are optional:

FieldTypeNotes
delivery_presetstringThe delivery network to join. Absent or empty means logos.test, the Logos test network. Must match across all participants.
log_levelstringThe chat core's own log verbosity: error, warn, info, debug, or trace (default info)
  1. Initialise the chat client, at the end of onContextReady():

    ChatModule::ChatConfig config;
    config.delivery_preset = QStringLiteral("logos.test");
    config.log_level = QStringLiteral("info");
    const LogosResult res = chat.init(config);
    if (!res.success) {
    qWarning() << "init failed:" << res.getError<QString>();
    return;
    }
    // init succeeded; delivery connects asynchronously.
    // Wait for delivery_state_changed with state == "online" before creating conversations.
    • delivery_module runs one node per process, shared by every module that uses it. If another module already created that node, chat_module uses it as it is, with that module's preset, and reports deliveryAdopted == true.
  2. Read (or set) your installation's display name. It is saved in the instance directory and not shown to peers:

    const QString myName = chat.get_installation_name();
    // Optionally choose a name: chat.set_installation_name("alice");
  3. Share your address:

    const QString myAddress = chat.get_address();
    // share `myAddress` out of band (the peer pastes it—see Steps 9 and 10)
    • The address is your account's address. Each init() creates a new account, so the address changes on every launch.

Step 5: Direct conversations​

A direct conversation is 1:1 - the initiator opens it with the peer's address, and messages flow both ways end-to-end encrypted.

info

To create a group conversation, skip this step and proceed to Step 6.

  1. Open the conversation as the initiator, or receive it as the recipient:

    const LogosResult res = modules().chat_module.create_conversation(peerAddress);
    const QString convoId = res.getValue<QString>();
    // On success a conversation_created event (isOutgoing == true) fires with the same convoId.
    • The initiator calls create_conversation with the peer's address (their get_address()).
    • The recipient does not call anything; a conversation_created push event (isOutgoing == false) arrives automatically once the invite lands.
  2. Send a message:

    modules().chat_module.send_message(convoId, "How are you?");
    // On success a message_sent event fires locally; the peer receives a message_received event.
    • Message content is plain text in both directions—the module handles encoding and end-to-end encryption on the wire.
    • Success means the message was handed to delivery_module, not that a peer received it.
  3. Receive messages through the message_received event.

Step 6: Group conversations​

A group conversation starts with its creator as the only member and grows one peer at a time. Unlike a direct conversation, membership changes are asynchronous by design: an add is committed by the group some time after it is proposed.

  1. Create the group:

    const LogosResult res = modules().chat_module.create_group_conversation("Book Club", "Weekly sci-fi picks");
    const QString groupId = res.getValue<QString>(); // the conversation id every member will share
    • The group starts with you as its only member. The name and description are shared metadata, carried to every joiner. Note that this metadata is only set once at the moment of group creation and cannot be edited later.
  2. Grow the group one member at a time:

    modules().chat_module.add_group_member(groupId, peerAddress);
    // Returns once the add is *proposed*; the group commits it asynchronously.
    • add_group_member returns as soon as the add is proposed. The process takes a few minutes, then the peer will see the group chat on their side.
    • The invited peer does not call anything; a conversation_created event (kind == "group", carrying the group's shared name and description) arrives once the welcome lands.
    • Membership is symmetric: any member can propose an add, not just the creator.
    • A members_changed event fires on every membership change.
    • Members can not be removed.
  3. Send a message to the group:

    modules().chat_module.send_message(groupId, "hello group");
    // One send reaches every member; each receiver gets a message_received event.
  4. Receive group messages through the same message_received event.

Step 7: Read state and shut down​

  1. Read history and conversation state at any time (synchronous reads):

    auto& chat = modules().chat_module;
    const QList<ChatModule::Conversation> convos = chat.list_conversations();
    const QList<ChatModule::Message> msgs = chat.get_messages(convoId);
    const ChatModule::Status st = chat.status(); // convo_count, delivery_state, detail, delivery_adopted
    • Optional record fields are std::optional—for example, convo.name.value_or(QString()) or msg.sender.value_or(QString()).
    • A conversation from an earlier session has history_only == true. You can read it, set its nickname, and delete it, but sending into it or inviting to it fails. A member inviting this installation back into such a group makes it live again.
    • After a restart, get_messages returns a conversation's newest 500 messages.
    warning

    Do not make a synchronous module read (list_conversations, get_messages, status) from inside an event handler—it re-enters the IPC replica while its read notifier is disabled and stalls until the call times out. Defer the read to the next event-loop turn instead (see deferToEventLoop in logos-chat-ui).

  2. Read a group conversation roster at any time:

    const QList<ChatModule::GroupMember> members = modules().chat_module.list_group_members(groupId);
    // Each element: { address, pending }—committed members first, then invites
    // this instance sent that the group has not committed yet (pending == true).
    • The pending flag clears when the group commits that add. An unknown conversation id reports an empty roster.
  3. Shut down cleanly:

    modules().chat_module.shutdown(); // disconnects and tears the client down
    • shutdown() ends the installation. The next init() comes up with a new address, and this session's conversations read back as history_only.

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 # preview via logos-standalone-app (for ui_qml modules)
    nix build .#lgx # package as .lgx for installation into logos-basecamp

Step 9: Verify a two-instance direct exchange​

A chat is only proven end to end when a message travels between two running instances. Start two copies of your module—each with its own instance directory—and confirm a message lands as a message_received event. Per-instance isolation comes from the host's instance directory (for example nix run . -- --user-dir ~/.local/share/chat_a).

  1. Start both instances and wait until each reports delivery_state == "online" via delivery_state_changed.
  2. In instance A: call get_address() and share the returned address out of band (copy it into instance B).
  3. In instance B: call create_conversation(addressFromA).
    • Instance B sees a conversation_created event (isOutgoing == true); instance A should receive one with isOutgoing == false once the invite lands.
  4. In instance B: send the first message: send_message(convoId, "Hello from B").
    • Instance A should receive a message_received event carrying "Hello from B".
  5. In instance A: reply with send_message(convoId, "Hi B").
    • Instance B should receive the matching message_received event.

Seeing the message_received events on both sides confirms the full round trip: identity, address exchange, key discovery, conversation setup, and end-to-end encrypted delivery.

Step 10: Verify a three-instance group conversation​

Group semantics only show with three or more members. Start three instances (A, B, and C) by following the instructions in Step 9 and wait for all three to report online. Group joins land asynchronously (see Step 6)—budget minutes per membership change.

  1. Collect each instance's address via get_address().
  2. In instance A: call create_group_conversation("Book Club", "Weekly sci-fi picks") and keep the returned conversation id.
  3. In instance A: call add_group_member(groupId, addressOfB).
    • The call returns immediately; B joins once the group commits the add—B receives a conversation_created event (kind == "group", name == "Book Club") under the same conversation id A holds.
  4. In instance B: Once B has joined, call add_group_member(groupId, addressOfC).
    • C can join the same way.
  5. In each instance, call list_group_members(groupId) and wait until all three addresses appear with pending == false on every member. Each membership change also fires members_changed.
  6. In instance A: call send_message(groupId, "hello group").
    • B and C each receive one message_received event whose sender is A's address.
  7. In instance C: post a reply.
    • A and B should receive it, attributed to C's address—the newest member reaches the founding members and vice versa.

Troubleshooting the Logos Chat module​

Why does a method fail?​

The method returns a LogosResult with success == false and a reason in getError<QString>(). The most common cause is calling a conversation or message method before init() succeeded, or before delivery reached the online state. Call init() first, check res.success, and wait for a delivery_state_changed event with delivery_state == "online" before creating conversations or sending messages. Sending into or inviting to a history_only conversation also fails—see Why can't I send into a conversation after a restart?

Why do peers not connect or messages not propagate?​

The delivery_preset differs across instances, or delivery has not reached online. All participants must use the same preset (for example logos.test) to share a network, and each instance must report online via delivery_state_changed (or status()) before it can exchange messages. If deliveryAdopted is true, the node was created by another module and runs on that module's preset, not the one you passed to init(). All participants also need chat_module 0.3.0 or later: an instance on 0.2.x cannot reach one on 0.3.x. Each instance must also be able to reach the key-package registry at https://devnet.chat-kc.logos.co over HTTPS, which init() publishes to and create_conversation looks up. The registry is an API endpoint: opening its root URL in a browser returns 404 Not Found even when it is working. For delivery-level detail, check the log file named by get_log_path().

Why does send_message succeed but peers never receive the message?​

chat_module sends through delivery_module, which rate-limits with RLN (Rate-Limiting Nullifier) wherever the network preset enables it. delivery_module v0.3.0 and later enable RLN on logos.test, so sending there needs an active RLN membership; see Register an RLN membership from Basecamp. Without one, the node starts and receives, but delivery retries each send and then gives up.

Why hasn't an invited member joined the group yet?​

This is the designed behaviour, not a hang. An add_group_member call only proposes the add: the group agrees on it, the group's steward batches it into an MLS commit after a commit-inactivity window (60 seconds by default), and the welcome only travels after that commit—so a join lands minutes after the call over the live network. Until the commit, the invite shows in the proposer's list_group_members with pending == true. Poll list_conversations on the invited instance (or wait for its conversation_created event) rather than assuming failure. An invite the group never commits stays pending for the life of the conversation.

In a group of two, once either member has restarted, a new add never completes: the commit needs both members, and the restarted member's old address never votes.

Why is send_message rejected right after a membership change?​

Briefly after a membership change commits, the group is finalising its new epoch and rejects sends. Retry after a few seconds.

Why does a read stall the UI?​

You issued a synchronous module read (list_conversations, get_messages, status) from inside an event handler. That re-enters the IPC replica while its read notifier is disabled and blocks until the call times out. Defer such reads to the next event-loop turn.

Why can't I send into a conversation after a restart?​

Each launch is a new installation with a new address. Conversations from earlier sessions come back from the instance directory with history_only == true: you can read their messages, but not send into them or invite to them. To talk again, share your new address and open a new direct conversation, or have a group member invite your new address back into the group. Peers who still write to your old address are not told it changed. Restoring live conversations across restarts is planned for a later release.