OpenLogi tarpc/bincode IPC Protocol Contract: Desktop-to-Agent Communication

OpenLogi's desktop application communicates with its background agent through a tarpc RPC service using bincode serialization, defined by the Agent trait in crates/openlogi-ipc/src/ipc.rs and governed by a strict PROTOCOL_VERSION constant that must match exactly between client and server.

OpenLogi relies on a deterministic binary IPC protocol to synchronize device state and commands between its desktop UI and the background agent process. This article examines the complete tarpc/bincode contract implemented in the AprilNEA/OpenLogi repository, detailing the versioned wire format, generation-based state observation, and the specific RPC methods that enable real-time device management across UNIX domain sockets.

Protocol Foundation: The Agent Trait and Wire Format

The entire IPC contract lives in the Agent trait defined in crates/openlogi-ipc/src/ipc.rs. Because tarpc encodes RPC calls as method indices based on trait definition order, the sequence of methods in this file constitutes the wire format. Changing method order breaks binary compatibility, making this trait the canonical source of truth for the protocol.

The trait defines a protocol version constant that both sides check before accepting connections:

pub const PROTOCOL_VERSION: u32 = 26;

This value increments for every breaking change to the wire format. The GUI validates this version immediately after connecting, aborting with an OutdatedGui error if the agent reports a newer version.

Handshake and Method Ordering Constraints

The protocol_version() method must remain first in the trait definition because tarpc assigns method indices sequentially. The desktop client calls this immediately after connecting to verify compatibility:

async fn protocol_version() -> u32;

If this method were moved, the binary encoding would shift, causing immediate protocol mismatch between old clients and new agents.

State Synchronization via Generation-Based Observation

OpenLogi uses a generation-based long-polling pattern to keep the UI synchronized with agent state without websockets or streaming complexity.

The observe Method and OBSERVE_HOLD

The core state channel is the observe method:

async fn observe(since: Generation) -> Observation;

The agent holds this request for up to OBSERVE_HOLD (20 seconds) before returning:

pub const OBSERVE_HOLD: Duration = Duration::from_secs(20);

If the state changes during this window, the agent returns immediately with the new AgentSnapshot and an incremented Generation value. If the timeout expires, it returns the unchanged state as a heartbeat, allowing the client to detect connection health while minimizing CPU usage.

The desktop implementation in crates/openlogi-desktop/src/services/ipc.rs maintains a single observe request in flight continuously, updating the UI only when the returned generation differs from the last seen value.

Atomic State Snapshots

To avoid race conditions between separate status and inventory calls, the protocol includes an atomic snapshot RPC added in protocol v4:

async fn snapshot() -> AgentSnapshot;

This returns the complete agent status and device inventory in a single call, ensuring consistency when the UI initializes or recovers from reconnection.

Device Control and Configuration RPCs

Beyond state observation, the Agent trait exposes fire-and-forget methods for hardware control, all returning Result<(), WriteError>:

async fn set_dpi(route: DeviceRoute, dpi: Dpi) -> Result<(), WriteError>;
async fn set_lighting(route: DeviceRoute, lighting: Lighting) -> Result<(), WriteError>;
async fn set_smartshift(route: DeviceRoute, enabled: bool) -> Result<(), WriteError>;
async fn set_light(enabled: bool) -> Result<(), WriteError>;

These methods accept strongly-typed parameters defined in crates/openlogi-core/src/, such as DeviceRoute and Dpi, which are serialized via bincode into compact binary messages.

Configuration Hot-Reload

The UI can request the agent to re-parse its config.toml without restart:

async fn reload_config() -> Result<(), ConfigReloadError>;

This enables the settings UI to apply changes immediately while maintaining the long-lived agent process.

Pairing and Overlay Workflows

The protocol supports complex multi-step workflows for pairing Logi Bolt receivers and managing the Actions-Ring overlay.

Bolt Receiver Pairing

The pairing workflow uses three primary RPCs:

async fn start_pairing(selector: ReceiverSelector) -> Result<(), PairingCommandError>;
async fn pair_device(pin: String) -> Result<(), PairingCommandError>;
async fn cancel_pairing() -> Result<(), PairingCommandError>;

Progress streams through the same observe state channel via AgentSnapshot::pairing, though legacy implementations may poll the deprecated next_pairing() method.

Actions-Ring Overlay State

For the overlay helper process, the protocol exposes a parallel generation-based observer:

async fn observe_action_ring(since: Generation) -> RingObservation;
async fn action_ring_hover(index: u8);
async fn action_ring_activate();
async fn action_ring_cancel();

This allows the overlay to maintain synchronized state with the agent independently of the main desktop UI.

Error Handling with Append-Only Enums

The protocol maintains backwards compatibility through append-only error enums. New failure modes are added as variants without removing existing ones:

  • WriteError for device command failures
  • PairingCommandError for pairing workflow errors
  • ActionRingCommandError for overlay failures
  • PairingFailure for specific pairing step failures

This strategy ensures that old GUI binaries can still deserialize responses from newer agents, even if they cannot handle new error variants explicitly.

Desktop Client Implementation Reference

