Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Two programs talk through the daemon

Example · for anyone running Pulse Mesh for the first time, in about fifteen minutes

daemonpmsrustpythonnodeone write in, every subscriber out
Four ways in, one daemon between them

You will have: a daemon on your machine, and messages moving through it from the command line, from two Rust programs, and from a Python publisher to a Node subscriber. You will have seen every message named by a hash of its content, the same name on every side, and what a subscriber misses while it is away.

You need: the daemon repository set up as Start here describes, with pms and mesh-hub installed and the Rust toolchain. For the other-language step, Python 3 with pip and Node 22 or later with npm; nothing in that step is compiled.

Hand this to your agent

Run the example "Two programs talk through the daemon" from book/src/examples/two-programs.md in the pulse-mesh-daemon repository:
1. Start a daemon in the background: pms start. It prints the process id and its log file. Confirm the log shows "websocket listening".
2. In the background, run pms tap --channel 'jobs/*' and keep its output. Then run pms push jobs/new 'first job' and show me the envelope_id it prints and the line the tap printed; the eight characters after "jobs/new |" in the tap line must be the start of that envelope_id.
3. Run pms push jobs/new 'first job' again and confirm the envelope_id is identical. Run pms push jobs/other 'first job' and confirm it differs.
4. Build and run the two Rust programs: cargo run --release -p multi-process --bin receiver in the background, wait for "attached and subscribed", then cargo run --release -p multi-process --bin sender. Show me that each "sender: sent 'job-N' as envelope X" has a matching "receiver: got 'job-N' (envelope X, 5 bytes)".
5. Install the thin clients, pip install -e clients/pms-py-thin and npm install --prefix peripheral/clients/pms-node-thin. Run node examples/multi-lang/node_subscriber.js in the background, then python3 examples/multi-lang/python_publisher.py, and show me that the identifiers match across the two languages.
6. Run pms ring inspect default_hub --count 2 and tell me what the cursor says about how many Pulses the daemon has written.
7. Stop everything you started, including the daemon (kill the process id from step 1).
Explain in plain words what each step showed. Stop and tell me if anything fails, and say which step.

Or do it by hand

1. Start the daemon

In a first terminal:

pms start --foreground
INFO uds listening path=/tmp/pms.sock
INFO websocket listening addr=127.0.0.1:9876

