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.
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.
-
A supported OS:
- Linux
- macOS
-
Network access.
-
Nix with flakes enabled.
- Install from nixos.org, then enable flakes:
mkdir -p ~/.config/nixecho '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_conversationfrom the initiating side. - You can create a group conversation with
create_group_conversation, grow it withadd_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.
-
Create and enter the project directory:
mkdir your-module-name && cd your-module-name -
Initialise from the template:
nix flake init -t github:logos-co/logos-module-builder/0.3.1#ui-qml-backend -
Initialise a Git repository and stage all generated files:
git init && git add -A -
Keep the generated files—you build on them, you don't delete them. The template is a working
ui_examplemodule. Its main files are:File What it is Can you edit it? src/ui_example_backend.{h,cpp}The C++ backend Yes—your chat code goes here (Steps 3–7) src/ui_example.repThe interface between the backend and the view Later—add the slots and properties your view needs src/qml/Main.qmlThe example view Later—replace with your own UI metadata.json,flake.nixBuild config Step 2 only You add your chat code in
UiExampleBackend::onContextReady(). Leave the exampleadd/statusUI as is for now.
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.
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.
-
In
metadata.json, replace the empty dependencies array withchat_moduleanddelivery_module:{"name": "ui_example","dependencies": [{ "name": "chat_module", "version": ">=0.3.0" },{ "name": "delivery_module", "version": ">=0.3.0" }],...} -
In
flake.nix, pinchat_module, take its transport dependencydelivery_modulefrom it, then map the delivery input to thedelivery_moduledependency 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;};
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.
-
Override
onContextReady()in the headersrc/ui_example_backend.h:class UiExampleBackend : public UiExampleSimpleSource,public LogosUiPluginContext{public:int add(int a, int b) override;void onContextReady() override;}; -
In
src/ui_example_backend.cpp, include the generated umbrella header and subscribe to the module's push events. Eachon<Event>method takes a callback with the event's arguments in the order declared inchat_module.lidl:#include "logos_sdk.h" // generated umbrella—exposes modules().chat_modulevoid 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.successtells 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.
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:
| Field | Type | Notes |
|---|---|---|
delivery_preset | string | The delivery network to join. Absent or empty means logos.test, the Logos test network. Must match across all participants. |
log_level | string | The chat core's own log verbosity: error, warn, info, debug, or trace (default info) |
-
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_moduleruns one node per process, shared by every module that uses it. If another module already created that node,chat_moduleuses it as it is, with that module's preset, and reportsdeliveryAdopted == true.
-
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"); -
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.
- The address is your account's address. Each
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.
To create a group conversation, skip this step and proceed to Step 6.
-
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_conversationwith the peer's address (theirget_address()). - The recipient does not call anything; a
conversation_createdpush event (isOutgoing == false) arrives automatically once the invite lands.
- The initiator calls
-
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.
-
Receive messages through the
message_receivedevent.
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.
-
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.
-
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_memberreturns 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_createdevent (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_changedevent fires on every membership change. - Members can not be removed.
-
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. -
Receive group messages through the same
message_receivedevent.
Step 7: Read state and shut down
-
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())ormsg.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_messagesreturns a conversation's newest 500 messages.
warningDo 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 (seedeferToEventLoopinlogos-chat-ui). - Optional record fields are
-
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
pendingflag clears when the group commits that add. An unknown conversation id reports an empty roster.
- The
-
Shut down cleanly:
modules().chat_module.shutdown(); // disconnects and tears the client downshutdown()ends the installation. The nextinit()comes up with a new address, and this session's conversations read back ashistory_only.
Step 8: Build and run
-
Build the module:
nix build -
Preview the module using
logos-standalone-app(forui_qmlmodules):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).
- Start both instances and wait until each reports
delivery_state == "online"viadelivery_state_changed. - In instance A: call
get_address()and share the returned address out of band (copy it into instance B). - In instance B: call
create_conversation(addressFromA).- Instance B sees a
conversation_createdevent (isOutgoing == true); instance A should receive one withisOutgoing == falseonce the invite lands.
- Instance B sees a
- In instance B: send the first message:
send_message(convoId, "Hello from B").- Instance A should receive a
message_receivedevent carrying"Hello from B".
- Instance A should receive a
- In instance A: reply with
send_message(convoId, "Hi B").- Instance B should receive the matching
message_receivedevent.
- Instance B should receive the matching
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.
- Collect each instance's address via
get_address(). - In instance A: call
create_group_conversation("Book Club", "Weekly sci-fi picks")and keep the returned conversation id. - 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_createdevent (kind == "group",name == "Book Club") under the same conversation id A holds.
- The call returns immediately; B joins once the group commits the add—B receives a
- In instance B: Once B has joined, call
add_group_member(groupId, addressOfC).- C can join the same way.
- In each instance, call
list_group_members(groupId)and wait until all three addresses appear withpending == falseon every member. Each membership change also firesmembers_changed. - In instance A: call
send_message(groupId, "hello group").- B and C each receive one
message_receivedevent whosesenderis A's address.
- B and C each receive one
- 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.