The client-side contract implementation resides in crates/openlogi-desktop/src/services/ipc.rs. This module handles:

  1. Connection establishment via openlogi_ipc::client::connect() to the agent's UNIX socket
  2. Version validation in the ensure() function, which compares the agent's reported version against PROTOCOL_VERSION
  3. Long-poll management through observe_loop, which maintains the continuous observe request and converts Observation structs into GuiUpdate events
  4. Command dispatch for device control RPCs, mapping transport errors to reconnection logic and device errors to UI notifications
  5. Identity tracking via the identity() RPC to detect agent restarts and trigger full state resynchronization

The generated tarpc client stub implements the Agent trait methods, handling bincode serialization and socket transport transparently.

Code Examples

Connecting and Verifying Protocol Version

use openlogi_ipc::client;
use tarpc::context;

#[tokio::main]
async fn main() {
    // Connect (handshake includes protocol version)
    let conn = client::connect().await.expect("cannot reach agent");
    let version = conn.client.protocol_version(context::current()).await
        .expect("rpc error");
    println!("Agent protocol version = {}", version);
}

Observing State Changes with Generation Tracking

use openlogi_ipc::{AgentClient, Observation, Generation, OBSERVE_HOLD};
use tarpc::context;
use std::time::Instant;

async fn observe_state(mut client: AgentClient) {
    let mut seen: Generation = 0;
    loop {
        let mut ctx = context::current();
        ctx.deadline = Instant::now() + OBSERVE_HOLD + std::time::Duration::from_secs(5);
        match client.observe(ctx, seen).await {
            Ok(obs) => {
                // New generation → new snapshot
                if obs.generation != seen {
                    println!("New snapshot: {:?}", obs.snapshot);
                    seen = obs.generation;
                }
            }
            Err(_) => {
                eprintln!("Lost connection – reconnecting");
                break; // in production code you would retry
            }
        }
    }
}

Changing Device DPI

use openlogi_ipc::client;
use openlogi_core::hid::{DeviceRoute, Dpi};
use tarpc::context;

async fn set_device_dpi(route: DeviceRoute, dpi: Dpi) {
    let conn = client::connect().await.expect("no agent");
    let res = conn.client.set_dpi(context::current(), route, dpi).await;
    match res {
        Ok(Ok(())) => println!("DPI applied"),
        Ok(Err(e)) => eprintln!("Device rejected DPI: {e:?}"),
        Err(_) => eprintln!("Agent unavailable"),
    }
}

Initiating Bolt Pairing

use openlogi_ipc::client;
use openlogi_core::hid::ReceiverSelector;
use tarpc::context;

async fn start_pairing(selector: ReceiverSelector) {
    let conn = client::connect().await.unwrap();
    // Initiate pairing
    conn.client.start_pairing(context::current(), selector).await.unwrap();

    // Stream pairing steps – legacy `next_pairing` (still part of the wire format)
    loop {
        match conn.client.next_pairing(context::current()).await {
            Some(update) => println!("Pairing step: {:?}", update),
            None => break, // hold window elapsed with no new event
        }
    }
}

Summary

  • The contract is defined by the Agent trait in crates/openlogi-ipc/src/ipc.rs, where method order determines the tarpc wire encoding.
  • Version compatibility is enforced through the PROTOCOL_VERSION constant (currently 26), checked immediately upon connection in the desktop client's ensure() function.
  • State synchronization uses generation-based long-polling via the observe method with a 20-second OBSERVE_HOLD timeout, returning AgentSnapshot data when generations change.
  • Device commands are strongly-typed RPCs like set_dpi and set_lighting that return Result<(), WriteError> for immediate feedback.
  • Backwards compatibility is maintained through append-only error enums and strict protocol versioning, allowing independent updates of the agent and desktop components.

Frequently Asked Questions

What serialization format does OpenLogi use for IPC?

OpenLogi uses bincode for binary serialization of all RPC messages. The tarpc framework handles the transport layer, while bincode provides compact, deterministic encoding for the Agent trait's method arguments and return types.

Why must the protocol_version method remain first in the Agent trait?

Tarpc encodes RPC calls as method indices based on the trait definition order. Since the desktop client calls protocol_version() immediately after connecting to verify compatibility, moving this method would shift its index in the wire format, causing existing binaries to call the wrong method. The source code in ipc.rs contains explicit comments warning that this method must stay first to preserve binary compatibility.

How does the generation-based observation pattern prevent missed state updates?

The observe RPC accepts a since: Generation parameter and blocks until the agent's internal generation counter exceeds this value. When the agent state changes, it increments the generation and immediately returns the new Observation to all waiting clients. If no change occurs within OBSERVE_HOLD (20 seconds), the agent returns the current state anyway, functioning as a heartbeat that allows clients to detect stalled connections while maintaining low latency for updates.

What happens if the desktop GUI and agent have mismatched protocol versions?

The desktop client checks the agent's reported version in the ensure() function within crates/openlogi-desktop/src/services/ipc.rs. If the agent returns a version higher than the client's PROTOCOL_VERSION constant, the client emits GuiUpdate::OutdatedGui and refuses to proceed, preventing undefined behavior from protocol mismatches and prompting the user to update the application.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →