This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A Python CLI port of bitchat, a serverless peer-to-peer chat over a Bluetooth LE mesh. It is wire-compatible with the real app (https://github.com/permissionlesstech/bitchat) for public/broadcast chat: it can exchange public text messages and media (images, voice notes, generic files) one-hop with a real bitchat phone in range. The binary packet format in protocol.py, the file-transfer TLV in media.py, and the BLE service/characteristic UUIDs in ble_service.py mirror the upstream Swift implementation — do not change those without re-checking against upstream source, or interop breaks.
Deliberately out of scope (not implemented): Noise_XX private messaging, mesh relaying, and acting as a BLE peripheral/advertiser. These are infeasible or very large in pure-Python bleak, which is central-only — see "Constraints" below.
The app lives in the bitchat_cli/ package (installable; see pyproject.toml).
pip install -e . # editable install of bleak, cryptography, prompt-toolkit
bitchat-cli # (or `bitchat`, or `python -m bitchat_cli`) run the app; scans over BLE
python selftest.py # offline protocol round-trip + signature tests (no BLE needed)selftest.py is the only thing runnable without hardware; it imports the bitchat_cli package, so run it from the repo root. The app requires a working Bluetooth LE adapter — without one it starts, prints the logo, and reports that the scan could not start. selftest.py needs only cryptography (not bleak/prompt-toolkit), so it runs in CI/headless. Published to PyPI as bitchat-cli; version is single-sourced in bitchat_cli/__init__.py.
In-app slash commands: /w (list known peers by peer-ID → nickname), /name <nick> (change nickname + re-announce), /send <path> (broadcast an image/voice note/file; alias /file), /clear, /help. Any non-slash input is broadcast publicly to connected peers.
Single-process asyncio app. main.py → random 8-byte peer ID + user-xxxx nickname → ChatState → CLI.run().
protocol.py— pure serialization, no I/O. The real bitchat wire format:BitchatPacket.encode()/decode(): big-endian headerversion(1) type(1) ttl(1) timestamp_ms(8) flags(1) payloadLen(2)+senderID(8)+ optionalrecipientID(8)+ payload + optionalsignature(64), then PKCS#7MessagePaddingto a block size (256/512/1024/2048). Decode also handles v2 (16-byte header, source-route hops it skips) and zlib-compressed inbound payloads.MessageTypeopcodes match upstream:ANNOUNCE=0x01,MESSAGE=0x02(public, unencrypted),LEAVE=0x03,NOISE_HANDSHAKE=0x10,NOISE_ENCRYPTED=0x11,FRAGMENT=0x20,FILE_TRANSFER=0x22(media — seemedia.py).data_for_signing(): canonical bytes the Ed25519 signature covers — the packet re-encoded with signature=None and ttl=0 (TTL mutates during relay), padded. The transmitted padding is irrelevant to verification; the receiver reconstructs this canonical form from the decoded fields.AnnouncementPacket: TLV (0x01nickname,0x02X25519 noise pubkey,0x03Ed25519 signing pubkey).- A public message's payload is the raw UTF-8 content string — NOT a TLV/structured blob. The sender nickname is resolved from its peer ID via the ANNOUNCE it sent earlier.
BitchatMessageis just a local value object for chat history. FragmentReassemblerreassembles inboundFRAGMENTpackets (fragmentID(8) index(2) total(2) originalType(1) data) into the original encoded inner packet, which is then decoded recursively.
media.py— pure (no-I/O) media helpers, wire-compatible with upstreamBitchatFilePacket.swift/MimeType.swift.BitchatFilePacketis the TLV payload of aFILE_TRANSFER(0x22) packet:0x01fileName (UInt16 len),0x02fileSize (len=4, UInt32),0x03mimeType (UInt16 len),0x04content (UInt32 len; decode tolerates legacy 2-byte). Hard cap 1 MiB (MAX_PAYLOAD_BYTES);encode()returnsNoneabove it. MIME helpers map extension↔MIME↔category (image/audio/file) and the chat-bubble prefix each uses ([image]/[voice]/[file]). Upstream has no video type — video (e.g..mp4) is sent as a genericapplication/octet-stream[file]. A public media message is a single signedFILE_TRANSFERpacket withrecipientID=None(no separate MESSAGE); each side derives theprefix+filenamechat line locally.identity.py—IdentityKeys: per-session X25519 static key + Ed25519 signing key. Exposes raw 32-byte public keys (for ANNOUNCE) andsign(). Public chat is unencrypted, but the real app drops public messages whose signature is missing/invalid, so every outbound packet must be signed and we must ANNOUNCE our signing key first.ble_service.py— all Bluetooth I/O viableak, central role. Scans with adetection_callbackand connects only to devices whose advertisement contains the bitchatSERVICE_UUID(with a per-device attempt cap + cooldown + permanent ignore-set). On connect: validate the characteristic,start_notify, and immediatelysend_announce(). Inbound notifications dispatch byMessageType; outboundsend_announce/send_public_message/send_leave/send_filesign → encode → write to every connected client (fragmenting frames overMAX_FRAME_SIZE, with pacing between fragments). ReceivedFILE_TRANSFERpackets are decoded viamedia.BitchatFilePacket, saved to~/.bitchat/downloads/with a sanitized basename, and auto-opened with the OS default viewer.chat_state.py—ChatState: holdsIdentityKeys, chat history, peers keyed by 8-byte peer-ID hex → nickname (from ANNOUNCE, NOT BLE MAC), and the set of connected BLE addresses. Shared by reference intoCLIandBLEService.cli.py—prompt_toolkitREPL. Owns theBLEServiceand passes it aredraw_promptcallback so the async BLE layer can print incoming messages without corrupting the input line (patch_stdout()inrun()).CLI.run()launchesBLEService.run()as a background task and loops onprompt_async()concurrently on the same event loop.
bleakis central-only: this client cannot advertise as a BLE peripheral or relay. It interoperates one hop with bitchat peers in range while connected to them; it is not discoverable when idle and does not participate in the multi-hop mesh.- Signatures are mandatory for public messages to be accepted (see
identity.py). If you changeBitchatPacket.encode()ordata_for_signing(), re-runselftest.py— the signature test simulates the receiver's verification path. - MTU: transmitted packets are padded to ≥256 bytes, so a single BLE write relies on the ~512-byte MTU that bitchat peers negotiate. Against a peer that only negotiates a small MTU, large writes may fail.
- The bitchat UUIDs and opcodes were verified against upstream
BLEService.swift,BinaryProtocol.swift,MessageType.swift, andPackets.swift. Re-verify there before changing any wire constant.