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
- 1Scaffold the project
cargo new --bin p2p-chat cd p2p-chat - 2Declare 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" - 3Write the chat node
Builds a
WeaveNode, opens a Forum namedchat, 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) } - 4Run two peers
# terminal 1 cargo run -- alice bob# terminal 2 cargo run -- bob aliceEach terminal prints its DID on startup. Use the printed DID to address the peer.
- 5Send 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?
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
takeonly 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
takewas 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
appendevents 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-streamsession 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.