Those are the two endpoints clients find on their own: pms and the Rust client try the Unix socket, then the WebSocket port, unless PMS_DAEMON names an endpoint (unix:///path or ws://host:port). Leave this terminal alone. Without --foreground, pms start detaches the daemon, prints its process id and log file (~/.local/state/pms/mesh-hub.log), and returns; stop a detached daemon with kill and that id.

2. Watch a channel

In a second terminal:

pms tap --channel 'jobs/*'
tapped; Ctrl-C to stop

tap attaches under a generated name and subscribes to every channel matching jobs/*. It prints one line per event for as long as it runs. Leave it running.

3. Publish from the command line

In a third terminal:

pms push jobs/new 'first job'
envelope_id=cb05c6b8f4b696551a0b7245b6f104a2

and the tap prints:

            jobs/new |        9B | cb05c6b8 | @6b19f89f

Left to right: the channel, the payload size, the first eight characters of the envelope identifier, and the origin, the short form of the publisher’s node id. Now publish three more:

pms push jobs/new 'first job'
pms push jobs/other 'first job'
pms push jobs/new 'first job' --type-name job.created
envelope_id=cb05c6b8f4b696551a0b7245b6f104a2
envelope_id=329b893bbb06850cd40679fd0a2d98d5
envelope_id=759d3a7c705c4d63cba9a36194fc980c

The first identifier is the same as before, because the content is the same: the identifier is computed from the channel, the type name and the payload, and from nothing else. Change the channel and it changes; add a type name and it changes.

4. Two Rust programs

The two programs are in the checkout under examples/multi-process/. The receiver:

//! Template: a receiver process subscribing to channel `jobs`.
//!
//! Pairs with `sender.rs` — see that file's docs for the run order.

#![allow(clippy::print_stdout, clippy::print_stderr)] // an example prints what it does

use mesh_client::Mesh;

fn main() {
    if let Err(e) = receive() {
        eprintln!("receiver: {e}");
        std::process::exit(1);
    }
}

fn receive() -> Result<(), Box<dyn std::error::Error>> {
    let mesh = Mesh::attach("multi-process-receiver")?;
    mesh.on("jobs", |ev| {
        let payload = String::from_utf8_lossy(&ev.payload);
        println!(
            "receiver: got '{}' (envelope {}, {} bytes)",
            payload,
            &ev.envelope_id[..ev.envelope_id.len().min(8)],
            ev.payload.len()
        );
    })?;
    println!("receiver: attached and subscribed to 'jobs' (Ctrl-C to stop)");
    // Returns only when the connection ends; a lost daemon is an error here.
    mesh.run()?;
    Ok(())
}

Mesh::attach connects to the daemon and registers the process under the name you give it. mesh.on subscribes to one channel and hands every event on it to the closure for as long as mesh.run() runs. The sender:

//! Template: a sender process publishing to channel `jobs` once per second.
//!
//! Run with the daemon already running:
//!
//!     # Terminal 1
//!     cargo run -p mesh-hub --release
//!
//!     # Terminal 2
//!     cargo run -p multi-process --bin receiver
//!
//!     # Terminal 3
//!     cargo run -p multi-process --bin sender

#![allow(clippy::print_stdout, clippy::print_stderr)] // an example prints what it does

use mesh_client::Mesh;
use std::thread;
use std::time::Duration;

fn main() {
    if let Err(e) = send_jobs() {
        eprintln!("sender: {e}");
        std::process::exit(1);
    }
}

fn send_jobs() -> Result<(), Box<dyn std::error::Error>> {
    let mesh = Mesh::attach("multi-process-sender")?;
    println!("sender: attached");
    for i in 0..10 {
        let payload = format!("job-{i}");
        let env_id = mesh.send("jobs", payload.as_bytes())?;
        println!(
            "sender: sent '{}' as envelope {}",
            payload,
            &env_id[..env_id.len().min(8)]
        );
        thread::sleep(Duration::from_millis(1000));
    }
    println!("sender: done");
    Ok(())
}

mesh.send publishes the bytes on the channel and returns the identifier the daemon computed. Start the receiver in the third terminal:

cargo run --release -p multi-process --bin receiver
receiver: attached and subscribed to 'jobs' (Ctrl-C to stop)

and the sender in a fourth:

cargo run --release -p multi-process --bin sender
sender: attached
sender: sent 'job-0' as envelope 796abc6e
sender: sent 'job-1' as envelope 5fba33ce
sender: sent 'job-2' as envelope 4166531f
receiver: got 'job-0' (envelope 796abc6e, 5 bytes)
receiver: got 'job-1' (envelope 5fba33ce, 5 bytes)
receiver: got 'job-2' (envelope 4166531f, 5 bytes)

The identifiers match line for line because both sides name the same envelope, and job-0 has the same identifier on every run. After ten messages the sender prints sender: done and exits; the receiver keeps running.

5. The same exchange from Python and Node

Install the two thin clients, each a single file with six calls (attach, send, on, tap, run, detach) and no codec: the daemon computes every identifier.

pip install -e clients/pms-py-thin
npm install --prefix clients/pms-node-thin

Start the Node subscriber:

node examples/multi-lang/node_subscriber.js
node: attached; listening on multi-lang/demo (Ctrl-C to stop)

and, in another terminal, the Python publisher:

python3 examples/multi-lang/python_publisher.py
py: attached
py: sent b'hello-from-python-0' as fe531358
py: sent b'hello-from-python-1' as a2bcbef7
node: got 'hello-from-python-0' (envelope fe531358)
node: got 'hello-from-python-1' (envelope a2bcbef7)

Run pms push multi-lang/demo hello-from-python-0 and it prints the same identifier, fe531358…: three languages, one name for one content.

6. Look at the ring underneath

pms ring inspect default_hub --count 2
ring          /pms-default_hub
capacity      64 slots
write_count   …
cursor        …
recent        2 pulse(s):
  slot=    …  inst=DATA        mono_ns=     …  has_envelope=true
  slot=    …  inst=DATA        mono_ns=     …  has_envelope=true

The ring is a shared-memory object named after the hub. Every envelope published through the daemon writes one 128-byte Pulse into it, and the cursor counts them. has_envelope is true on every slot: the Pulse carries the envelope’s identifier, and a second process attached to the hub fetches the envelope from the store beside the ring by that identifier. That is how envelopes cross processes.

7. Stop the daemon

Ctrl-C in the first terminal. The daemon removes its socket and unlinks its shared memory as it exits.

Check it worked

CheckCommandPass
The command line, Rust, Python and Node all name content the same wayCompare pms push’s envelope_id, the sender’s and receiver’s lines, and the Python and Node linesThe same eight characters on every side of each message
Identity is contentpms push jobs/new 'first job' twice, then on jobs/otherIdentical identifiers, then a different one
The daemon counts what it carriedpms ring inspect default_hub --count 2cursor equals the number of envelopes published since the daemon started
A subscriber away misses what was sentRun the sender again; stop the receiver at job-2, wait two seconds, restart itThe receiver prints from job-5 or job-6 on, and nothing for the gap

If pms push reports it cannot reach a daemon, none is running or PMS_DAEMON points elsewhere; the daemon’s log names its endpoints. If the Node subscriber or Python publisher fails to connect, PMS_DAEMON must be a ws:// address or unset: the thin clients speak WebSocket only.

What happened

  • Identity is content. The daemon names every envelope by the BLAKE3 hash of its canonical bytes: channel, type name and payload. Two envelopes with identical content share one name, in every language, so a receiver can check what it got without trusting who sent it.
  • A subscription is a live stream. It delivers from the moment the daemon acknowledges the subscribe request, and the daemon keeps nothing for a subscriber that is not connected; the messages sent in a gap are gone for that subscriber. A subscriber that stays connected but stops reading is a different case: the daemon queues up to a fixed number of events for it (--subscriber-queue, 1,024 by default), drops newer ones beyond that, and reports how many when the subscription ends.
  • Every subscriber gets every matching message. The daemon broadcasts to the subscriptions whose filter matches; it does not pick one.
  • The ring is the signal, the store is the content. One 128-byte Pulse per envelope goes into the shared-memory ring; the envelope itself sits in the store beside it, fetched by identifier.

What the daemon does not do: it has no authentication and no encryption, so it listens on loopback by default and should stay there unless a deployment wraps it; it keeps no history for late subscribers (the basket, in Capture envelopes and query them, is the record); and mesh_health in the MCP server reports which names are attached, not whether they are alive.

Make it yours

  • See a whole event: stop the tap and start it with --json; each event is one JSON object with the channel, the identifier, the origin, the capture time and the payload size.
  • Speak the protocol yourself: in a browser’s console, open new WebSocket('ws://127.0.0.1:9876', ['pms-universal-port-v0']) and send three JSON frames, attach, subscribe and send, with a base64 payload; four come back, three acks and the event for your own envelope. The port protocol is the whole contract.
  • Type your messages: --type-name on pms push, or the type_name argument in any client, becomes part of the identifier and a filter a subscriber can use.

Next

Spoken walkthrough

A script for the video and audio version of this page, about two minutes.

One daemon, and four ways to talk to it.

First the command line. We start the daemon, open a tap on the jobs channels, and push a message. The tap shows it arrive, and both sides print the same eight characters: the start of the message’s identifier. Push the same message again and the identifier is the same. Push it on another channel and it changes. That identifier is a hash of the content, so identity is the content, not who sent it or when.

Next, two Rust programs, each about a screen long. The receiver attaches and subscribes. The sender attaches and publishes ten jobs. Every job the sender names, the receiver names the same way.

Now Python and Node. Two thin clients, no codec in either; the daemon does the naming. A Python publisher, a Node subscriber, and the identifiers still match.

Finally we look underneath. Every message put one 128-byte Pulse into a shared-memory ring, and the ring’s cursor counts them. The Pulse carries the identifier; the content sits in a store beside the ring.

One thing to try: stop the receiver while the sender is running, then start it again. It picks up from the next job and never sees the ones sent while it was away. A subscription is a live stream. The record of what went by is the basket, which the next example uses.