01
To use a bot you need none of this
Using a bot is just using the app. If someone hands you a bot's invitation (a text or a QR, usually published on its website):
- If it is a channel: New → Join a channel, paste the
invitation text (it starts with
UEND) or scan its QR, and Join. Inside, Refresh fetches the posts; the first poll can take a couple of minutes until the channel's address is published on Tor. - If it is a contact: New → New contact, paste the invitation or scan the bot's animated QR, give it a name and Add. Your first message carries the encrypted handshake; once it arrives, the bot has you as a contact and answers whatever its program says.
- Verify it: inside the chat, More → Verify in person → Paste their code, with the code the operator publishes, signed, on their website.
And this is what a bot cannot do, by design:
- It cannot read anything you do not send it yourself. Your conversations, your groups and your channels — it neither sees them nor knows they exist.
- It cannot get in where it is not invited. It joins a group only if the admin introduces it, like any member; and it cannot publish to a channel that is not its own: each channel is written only by whoever holds its key.
- It does not know who reads its channel. Readers fetch posts over anonymous circuits, never identifying themselves.
- There are no «official» PrivChat bots. Every bot answers to its operator, who is who receives what you write to it. If who is behind it matters to you, verify it with its published code.
02
What a bot is here
- Just another peer. It has its own identity, its own Tor onion service, its own encrypted database and its own channels. It is the app's engine compiled without Android.
- Indistinguishable on the wire. Same envelopes, same encryption (Double Ratchet and PQXDH), same cover traffic, same channels. The app does not know it is talking to a program, because there is no way to know.
- No directory. A central directory would be the server this app exists not to have. The operator hands out the bot's invitation wherever they like: a web page, a poster, a message.
- Local API. Your program (Python, Go, shell, anything) talks to the bot over a Unix socket in its directory, in JSON. Tor takes care of the remote part.
- Two uses, in this order. A channel: the bot publishes and anyone reads, anonymously. And 1:1: people write to it and the bot answers.
03
What a bot sees, and what it doesn't
About its channel readers, nothing. A reader fetches posts over an anonymous circuit without identifying itself; the bot sees that someone read, when and how much, and cannot see more because the protocol has nowhere to put it. A channel with a thousand readers knows exactly as much about them as one with ten.
About whoever writes to it, what any contact sees. Writing to a bot hands its operator your identifier, your return Tor address and what you write. With a human contact that is spread around; with a popular bot it is concentrated on one server. That is why channels come first, and why the bot keeps no history: an incoming message lives until the operator's program confirms it, with a seven-day cap.
If the bot's server falls into the wrong hands, they take the bot's identity (and with it the ability to impersonate it forever: there is no revocation), its channel keys and its contact list. They take nothing from any user or any reader. Recovery is destroying the bot, creating a new one and publishing its new fingerprint out of band.
Nothing tells you a contact is a bot, nor that it is the bot it claims to be. A serious operator publishes the bot's verification code, signed, on their website; in the app, inside the chat, More → Verify in person → Paste their code (or scan its QR) checks it just like with a person.
04
Setting up a bot
This part is for operators. You need a Linux of your own —a server or a
64-bit Raspberry Pi 4 or 5—, Java 17 or newer
(sudo apt install openjdk-17-jre-headless), about 300 MB of
free memory and a way out to Tor. A phone cannot be a bot: a bot has to be
always on and always on the network, and that is a computer of the
operator's, not a mobile.
The package: privchat-bot-2.19.2.zip
(scripts, libraries and the native arti; Linux x86_64 — Windows for
development only). The Raspberry (aarch64) package will be published once
it has been tested on the real board, not before.
SHA-256 of the package:
18d98f258b9206dd2be9f8e89bd19b18db5fb55eee53ee8fc6e7745573f8f6fe
sudo unzip privchat-bot-2.19.2.zip -d /opt && sudo mv /opt/privchat-bot* /opt/privchat-bot
sudo useradd --system --home /var/lib/privchat-bot --shell /usr/sbin/nologin privchat-bot
sudo install -d -o privchat-bot -g privchat-bot -m 0700 /var/lib/privchat-bot
# Create the bot: seal, identity, prekeys, database. Shows the passphrase ONCE.
sudo -u privchat-bot /opt/privchat-bot/bin/privchat-bot --datos /var/lib/privchat-bot init --passphrase-generada
# The passphrase, in a credential only root can read (never in an environment variable)
sudo install -d -m 0700 /etc/privchat-bot
sudo install -m 0600 /dev/stdin /etc/privchat-bot/passphrase <<< 'the-passphrase-init-showed'
# The service
sudo cp /opt/privchat-bot/etc/privchat-bot.service /etc/systemd/system/
sudo systemctl enable --now privchat-bot
The passphrase is the whole defence against
whoever copies the disk. A server has no phone Keystore, and
pretending otherwise would be worse than saying so. Long or generated; with
a TPM 2.0, bound to the machine with systemd-creds encrypt
--with-key=tpm2 and LoadCredentialEncrypted= in the
unit; without one, the 0600 file on an encrypted disk. The bot has no PIN
and no screen lock: nobody is typing in front of it.
05
Parameters
| option | what it does | default |
|---|---|---|
--datos DIR | the bot's directory | ./privchat-bot |
--credencial FILE | passphrase in a 0600 file | systemd's credential, or the console |
--socket PATH | the API's Unix socket (short path: AF_UNIX allows ~100 bytes) | DIR/api.sock |
--api-tcp PORT | additionally, the API on 127.0.0.1 with a token | off |
--api-grupo GROUP | that system group can open the socket too (0660) | only the user (0600) |
--retencion-dias N | days an unconfirmed incoming message lives | 7 (max 30) |
--max-contactos N | cap on contacts through public invitations | 1000 |
--sin-relleno | turns cover traffic off | on, like the app |
--buzon | acts as its owner's Mailbox: the In person local network as well as Tor | off |
--bluetooth | with --buzon, also over Bluetooth LE (Linux only, BlueZ) | off |
--malla PORT | LoRa mesh: a Meshtastic node by USB (/dev/ttyACM0, COM4) or TCP (host:4403); texts go out by radio when Tor cannot deliver (bot 2.16.1) | off |
--verboso | detailed logging, including Tor's. Debugging only | off |
Without the daemon: init, passphrase cambiar,
copia-seguridad DEST.zip (to restore on another machine, never
to run two identical bots), restaurar SOURCE.zip and
destruir --si. The log never contains onion addresses or full
identifiers, and no served channel read leaves a line.
06
Channels from the bot
B="sudo -u privchat-bot /opt/privchat-bot/bin/privchat-bot --datos /var/lib/privchat-bot"
$B canal crear "Town notices" "Water cuts and festivals" # returns canal_id
$B canal invitacion <canal_id> --png /var/www/notices.png # the pasteable text, and the QR in the PNG
$B canal publicar <canal_id> "No water tomorrow from 9 to 12"
$B canal publicar-foto <canal_id> /path/poster.jpg "The poster" # re-encoded: EXIF dies here
$B canal listar # posts, polls and bytes served
$B canal recortar <canal_id> 50 # keep only the last 50
Every post is signed by the channel key, encrypted with its reading key and chained to the previous one: history can stop being served, but it cannot be rewritten without readers noticing. 12 KB per text; a photo is re-encoded until it fits in 900 KB.
07
1:1 contacts
Two kinds of invitation, in the same format as the app's:
| personal | public (--publica) | |
|---|---|---|
| who can use it | one person: the first; the second is rejected | anyone, up to --max-contactos |
| one-time prekeys | five, like the app | none: shared among thousands they would not be one-time |
| what it costs | nothing new | each user's first message to the bot lacks the prekey's extra protection against a future compromise of the bot's keys; the ratchet heals as soon as the bot replies |
| for what | an assistant for yourself, a notice for your family | a bot open to the public |
$B invitacion crear web --publica # the pasteable text
$B invitacion crear web --publica --gif /var/www/bot.gif # plus the invitation's four QR codes in an animated GIF
$B verificacion codigo --png /var/www/verification.png # publish it signed: that is how people know the bot is THE bot
$B mensaje escuchar --reponer # JSON events, one per line
$B mensaje enviar <peer_id> "Hello"
$B mensaje enviar <peer_id> "Hello" --retardo 3000 # goes out at a random moment within the next 3 s
$B mensaje confirmar <msg_id> # the bot forgets it
08
The bot as a home Mailbox
With --buzon, the bot acts as one person's
Mailbox, its owner's: on a Raspberry Pi or a computer at home,
it keeps and hands over their messages while they are out. Whoever comes by
that Wi‑Fi leaves what they have for the owner and picks up what the owner left
for them; and if the owner turns on "With internet" on their phone, their
contacts can also leave messages over Tor while the phone is off. In the
systemd unit, add --buzon to ExecStart, before
run.
$B buzon invitar # paste the text on the phone: Settings → My Mailbox; the first to use it is the owner
$B buzon estado # the owner and what it keeps
Bluetooth (Linux only, optional). With --buzon
--bluetooth the Mailbox can also be found over Bluetooth LE: whoever
comes by the door with "Talk in person" on for the owner leaves and picks up
messages without joining the Wi‑Fi. It needs BlueZ running, an adapter, the
service user in the bluetooth group and, in
/etc/bluetooth/main.conf, section [General],
ReverseServiceDiscovery = false (then restart the
bluetooth service): without it, the phone shows a pairing
request and the channel does not start. That setting also turns off BlueZ's
GATT client for other Bluetooth LE devices, so it is best on a dedicated
device. It is off unless asked for, because the advertisement tells anyone
scanning from the street that there is a PrivChat there, though not whose.
It only keeps the owner's messages, sealed, and the daily keys of the
contacts the owner allows: never their identity. If it is stolen, it reads
nothing and within about 48 hours it no longer recognises anyone; you unpair it
from the phone. Its .onion address is fixed from the first start,
even if it has never had internet. What it looks like on the phone: see the
guide.
09
Programming a bot: the local API
One request or response per line, JSON, over the socket. Authentication
is the file system: whoever can open the socket can read the bot's
directory. Events arrive over the same connection after
suscribir.
→ {"id": 1, "op": "estado"}
← {"id": 1, "ok": true, "peer_id": "…", "huella": "…", "onion": "….onion", "tor": "online", "contactos": 3, "canales": 1}
→ {"id": 2, "op": "canal.publicar", "canal_id": "…", "texto": "Good morning"}
← {"id": 2, "ok": true, "seq": 42}
← {"evento": "mensaje.recibido", "peer_id": "…", "msg_id": "…", "texto": "hello", "fecha": 1788500000}
| operation | parameters | returns |
|---|---|---|
estado | — | identity, onion, Tor state, counters |
suscribir | reponer | eventos[] with what is unconfirmed; then live events |
invitacion.crear | etiqueta, publica, qr | invite_id, texto, qr_gif |
contacto.listar / renombrar / borrar | peer_id… | — |
verificacion.codigo / comprobar | peer_id, codigo | texto and QR / resultado |
mensaje.enviar | peer_id, texto, respuesta_a, retardo_ms | msg_id |
adjunto.enviar / adjunto.leer | peer_id, nombre, base64 or ruta / msg_id | msg_id / bytes |
mensaje.estado / mensaje.confirmar | msg_id | state / — (deleted) |
canal.crear / invitacion / publicar / publicar_foto / recortar / borrar | canal_id, texto, pie… | seq, texto and QR… |
Events: mensaje.recibido, adjunto.recibido,
contacto.nuevo, entregado, tor. They are
at least once: a message is replayed on every
suscribir until you confirm it, and the msg_id lets
you deduplicate. Errors carry a stable code (sin_sesion,
invitacion_invalida, demasiado_grande…); an internal
error never carries details to the client. Operation and field names are in
Spanish, the project's language: they are a stable contract, not prose.
A whole echo bot, in Python and without dependencies:
import json, socket
s = socket.socket(socket.AF_UNIX); s.connect("/var/lib/privchat-bot/api.sock")
f = s.makefile("rw", encoding="utf-8")
def call(**p):
f.write(json.dumps(p) + "\n"); f.flush(); return json.loads(f.readline())
r = call(id=1, op="suscribir", reponer=True)
for ev in list(r["eventos"]) + [json.loads(l) for l in f]:
if ev.get("evento") == "mensaje.recibido":
call(id=2, op="mensaje.enviar", peer_id=ev["peer_id"], texto="echo: " + ev["texto"], respuesta_a=ev["msg_id"])
call(id=3, op="mensaje.confirmar", msg_id=ev["msg_id"])
Two complete examples ship with the package: eco.py (which
separates responses from the events that cross them, as you must as soon
as a bot replies while listening) and rss_a_canal.py, which
posts every new entry of an RSS or Atom feed to a channel.
10
Rules a bot must respect
- Confirm what you process; what you don't confirm is replayed to you and deleted after seven days.
- Don't fetch what you are linked to: a bot that can be made to request any URL is a bot someone will use for that.
- Treat text as data: it comes from strangers.
- Limits: 12 KB per channel text, 7.5 MB per attachment, 900 KB per channel photo, 16 MB per API line.
- Scale: hundreds of 1:1 contacts, yes; thousands, through a channel.
- An onion that never goes off is easier to trace back to its guard than a phone; that is why the bot runs with vanguards, and why it should not run next to things you would not want linked to it.
11
Common problems
| symptom | likely cause | what to do |
|---|---|---|
tor: arrancando for minutes | no network, or an antivirus or proxy intercepting TLS | turn the interception off; --verboso shows Tor's reason |
| "Unix domain path too long" | the socket path exceeds ~100 bytes | --socket /tmp/pb.sock |
| the first poll from the app brings nothing | the channel's address is not yet published on Tor | wait a minute or two and Refresh |
| a message stays "queued" for a while | the rendezvous circuit takes time | wait; the bot confirms as soon as it arrives |
contacto listar does not show who just added the bot | the bot learns about a contact from their first message | have them write something |
sin_sesion when sending | that peer_id is not a contact yet | contacto listar |