Two badges, and the difference between them is which library they build
against. CI gates every push on the revisions libs/ pins — what a fresh
git clone --recurse-submodules produces — on Windows and Debian 13: 29 of
29, nothing skipped, on both. Nightly runs the same suite on the same two
platforms with the three submodules moved to master, because a push to
Ratatoskr does not touch this repository and the pins keep the gate green
through any amount of movement underneath. Red there and green here means the
library changed in a way this console can see.
An interactive XMPP client for the command line: it logs in to an XMPP server
over WebSocket (RFC 7395), authenticates with SCRAM, and then gives you a
prompt from which you chat, manage your roster, query services and drive
PubSub — everything as a /command, everything else you type goes to the
current conversation partner.
The protocol itself does not live here. It lives in
Ratatoskr, a library of its own
under libs/; XMPPConsole is its hand-operated front end. What is in this
repository is user interface: command line parsing, command dispatch and
presentation.
Maturity: experimental. The client connects, authenticates and chats against Prosody 13 over
wss://. Connection management and error handling are incomplete. What is checked in this repository is the console output and nothing else — the protocol is checked with the library. Not for production use.
- What it does
- What it speaks today
- Requirements
- Getting started
- Command line options
- Commands
- How the connection is made
- Keepalive
- End-to-end encryption (OMEMO)
- Security notes
- Architecture
- Repository layout
- Tests
- License
- Chat. Set a conversation partner with
/to, then type — every line without a leading/goes out as a message. Single messages without switching partner go with/msg. A message already sent is corrected with/fix(XEP-0308), and a message that arrived is answered with/re(XEP-0461) — which is not the same as simply writing back, because the answer names the message it is about. - Roster. List, filter, add and remove contacts, see who is online, read the details of a single contact, and accept or deny incoming contact requests.
- Presence. Set your own status (
available,away,chat,dnd,xa) with a status text. - Typing notifications and read markers.
/typing,/paused,/gone(XEP-0085) and/mark received|displayed|ack(XEP-0333). - Service discovery. Ask any JID what it can do (
/disco info), what it offers (/disco items), and compare the server's feature list against your own (/features). - PubSub. Roughly two dozen subcommands under
/pubsub: subscribe, publish, fetch items, create, configure and delete nodes, manage roles, subscribers and access models, and answer subscription requests (XEP-0060). - Files.
/send <path>puts a file on the server's upload service (XEP-0363) and sends the address;/send -e <path>encrypts it first (XEP-0454), and then the storage host holds bytes it cannot read. The limit the service announces is asked for before the file is read from disk, so a refusal does not arrive after the reading. An incoming file is shown as one rather than as the address it is, and fetched into the archive when--storeChatMediasays so. - Avatars.
/avatar <path>publishes a picture over PEP (XEP-0084),/avatar offtakes it down,/avatar <jid>fetches somebody else's and says where it was written. A terminal cannot show a face; what it can do is tell you there is one and put it where something else can open it. A change announced by a contact appears as a note and is not fetched: doing that unasked would let every contact decide that this machine downloads something. - OMEMO. End-to-end encrypted messages, fingerprint comparison and
per-device trust decisions (XEP-0384) — in a room as well, where it needs
one thing first: one encrypts to the devices of a real address, and a room
hands out nicknames.
/roomencryptmakes the room non-anonymous, which is what puts real addresses into everybody's view, and it asks before doing it because it changes the room for everybody in it. A line in a room then says which of the two it was — a lock, or the reason it went in the clear — exactly as a one-to-one line does. One occupant whose address the room will not give stops the whole message: a person standing in the room is not a device that can be quietly skipped. - Operational insight.
/pingwith round-trip measurement,/who,/carbons,/sm,/csi,/keepalive, and/rawto watch the actual XML going back and forth.
The console keeps its input line intact while all of this happens: incoming messages, presence changes and the log itself go through one output lock, so nothing is ever written into the middle of what you are typing.
Inherited from Ratatoskr and reachable from this console:
| Area | State |
|---|---|
| Transport | WebSocket over TLS (RFC 7395), wss://. A ws:// endpoint is refused unless --insecure is given. No TCP, no BOSH |
| Authentication | SCRAM-SHA-256, and demanded by default — SCRAM-SHA-1 and SASL PLAIN only for whoever lowers the bar with --sasl. No channel binding (-PLUS) |
| Endpoint discovery | XEP-0156 host-meta over HTTPS; only wss:// endpoints are taken |
| JID handling | RFC 7622 (PRECIS, IDNA2008) |
| XEP-0030 / XEP-0115 / XEP-0128 | Service discovery, entity capabilities, disco extensions |
| XEP-0060 | Publish-Subscribe, including node ownership and access models |
| XEP-0085 / XEP-0184 / XEP-0333 | Chat states, delivery receipts, chat markers |
| XEP-0198 | Stream Management with resumption, on by default |
| XEP-0199 | Ping and RTT measurement |
| XEP-0203 | Delayed delivery — late messages carry their original date |
| XEP-0280 | Message Carbons, with spoofing protection |
| XEP-0308 | Last message correction (/fix) |
| XEP-0424 | Message retraction (/unsay) — a request and not a deletion, and the archive keeps its own copy |
| XEP-0045 | Multi-user chat (/join, /part, /rooms, /nick, /topic), and /roomencrypt for the one setting that lets a room be encrypted in. The moderator's half (/kick, /ban, /voice, /askvoice, /grantvoice, /denyvoice) and the owner's (/affiliations, /destroyroom), a nickname held against everybody else (/holdnick, section 7.10), and the private word of section 7.5 (/pm) |
| XEP-0313 | Message archives (/history), and a room's history on entering it |
| XEP-0359 | Stable stanza IDs — read, to know which name a reply may point at |
| XEP-0426 / XEP-0428 / XEP-0461 | Message replies (/re), with the quoted lines marked as the duplicate they are |
| XEP-0352 | Client State Indication (/csi) |
| XEP-0363 / XEP-0454 | HTTP file upload (/send), encrypted first with -e |
| XEP-0084 | Avatars (/avatar), over XEP-0163 |
| XEP-0384 / XEP-0420 | OMEMO 2 (urn:xmpp:omemo:2) with stanza content encryption, one to one and in a room |
Carbons, receipts, PubSub events, roster pushes and caps answers are all checked against a forged sender before they are processed.
Not implemented: MIX (XEP-0369), Jingle, blocking (XEP-0191), and in-band
registration of an account — XEP-0077 is here, but only pointed at a room, which
is what /holdnick is. SASL2 and Bind 2 need no command: the console speaks
them because Ratatoskr negotiates them. The full catalogue of what a modern
XMPP client could speak — and where each specification stands — is in
docs/STANDARDS.md.
- .NET SDK 10.0 or newer
- An XMPP account on a server that offers XMPP over WebSocket (RFC 7395) — Prosody, ejabberd and most modern servers do, but it often has to be enabled
Ratatoskr, Hermod and Styx are submodules under libs/, so one clone is
the whole build:
git clone --recurse-submodules https://github.com/Vanaheimr/XMPPConsole.gitIf you already cloned without them:
git submodule update --initThe submodule URLs are the public https://github.com/Vanaheimr/… mirrors, so
this needs no account and no deploy key. Inside libs/ the three end up beside
each other, which is the layout they expect of one another — Ratatoskr reaches
for ..\..\Hermod, Hermod for ..\..\Styx:
libs/Ratatoskr/ libs/Hermod/ libs/Styx/
The revisions are pinned, so a breaking change upstream does not turn this red on its own — and equally, does not get noticed here. That question is asked by the nightly in XMPPConformanceTests, which builds the same chain at master.
XMPPConsole.csproj names Ratatoskr twice, under opposite conditions:
<ProjectReference Include="..\libs\Ratatoskr\Ratatoskr\Ratatoskr.csproj"
Condition="Exists('..\libs\Ratatoskr\Ratatoskr\Ratatoskr.csproj')" />
<ProjectReference Include="..\..\Ratatoskr\Ratatoskr\Ratatoskr.csproj"
Condition="!Exists('..\libs\Ratatoskr\Ratatoskr\Ratatoskr.csproj')" />Condition is allowed on any MSBuild item, and Exists() is evaluated while
the project is being read — before any target runs, so a ProjectReference
that loses its condition is not a reference that fails to resolve, it is one
that was never in the project. Relative paths inside Exists() resolve against
the directory of the file the condition is written in, not the working
directory, so this means the same thing wherever dotnet build is invoked
from.
That is not indecision, it is the two trees this project is built in:
| Checkout | libs/Ratatoskr |
Resolves to |
|---|---|---|
| This repository, cloned on its own | present, pinned | libs/Ratatoskr — one clone is the whole build |
As libs/XMPPConsole inside XMPPConformanceTests |
empty, that checkout is deliberately not recursive | ..\..\Ratatoskr — which inside its libs/ is the Ratatoskr that repository pins |
So the pin here never competes with the pin there. That matters because
XMPPConformanceTests holds this console in its solution: if both pins were
live in one tree, two Ratatoskr.csproj would build the same assembly twice.
Delete the fallback and the conformance suite stops building; delete the
submodules and a plain clone stops building.
To see which one won, ask MSBuild rather than guessing — this prints the evaluated items without building anything:
dotnet msbuild XMPPConsole/XMPPConsole.csproj -getItem:ProjectReferenceBuild:
dotnet build XMPPConsole.Tests/XMPPConsole.Tests.csprojor open XMPPConsole.slnx, which holds the console, its tests and the three
libraries as source rather than as references pointing off-screen.
Run — with no arguments it asks for JID, password and WebSocket URI interactively:
dotnet run --project XMPPConsoleOr pass them:
dotnet run --project XMPPConsole -- -j user@example.com -p secretThe built executable is XMPPConsole and takes the same options:
./XMPPConsole/bin/Debug/net10.0/XMPPConsole -j user@example.com -p secret| Option | Meaning |
|---|---|
-j, --jid <jid> |
JID in the form user@domain. A resource may be written along — user@domain/phone then asks the server for exactly that resource instead of console-<pid> |
-p, --password <pw> |
Password |
-w, --ws, --websocket <uri> |
WebSocket endpoint, e.g. wss://xmpp.example.com:5281/xmpp-websocket. wss:// only, see below |
--insecure |
Allow a ws:// endpoint anyway |
--sasl <mechanism> |
The weakest SASL mechanism still accepted. Default SCRAM-SHA-256 |
-v, --verbose |
Verbose logging (trace level — shows every stanza) |
--chatlogs <dir> |
Keep the conversations as text, see below |
--storeChatMedia |
Fetch the files that were shared. Needs --chatlogs |
-h, --help |
Show the help and exit |
Long options take their value either way, --chatlogs logs and
--chatlogs=logs.
Anything not given on the command line is asked for at startup. The password prompt does not echo. Leaving the WebSocket URI empty starts the endpoint discovery described below.
# interactive
dotnet run --project XMPPConsole
# with an explicit endpoint (needed for servers that publish no host-meta)
dotnet run --project XMPPConsole -- -j user@example.com -p pw \
-w wss://xmpp.example.com:5281/xmpp-websocket
# with the full protocol log
dotnet run --project XMPPConsole -- -j user@example.com -p secret -v--chatlogs <dir> writes what was said as text — not the XML that carried it.
The raw stream is already available live through /raw, and it answers a
different question: whoever opens a chat log a year later wants to read the
conversation, not the protocol.
<dir>/
└── ahzf@example.org/
├── ahzf@example.org_202608.log
├── ahzf@example.org_202609.log
└── media/ (only with --storeChatMedia)
└── 20260815_134207_photo.jpg
One directory per conversation, one file per month, one line per event:
# XMPPConsole chat log - ahzf@example.org - 2026-08
# <date time> <marker> <who> <what>
# markers: '<' received, '>' sent, '*' presence, '#' session, '@' file
# a line break inside a message is written as \n, a backslash as \\
2026-08-15 13:42:07 < ahzf@example.org/iPhone Hi 4!
2026-08-15 13:42:11 > me Hallo\nzurück
2026-08-15 13:42:34 * ahzf@example.org/iPhone available (back at four)
2026-08-15 13:43:02 < ahzf@example.org/iPhone Hi 5! [corrects msg-17]
Both directions land in the file named after the far end, carbons from your other devices included — a conversation belongs in one place. Presence changes are kept with their status text, and so is the session itself (connecting, losing the connection, contact requests), because a gap in a log is otherwise an unanswerable question later: was nothing said, or was nobody connected?
A message may contain line breaks and a log line may not, so they are written
as \n and a backslash as \\. That keeps every event greppable as one line
and stays reversible.
A JID is not a file name — the resource part forbids almost nothing, and
eve@example.org/..\..\..\Windows is a JID anybody may carry. Everything
outside A-Z a-z 0-9 @ . - _ therefore becomes _, which costs two JIDs
differing only in a forbidden character their own directory, and saves a class
of bug in which the answer to "where did the file go" is "anywhere".
--storeChatMedia additionally fetches what was shared, into
<dir>/<jid>/media/, named after the moment it arrived — that is the one time
this side actually knows, since the timestamp inside a message is what the
sender claims.
This turns an incoming message into a network request from your machine, so what counts as a shared file is deliberately narrow:
aesgcm://always. That scheme exists for nothing else (XEP-0454); the URL carries the key. The file is downloaded, decrypted with the key from the URL fragment and stored in the clear — the fragment itself is never written to the log and never sent to the server.https://only when the message is nothing but that one URL. That is how a client hands over an upload (XEP-0363): the body repeats the URL because the receiver may not read thejabber:x:oobelement beside it. A link inside a sentence is a different act — somebody is pointing at something, not handing it over.http://never, and no other scheme.
What is refused is written into the log too, with the reason, so a file that never arrived does not simply go missing.
The remaining rules: at most 100 MB per file, five minutes, redirects followed by hand and checked at every hop, and no address that belongs to this machine or to a private network. One gap is named rather than papered over — the host is resolved, checked, and then resolved a second time by the HTTP client, so a name that answers differently the second time gets through. Closing that needs a socket handler of its own.
Ratatoskr does not read jabber:x:oob today, so the detection works on the
message text. In practice that is the same thing, because XEP-0363 senders put
the URL in the body as well — but a client that does not would go unnoticed.
Reading the aesgcm:// URL and decrypting what comes back is not done
here: that is XEP-0454, it is protocol rather than application, and it lives in
Ratatoskr as AesGcmUrl where any client on that library can use it. The
fetching stays here on purpose — whether an incoming message may cause a
request at all, and under which limits, is a decision an application has to
make. A protocol library that downloads on its own hands that decision to
whoever sent the message.
At the prompt, a line without a leading / is sent as a message to the
current conversation partner. Everything else is a command.
/to <jid> set the conversation partner (alias: /chat)
/to reset the conversation partner
/msg <jid> <text> send a single message (alias: /m)
/fix <text> correct the last message to this partner (alias: /corr)
/unsay take back the last thing said (alias: /retract)
/re <text> answer the last message from this partner (alias: /reply)
/join <room> [nick] enter a room and make it the conversation (alias: /j)
/part [reason] leave the current room (alias: /leave)
/rooms the rooms, their subjects and who is in them
/roomencrypt make this room non-anonymous, which is what lets it
be written in encrypted - it changes the room for
everybody in it, so it asks first
/pm <nick> <text> a word to one occupant and to nobody else
/nick <name> a different name in the current room
/holdnick [name] claim a nickname here; bare, says what is held
/topic [text] the subject of the current room (alias: /subject)
/history [count] what was said before, out of the archive (alias: /hist)
/invite <jid> [reason] ask somebody into the current room
/decline <room> [reason] say no to an invitation that arrived
/kick <nick> [reason] throw somebody out of the room for this visit
/ban <nick> [reason] keep somebody out of the room for good
/affiliations [which] who is on one of this room's lists
/destroyroom [jid] [reason]
take this room down for everybody, for good
/voice <nick> on|off the voice in a moderated room
/askvoice ask a moderated room to be allowed to speak
/grantvoice <nick> answer a request for a voice that arrived here
/denyvoice <nick> refuse one
/status [show] [text] set the status: available|away|chat|dnd|xa (alias: /s)
/fix takes the complete new text, not the change to it. It corrects the
last message to the current partner, and a correction can be corrected again —
every one of them naming the original, as XEP-0308 asks since 1.1.0. Because
it carries the whole new text, it goes out the way the corrected message
did: encrypted after an encrypted line, with the <replace/> inside the
envelope. Where that is no longer possible it sends nothing and says why,
rather than write the new text in the clear.
/unsay takes back the last thing said in this conversation (XEP-0424). It is
a request and not a deletion: what the far side does with it is theirs, the
archive keeps its own copy, and whoever was reading has already read. In a room
what it takes back is the line under the name the room gave it — and that name
arrives only when the room hands our own line back, so the console notes it from
the reflection and not from the send. An encrypted line comes back
unreadable, and only its name is taken from it.
A line that went out encrypted is taken back encrypted, the <retract/> inside
the envelope: in the clear the request would name the line and tell everybody
watching that you wanted it gone. When a room can no longer carry that, /unsay
says why and sends nothing.
/re answers the last message that arrived from the current partner, and
sends the old text along as > lines so that a client which does not know
XEP-0461 still shows what the answer is about. Those lines are marked as
fallback (XEP-0428), so a client which does know it hides them and shows its
own quotation instead — which is what this console does with incoming answers:
[21:14:32] alice ↩:
> Coming along?
Eight o'clock then
The quotation is dimmed and the answer is not, because only one of the two was
written just now. What is quoted when sending is the other side's text
without its quotation — otherwise every turn would add a layer of > and
after four exchanges the message would be mostly other people's words.
/join chat@conference.example achim enters a room and makes it the current
conversation, so an ordinary typed line goes into it. A room that did not exist
is created by entering it — and is then locked until its owner configures
it, so /join does that straight away; without it you would have a room only
you can see, with nothing anywhere saying so.
/rooms lists who is in them with affiliation and role. The real address is
usually missing, and that is not a gap: a room is semi-anonymous by default and
tells nobody who anybody is. When it is not — /join says so.
What happens in a room is shown dimmed, because none of it is somebody saying something. Being thrown out is not:
[21:14:32] chat@conference: bob is here
[21:14:40] chat@conference: subject: deployment on Friday
[21:15:02] kicked out of chat@conference - enough of that (by alice@example)
Leaving, being kicked, being banned and the service shutting down all arrive as the same stanza, and the status codes are the only thing that tells them apart. Reported as "you have left the room" they read identically — and the one thing worth knowing, whether coming back is worth trying, would be the part dropped.
/join shows the last ten lines of a room as it lets you in, because walking
into a room and seeing nothing is walking in blind. /history [count] asks for
more — the room's archive for a room and your own for a person, which
are two different archives at two different addresses: your server never saw a
word of what was said in a room before you arrived.
It is dimmed and carries the date, because none of it is happening now and a bare time of day would say that it is. The whole difficulty of an archive on a screen is that it looks exactly like a conversation.
A room that keeps no archive says so:
> /history
chat@conference keeps no archive, so nothing said there can be looked up - or
answered.
The second half of that sentence is not a throwaway. A room assigns a message
its own name only if it archives, and without that name there is nothing for a
reply to point at — so in such a room /re cannot work either (XEP-0461 §4).
An invitation is the only thing a room says about a room you are not in — and therefore the only way to hear that one exists:
[21:14:32] alice asks you into chat@conference: come along
/join chat@conference or /decline chat@conference
The invitation is kept, because a refusal has to be addressed to whoever asked
and by then the stanza is gone. Who that is comes back in one of two shapes,
and both real services were measured doing a different one — ejabberd names the
inviter's real address, Prosody their address in the room. The second says who
asked without saying who that is, which for a semi-anonymous room is the more
careful answer; the console prints alice (in the room) for it rather than a
room address where a person belongs.
/kick <nick> and /ban <nick> both take a nickname, and only one of them can
keep it:
> /ban bob
This room does not say who 'bob' really is, and a ban has to name somebody who
exists outside it. /kick works.
A kick takes away a role, which lasts only for the visit, so a nickname names somebody. A ban sets an affiliation, which outlives the visit, so it has to name somebody who exists outside it — and a semi-anonymous room gives real addresses to moderators only. The console looks the nickname up and says which of the two it is: a missing name, or a missing permission. They are different problems and only one of them is yours.
Destroying a room is here: /destroyroom takes it down for everybody and
for good. The address it takes is where the occupants are sent instead, and it
is the only part of a destruction that is of any use to them.
Not here: the configuration form as a whole — /roomencrypt sets the one field
that OMEMO in a room needs and nothing offers the rest — and a password for a
protected room, which /join reports rather than sends. /join says which of
those a refusal was about.
Two limits worth knowing. There is no /re for a particular older message:
only the last one that arrived can be answered, because that is all a console
that keeps no history can point at. And an answer to an encrypted message is
encrypted, with the <reply/> and the quotation's marking inside the
envelope: beside it they would say who answered whom and when, which is the
shape of a conversation in clear to anyone watching the connection. In a room
an answer follows the rule an ordinary line follows — encrypted when the room
can carry it — except that a line which arrived encrypted is never answered in
the clear, because the answer quotes it; /re says so and sends nothing.
/askvoice asks a moderated room to be allowed to speak (section 8.6). Nobody
answers it as such: the request goes to the moderators as a form, and what comes
back is a role or nothing at all. On the other side /grantvoice <nick> and
/denyvoice <nick> answer one that arrived here — the console keeps the request,
because the form has to go back with the fields it came with, and by the time
somebody has read the line the stanza is gone.
/holdnick [name] claims a nickname in this room so that nobody else may enter
under it (section 7.10, which is XEP-0077 pointed at a room). Bare, it says what
is held. A name held is not a name taken: it keeps somebody else from using
it, it does not put you in the room.
/affiliations [members|admins|owners|banned] shows who is on one of the room's
lists. An affiliation outlives a visit and a role does not, which is the same
distinction /kick and /ban run into from the other side — the list is the
room's memory of people who are not in it.
/pm <nick> <text> says something to one occupant and to nobody else (section
7.5). It goes through the room, so a nickname is all that is needed and, in a
semi-anonymous room, all there usually is. It is shown differently from a room
line on purpose: of the two possible mistakes, a private word read as a public
one is the expensive one.
/roster [filter] show the contacts (aliases: /list, /contacts)
/online only the contacts that are online
/add <jid> [name] [g1,g2] add a contact and ask for a subscription
/remove <jid> remove a contact (alias: /del)
/info <jid> contact details
/groups groups with the number of contacts
/pending pending contact requests
/accept [jid] accept a contact request (no argument: the first)
/deny [jid] deny a contact request (no argument: the first)
/typing send 'is typing'
/paused send 'has stopped typing'
/gone leave the chat
/mark received [msg-id] mark as received (alias: r)
/mark displayed [msg-id] mark as read (aliases: d, read)
/mark ack [msg-id] acknowledge (aliases: acknowledged, a)
Without a msg-id the message received last is used.
/disco show the subcommands
/disco server features of your own server
/disco info <jid> features of a JID
/disco items <jid> services/items of a JID
/features the server's features and your own
/pubsub show the subcommands
/pubsub sub <node> [jid] subscribe to a node (alias: subscribe)
/pubsub unsub <node> [jid] [subid] end a subscription (alias: unsubscribe)
/pubsub subs own subscriptions with subid (alias: subscriptions)
/pubsub sync [jid] fetch the subscriptions from the service
/pubsub opts <node> [subid] options of the subscription (alias: options)
/pubsub deliver <node> <on|off> [subid] delivery on/off
/pubsub pub <node> <id> <data> publish an item (alias: publish)
/pubsub get <node> [max] fetch items (alias: items)
/pubsub create <node> [access] create a node (models as with 'access')
/pubsub cfg <node> node settings (alias: nodecfg)
/pubsub access <node> <open|presence|whitelist|roster|authorize> change the access
/pubsub groups <node> [group...] roster groups for 'roster' (alias: rostergroups)
/pubsub roles <node> who is what at this node (alias: affiliations)
/pubsub role <node> <jid> <owner|publisher|member|outcast|none> set a role
/pubsub subscribers <node> who subscribes to this node (alias: who)
/pubsub kick <node> <jid> [subid] remove a subscriber (alias: remove)
/pubsub request <node> <jid> <yes|no> answer a subscription request (alias: authorize)
/pubsub retract <node> <id> take back a single item (alias: undo)
/pubsub purge <node> empty a node, keep the subscribers (alias: empty)
/pubsub delete <node> delete a node
Without a <jid> the request goes to pubsub.<domain>; a PEP node belongs to
an account, and then its bare JID stands there. Every one of these commands
reports what the service answered — "Subscribed" means it confirmed, not that
it was asked.
The [subid] on unsubscribing is needed as soon as there are several
subscriptions to the same node: without it there is no saying which one is
meant, and the client picks none. /pubsub subs shows them. With kick the
[subid] is optional by contrast — without it all subscriptions of that JID
go.
subs and subscribers ask in opposite directions: the first where this client
subscribes, the second who subscribes to a node of your own.
/omemo on switch OMEMO on for this account
/omemo on <jid> <text> send an encrypted message
/omemo fingerprints own and known fingerprints (alias: fp)
/omemo trust <jid> <device> confirm a device
/omemo distrust <jid> <device> refuse a device
/ping [jid] send a ping and measure the RTT (XEP-0199)
/keepalive [on|off|sec] show/change the keepalive
/sm [on|off] show/change stream management (XEP-0198)
/csi [active|inactive] client state indication (XEP-0352)
/who your own connection status
/carbons the carbon status
/reconnect connect again
/disconnect disconnect
/raw toggle the raw XML display
/help help (aliases: /h, /?)
/quit quit (aliases: /q, /exit)
If -w gives an endpoint, that endpoint is used and never overruled.
Otherwise the client looks for one (XEP-0156):
https://<domain>/.well-known/host-meta.jsonhttps://<domain>/.well-known/host-meta- If neither yields a
wss://endpoint:wss://<domain>:5443/ws
Only HTTPS is used for the lookup, and only wss:// endpoints are taken over —
a BOSH endpoint is read and passed over, this client does not speak it. The
fallback port is ejabberd's default; Prosody usually wants
wss://<host>:5281/xmpp-websocket, which you then have to give with -w.
ConnectAsync throws when the setup fails, and every reading step of the
negotiation has a 10 second deadline — the one case an error does not cover is
a far side that accepts the connection and then keeps quiet. After the
connection is up, a break is answered with automatic reconnection: exponential
backoff from 1 second up to 30, at most 5 attempts. The resource is
console-<pid> unless the server is left to choose.
An endpoint given with -w has to be wss://. Not warned about — refused, and
the program stops before it asks for a password.
The damage is not the one the word "unencrypted" suggests. It is not that
somebody reads along; it is that the password itself goes out. The list of SASL
mechanisms comes from the server and nothing signs it, so whoever sits on the
way rewrites it: the SCRAM entries disappear, PLAIN stays, and PLAIN is not
a proof that you know the password, it is the password. The pinning in
Ratatoskr would notice the downgrade — from the second login onwards. The
first one is the one being taken, and a first one can be arranged by cutting the
connection, since this client rebuilds by itself.
Hence two settings that work from the very first frame:
| Setting | Default | What it is for |
|---|---|---|
--insecure |
off | Permits ws://. For a server on the same machine, or a test setup with no certificate. Permits nothing else — an http:// address stays refused, because that objection is a different one |
--sasl <mechanism> |
SCRAM-SHA-256 |
The weakest mechanism still accepted. Everything this console is pointed at offers it: Prosody, ejabberd, and the XMPPServer in Ratatoskr |
A server that really cannot do SCRAM-SHA-256 is told apart from an attack by
nothing this program can see. So it is refused, and the refusal names the
option: --sasl SCRAM-SHA-1, or --sasl PLAIN, which demands nothing at all.
The endpoint discovery above needs neither setting. XEP-0156 takes only wss://
out of a host-meta, and the fallback names it itself — a plain endpoint can only
ever come from the command line or the prompt.
Default interval: 25 seconds. If stream management is active an <r/> is
sent, otherwise an XEP-0199 ping.
/keepalive
Keepalive status:
Enabled: True
Interval: 25s
Method: Stream Management <r/>
/keepalive 60 # set the interval to 60s
/keepalive off # disable
Changes take effect only after a reconnect, because the loop is started when
the connection is set up. The same holds for /sm on|off.
/omemo on creates or loads a device identity. The store lies next to the
executable and carries the JID in its name (omemo-user_example.com.json), so
two accounts on the same machine are two devices and do not share a
fingerprint.
The store file is not encrypted. Whoever can read it can read along.
/omemo fingerprints shows your own fingerprint and every device known so far,
grouped in blocks of eight so a human can compare them. Compare a fingerprint
over another channel, never over the one you are securing. /omemo trust and
/omemo distrust record the decision; devices you have not decided on stay
marked ?.
When an encrypted message cannot reach a device, the console names it and says why — a sender who does not learn of it takes their conversation for held. And it keeps two cases apart that look alike: one device of four missing is a message that arrived, none of three is a message that went out and nobody can open. The second is what writing to somebody who does not do OMEMO at all looks like, and it gets a line of its own rather than the arrow that means sent.
A device that has written before and turns up with a different identity key gets its message refused, and the console says so — naming the fingerprint on file beside the one just offered. That is the whole of what blind trust buys: the first message is given away against the promise that a change afterwards is noticed, and this is the promise. Until recently it was not kept here. The library detected the case correctly and raised an event, nothing in the console listened, and the contact simply stopped arriving with no line anywhere saying why — which is indistinguishable from the far side having gone quiet.
There is no way to accept a new key here and no command for it. A new installation and somebody pushing in between look exactly alike from this end, so whoever wants the device back deletes the store and loses every other comparison with it. That price is the honest one; a one-key override would look cheaper and be worth less.
What the console does not show is which device an incoming encrypted message came from, or whether that device is confirmed: it listens on the plain message event, which carries the text and not the rating. The message arrives, and nothing marks it as having been encrypted at all.
-pon the command line is visible. It lands in the shell history and in the process list of the machine. The interactive password prompt does not.- The OMEMO store is plaintext. See above.
/rawprints everything, message bodies included. Do not paste that output into a bug report unedited.XMPPConsole/Properties/launchSettings.jsonis a local debugging profile. If you put real credentials in it, keep it out of the repository.- The password stays in memory for as long as the connection does, as an ordinary string, so a crash dump of this process contains it. That is a named trade-off and not an oversight: SCRAM needs it at every authentication and not only the first, so removing it means an API that can demand a password back at any moment. Worth knowing what it costs — whoever can read this process already has the session and the OMEMO identity, so what the password adds is worth something elsewhere, wherever it was reused.
Where the reasoning lives. A code review of this console and of Ratatoskr was made in August 2026 and is kept in docs/grok-review.md, annotated since with what has been closed and what deliberately has not. Ten findings, seven closed outright; the rest carry their reasons, and the trade-offs listed above are written out there in one piece rather than one line each. Two of the review's own sentences turned out to be wrong, and the answers stand beside them — which is the whole argument for keeping a review after its list has gone quiet.
Three layers, cleanly separated:
| Layer | Type | Task |
|---|---|---|
| UI | Program |
Command line, command dispatch, presentation. Holds no protocol logic |
| Application | XMPPClient |
Session state (conversation partner, pending contact requests, last message id) and composite operations |
| Protocol | XMPPConnection |
WebSocket I/O, SASL, resource binding, stanza routing |
Only the first of these lives in this repository; the other two come from
Ratatoskr. XMPPClient and XMPPConnection write nothing to the console —
everything runs over events and the injected ILoggerFactory.
That injected logger is why ConsoleUI/ exists. A console that keeps an input
line has a problem a pure output does not: the user is typing right now. An
AddSimpleConsole writes whenever it suits it, straight into a half-finished
line. ConsoleOutput is the one door everything goes through — incoming
events, system notices and the log — under one lock: the input line gives way,
the output appears, the prompt is back afterwards.
Log levels: Information for connection steps, Debug for protocol details,
Trace for single stanzas (that is what -v turns on), Warning for spoofing
attempts fended off and protocol oddities.
XMPPConsole/ the console application
├── XMPPConsole.csproj
├── Program.cs command line, dispatch, presentation
└── ConsoleUI/
├── ConsoleOutput.cs one lock, one line, one output
└── ConsoleOutputLoggerProvider.cs the log through the same door
XMPPConsole.Tests/ eight tests on one question:
└── ConsoleOutputTests.cs does the input line survive output?
docs/STANDARDS.md catalogue of relevant RFCs and XEPs
Pinned submodules, inside the repository:
libs/Ratatoskr/ XMPP protocol: client, server, XEPs, OMEMO
libs/Hermod/ network stack: TCP/TLS, HTTP, DNS/SRV, WebSockets
libs/Styx/ data flow / pipeline abstractions
| Submodule | Repository | Purpose |
|---|---|---|
libs/Ratatoskr |
Vanaheimr/Ratatoskr | The XMPP protocol — everything this console drives |
libs/Hermod |
Vanaheimr/Hermod | Network stack Ratatoskr builds on |
libs/Styx |
Vanaheimr/Styx | Data-flow abstractions Hermod builds on |
The dependency chain runs XMPPConsole → Ratatoskr → Hermod → Styx; only the
first reference is declared here, the rest follow.
The namespace of this application is org.GraphDefined.Vanaheimr.XMPPConsole
(and …XMPPConsole.ConsoleUI); the protocol types come from
org.GraphDefined.Vanaheimr.Ratatoskr.
dotnet test XMPPConsole.Tests/XMPPConsole.Tests.csprojTwenty-nine tests on three questions, and all three are things that can be got wrong invisibly.
Eight ask whether the input line survives an output that comes unasked — a log line written past the prompt looks fine in isolation and only shows up while somebody is typing, to the one person it happened to.
Thirteen are about the chat log and what it fetches, and there the invisible
part is that the input comes from strangers. A JID becomes a directory name, a
message becomes a download: the tests are accordingly about a resource called
..\..\Windows and about a link inside a sentence.
The last eight ask which endpoints this client will open at all — ws:// is
refused unless --insecure says otherwise, which is one comparison and exactly
the kind of thing that stays silently correct until the day it is silently not.
NUnit, in the same versions as the other Vanaheimr suites.
All twenty-nine run on Windows and on Debian 13, and that is not symmetry for
its own sake: what a stranger's name becomes as a file name is a platform's
answer. ..\..\Windows is one segment on Linux and three on Windows, and NUL
is a device on one and an ordinary name on the other. Checked on one platform,
a sanitiser is checked in one of the two situations it exists for.
Reading and decrypting an aesgcm:// URL is checked with
Ratatoskr rather than here — it is
XEP-0454 and therefore protocol, not console. What this application answers for
itself, and what is checked here, is whether to fetch at all.
The protocol is not checked here. Its suite lives with Ratatoskr, in the submodule:
dotnet test libs/Ratatoskr/RatatoskrTests/RatatoskrTests.csprojAnd everything that needs a foreign implementation — Prosody, ejabberd, python-omemo — lives in XMPPConformanceTests, next to the setups that produce those far sides.
Apache License 2.0 — see LICENSE, like the other Vanaheimr projects.