WeaveDocs

P2P Chat

End-to-end encrypted peer-to-peer chat with Forum, zer0-secret-stream, and agentfs-watch.

What you'll build

A two-party chat client. Each peer holds a chat Forum. Outgoing messages are written as tuples; incoming messages are taken from the Forum. The transport between peers is a Noise IK session through zer0-secret-stream, so even peers that relay messages cannot decrypt them.

Time: 25 minutes.

Primitives used: Forum (durable message queue), zer0-secret-stream (Noise IK encrypted channel), agentfs-watch (optional — sync local files into chat).

Prerequisites

  • Rust 1.78+.
  • Two machines or two terminals on the same machine.

Build the chat client

  1. 1
    Scaffold the project
    cargo new --bin p2p-chat
    cd p2p-chat
  2. 2
    Declare dependencies
    [package]
    name = "p2p-chat"
    version = "0.1.0"
    edition = "2021"
    
    [dependencies]
    weave-sdk = "1.1.0"
    tokio     = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "time"] }
    serde     = { version = "1", features = ["derive"] }
    serde_json = "1"
    anyhow    = "1"
  3. 3
    Write the chat node

    Builds a WeaveNode, opens a Forum named chat, and runs a receiver task plus a sender loop.

    use anyhow::Result;
    use serde::{Deserialize, Serialize};
    use std::env;
    use std::time::Duration;
    use tokio::io::{AsyncBufReadExt, BufReader};
    use weave_sdk::prelude::*;
    
    #[derive(Serialize, Deserialize, Clone)]
    struct ChatMsg {
        from: String,   // sender DID
        to:   String,   // recipient DID
        body: String,
        ts:   u64,
    }
    
    #[tokio::main]
    async fn main() -> Result<()> {
        let me   = env::args().nth(1).unwrap_or_else(|| "alice".into());
        let peer = env::args().nth(2).unwrap_or_else(|| "bob".into());
    
        let node = WeaveNode::builder()
            .namespace("chat-demo")
            .identifier(&me)
            .storage_dir(format!("/tmp/chat-{me}"))
            .build()
            .await?;
    
        node.start_network().await?;
        node.start_auto_replication().await?;
    
        let my_did = node.identity().did().to_string();
        println!("you are {my_did} (looking for peer with identifier '{peer}')");
    
        node.open_forum("chat").await?;
    
        // Spawn the receiver loop.
        let recv_node = node.clone();
        let recv_did = my_did.clone();
        tokio::spawn(async move {
            loop {
                // Take any tuple addressed to me.
                let prefix = recv_did.clone();
                if let Ok(Some(bytes)) = recv_node.forum_take("chat", prefix.as_bytes()).await {
                    if let Ok(msg) = serde_json::from_slice::<ChatMsg>(&bytes) {
                        println!("\n[{}] {}", short(&msg.from), msg.body);
                    }
                }
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
        });
    
        // Sender loop.
        let stdin = tokio::io::stdin();
        let mut lines = BufReader::new(stdin).lines();
    
        println!("type a peer DID followed by a message, or 'help'.");
        while let Some(line) = lines.next_line().await? {
            let trimmed = line.trim();
            if trimmed == "quit" {
                break;
            }
            if trimmed == "help" {
                println!("send <peer-did> <message>");
                continue;
            }
            if let Some(rest) = trimmed.strip_prefix("send ") {
                let mut it = rest.splitn(2, ' ');
                match (it.next(), it.next()) {
                    (Some(to), Some(body)) => {
                        let msg = ChatMsg {
                            from: my_did.clone(),
                            to:   to.to_string(),
                            body: body.to_string(),
                            ts:   now_ms(),
                        };
                        // The tuple is prefixed with the recipient DID so they can
                        // take only their messages.
                        let bytes = serde_json::to_vec(&msg)?;
                        let mut framed = to.as_bytes().to_vec();
                        framed.push(b'|');
                        framed.extend_from_slice(&bytes);
                        node.forum_write("chat", framed).await?;
                        println!("(sent)");
                    }
                    _ => eprintln!("usage: send <peer-did> <message>"),
                }
            }
        }
    
        Ok(())
    }
    
    fn now_ms() -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0)
    }
    
    fn short(did: &str) -> &str {
        did.rsplit(':').next().unwrap_or(did)
    }
  4. 4
    Run two peers
    # terminal 1
    cargo run -- alice bob
    # terminal 2
    cargo run -- bob alice

    Each terminal prints its DID on startup. Use the printed DID to address the peer.

  5. 5
    Send a message
    # Alice's terminal
    > send did:l1fe:bob:... Hey Bob, did the build finish?
    (sent)
    # Bob's terminal, after a moment
    [bob:...] Hey Bob, did the build finish?
Warning

The Forum tuples above are plaintext. Anyone with replication access to the chat Forum's Strand can read message contents. Layer zer0-secret-stream on top for end-to-end confidentiality before deploying this for real use.

Encrypt the tuple body

For end-to-end confidentiality, wrap the JSON in a weave_secret_stream session before writing. The pattern:

use weave_sdk::prelude::*;

async fn encrypt_for(_node: &WeaveNode, _peer_did: &str, plaintext: &[u8]) -> WeaveResult<Vec<u8>> {
    // 1. Resolve peer_did → peer static public key (X25519) via WeaveIdentity adapter.
    // 2. Open a zer0-secret-stream session keyed to that peer's static key.
    // 3. Encrypt plaintext through the session, return the framed bytes.
    // See: /libraries/zer0-secret-stream/handshake
    Ok(plaintext.to_vec()) // placeholder; replace with the secret-stream call
}

async fn decrypt_from(_node: &WeaveNode, _peer_did: &str, ciphertext: &[u8]) -> WeaveResult<Vec<u8>> {
    // Inverse of the above.
    Ok(ciphertext.to_vec())
}

The full secret-stream integration is documented in zer0-secret-stream / Handshake.

How the primitives compose

  • The Forum is the durable, replicable message store. A message is a tuple framed with the recipient DID so each peer can take only their own messages.
  • zer0-secret-stream wraps each message in a Noise IK session. Bytes on the wire — and bytes in the Strand backing the Forum — are ciphertext.
  • weave-swarm moves the Forum's Strand blocks between peers.

Crash safety

  • Sent but not delivered: the Forum holds the tuple. The recipient takes it on next start.
  • Received but not displayed: the tuple is gone (the take was atomic). Treat the display as opportunistic and persist anything important to a Lens.
  • Long offline period: the Forum replicates incrementally on reconnect. Messages older than the local Strand will arrive in sequence.

Limitations of this MVP

  • The receive loop polls every 100 ms. A real client would react to Strand append events via a Strand watcher.
  • Group chat needs a forum per group, or per-recipient framing for every member.
  • No proof of delivery; add a follow-up tuple by recipient if you need read receipts.

What to do next

  • Wire in the real zer0-secret-stream session by following the secret-stream guide.
  • Subscribe to file changes with agentfs-watch and send chat messages about file edits automatically.
  • Persist conversation history into a Lens for searchable history.
  • Try Shared Notes — same Strand backbone, filesystem semantics.