XMPP Bots

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-10-01 against slixmpp 1.17.0 (Python 3.12) with the LangChain 1.4.3 core, Smack 4.5.0 (Java 21) with the Spring AI 2.0.1 core on Spring Boot 4.1.1, ejabberd 26.09, Openfire 5.1.2 and the current Prosody documentation (see the version baseline). The snippets are illustrative: they were written from the official slixmpp and Smack documentation and are not compiled or run by this repository. The slixmpp plugin and method names and the Smack class and method names were checked against the official API documentation on that date; server configuration keys are summarized from memory of the vendors' documentation and must be checked against the version you deploy. XEP statuses in the table below were read from the XSF index on the same date.

XMPP (the Extensible Messaging and Presence Protocol) is the oldest protocol in this sub-section and the only one that you can federate and self-host end to end: your assistant is just another account, or another service, on a chat server that you or your organisation run. It is a good fit for internal tooling, for communities that already live on XMPP and for situations where the data must not leave your infrastructure. It has no rich cards and no native token streaming, so the adapter leans on three small extensions: chat states for a typing indicator, message correction to grow one message as tokens arrive, and the archive for history. The same channel-agnostic core described in Channel-Agnostic Architecture sits behind it. For how XMPP compares with the other wire protocols used for messaging, see Messaging Protocols.

XMPP in five minutes

JIDs

Every entity has a Jabber ID (JID), written localpart@domainpart/resourcepart. The bare JID docs-bot@chat.example.com identifies an account; the resource (/laptop, /phone, /worker-1) identifies one connected session of that account. Routing for a bare JID goes to the most suitable connected resource, so a person can be signed in on two devices at the same time. For the adapter this gives a ready-made identity: the bare JID of the sender is the userId, and the pair (user bare JID, bot bare JID) or the room JID is the conversationId. The server authenticates the sender; the bot only needs to trust the from attribute of stanzas delivered by its own server, which is what makes XMPP identity mapping simpler than on most business channels. The access-control model from RAG Access Control & Privacy applies unchanged on top of that userId.

Stanzas

All traffic is XML stanzas sent over one long-lived stream (RFC 6120). There are three kinds:

Stanza Role for a bot

<message>

Chat text. The type attribute is chat (one to one), groupchat (a room) or normal. The id attribute matters because XEP-0308 corrections refer to it, and the optional <body> carries the text.

<presence>

Availability (available, unavailable, away) and subscriptions. A bot sends initial presence after login so that the server routes messages to it; joining a room is also a presence stanza addressed to room@service/nick.

<iq>

Request and response, used for service discovery (XEP-0030), archive queries (XEP-0313), pings and roster fetches.

The base semantics of messaging and presence are in RFC 6121; the core transport, TLS and SASL are in RFC 6120. Both are listed in the references.

Presence

A bot that does not send presence receives little or nothing. After the session starts it sends one <presence/>, and it may later publish a status (dnd while a long ingestion job runs, for example). Contacts that want to see the bot’s availability subscribe to it; accepting subscriptions automatically is acceptable for a public assistant and a bad idea for an internal one, where the server’s roster or allow-list should decide who can talk to the bot at all.

Servers

All three servers below are mature, support the extensions used on this page and can be self-hosted; the choice is mostly operational.

Server Written in / configured by Notes for a bot

ejabberd 26.09

Erlang; one YAML file

Scales to very large clusters. Built-in MUC and MAM modules. External components connect through a listen entry that uses the component protocol, with a per-domain secret. Rate limits per listener and per user are configurable.

Prosody

Lua; a Lua configuration file with modular plugins

Light to run and easy to extend. MUC and MAM are enabled through module lists, and external components are declared as Component "docs-bot.example.com" with a component_secret, optionally listening on the dedicated component port.

Openfire 5.1.2

Java; web admin console and plugins

Convenient when the team prefers a GUI and a JVM stack. The console manages users, MUC rooms and external components, and the Ignite Realtime project maintains Smack, the Java library used below, alongside it.

For development, any of them can run in a container with a self-signed certificate and a user created from the command line or console; for production, use a real certificate for the domain (see security).

Client bot or component

There are two ways to attach a bot to a server.

Client-account bot Component (XEP-0114)

What it is

An ordinary account, for example docs-bot@chat.example.com, that logs in with SASL like a human user.

A service that owns a whole sub-domain, for example docs-bot.chat.example.com, and authenticates with a shared secret configured on the server.

Addressing

One bare JID; people add it to their contact list or invite it to rooms.

Any JID under the sub-domain (anything@docs-bot.chat.example.com), so one process can present many virtual identities.

Server cooperation

None beyond an account. Works on any server, including hosted ones that allow bot accounts.

The server operator must register the component and its secret, so it only suits servers you control.

Features

Presence, roster, carbons, stream management, MAM of its own conversations.

Raw stanza routing; the server does not manage roster or archive for the component, so those are yours to build.

Best fit

DocsAssistant as one visible contact or a room participant.

Gateways and multi-tenant services where each customer gets a JID.

XEP-0114 (Jabber Component Protocol) is classified by the XSF as a historical specification, but it remains the interface that ejabberd, Prosody and Openfire all offer for external components. slixmpp provides a ComponentXMPP class (it takes the component JID, the secret, and the server host and port) for that mode. Because DocsAssistant needs one identity and benefits from roster, carbons and archive, a client-account bot is the default recommendation, and the rest of this page uses it.

Conversation shape

A single exchange between a person and the bot looks like this, using the extensions described below:

sequenceDiagram participant U as "User client" participant S as "XMPP server" participant B as "Bot adapter" participant C as "DocsAssistant core" U->>S: message type=chat id=m1 body="How do I configure X?" S->>B: message m1 B->>S: message chat state=composing S->>U: composing indicator B->>C: ask(conversationId, userId, text) C-->>B: token, token, token ... B->>S: message id=r1 body="First words" S->>U: shows r1 B->>S: message id=r2 replace id=r1 body="First words and more" S->>U: client replaces r1 C-->>B: citation, done B->>S: message id=r3 replace id=r1 body="Full answer with links" B->>S: message chat state=active

The pieces, in order of importance for a bot, are below.

One-to-one chats and rooms (XEP-0045)

A one-to-one chat is a <message type="chat"> to the bot’s JID. A room is a Multi-User Chat (XEP-0045): the bot joins docs@conference.chat.example.com with a nickname by sending presence to room/nick, then receives type="groupchat" messages from room/othernick. Three adapter rules follow:

  • Answer only when addressed. In a room, people talk to each other. Respond only to messages that mention the bot’s nickname or start with a command prefix, and never respond to your own echoes (the room reflects the bot’s messages back with its own occupant JID).

  • Identity is the occupant, not the room. In a room the visible sender is room/nick; the real bare JID is visible to every occupant in a non-anonymous room and only to moderators in a semi-anonymous one. Use the room JID plus the nickname as the conversation key, and treat authorization in rooms as a coarser policy (everyone in the room sees the answer) than in one-to-one chats.

  • Be careful with data the room has not earned. An answer drawn from restricted documents must never be posted to a room whose members lack the access; filter retrieval by the lowest common privilege, or answer privately by sending the user a one-to-one message with a short notice in the room.

Chat states while generating (XEP-0085)

Generation latency is the main user-experience problem of a chat bot. XEP-0085 defines five states carried as child elements of a message: active, composing, paused, inactive and gone. Send composing as soon as a question is accepted, optionally refresh it every few seconds for long answers, and send active with the final message (or with the last correction) to clear the indicator. The state costs one tiny stanza and works in virtually every client; it is the direct XMPP equivalent of the typing indicators discussed in Agent UX. A chat state message must have no body of its own.

Pseudo-streaming with message correction (XEP-0308)

XMPP has no way to stream into a message. XEP-0308 (Last Message Correction) is the closest standard tool: the sender transmits a new message with a <replace xmlns="urn:xmpp:message-correct:0" id="…​"/> element whose id is the id of the original message, and clients that understand it update the displayed message in place. Three rules from the specification matter for implementation:

  • only the original sender can correct, so the bot corrects its own messages;

  • every correction refers to the id of the first message, not to the previous correction;

  • clients that do not support it show each correction as a new message, so a bot that edits dozens of times will spam those clients.

The last point is why edits must be rate limited. If an answer is generated in \( T \) seconds and the bot edits at most once per \( \Delta \) seconds, the number of stanzas it sends per answer is

\[ N_{\text{edits}} = \left\lceil \frac{T}{\Delta} \right\rceil + 1 , \]

where the extra stanza is the first message. With \( T = 12 \) s and \( \Delta = 1.5 \) s that is nine stanzas, which displays smoothly and stays far below typical per-user message rate limits on servers; editing per token (hundreds of stanzas) trips those limits and can get the account throttled or disconnected. Practical rules: buffer tokens, send the first message after the first sentence or about one second, edit no more often than once per second or so, always send a final correction with the complete text and citations, and provide a configuration switch to fall back to a single final message for clients or rooms where corrections are not honoured. Whether a given server allows a correction in a room, and how long its archive keeps both versions, depends on the server and client; test with the clients your users have.

Message history (XEP-0313)

Message Archive Management lets an entity query the server-side archive with <iq> requests, filtered by time and by conversation partner, and receive the results as forwarded messages in pages. DocsAssistant has two uses for it. The first is continuity: after a restart, the bot can reload the last messages of a conversation instead of relying only on its own store. The second is audit and support: operators can export what was asked and answered. The authoritative history for the model remains the checkpointer or chat memory of the core (see LangChain: Short-Term Memory & Checkpointers and Spring AI: Chat Memory); the archive is a convenience, and it stores messages in clear text on the server, so privacy policy and retention must be decided consciously. slixmpp exposes retrieve() and an async generator iterate() on the xep_0313 plugin; Smack provides MamManager with queryArchive(MamQueryArgs), which returns a MamQuery page and supports pageNext().

The XEPs used

XEP Title Status (2026-10-01) What the bot uses it for

XEP-0030

Service Discovery

Final

Learning whether the server supports MAM, MUC and corrections; advertising the bot’s own features.

XEP-0045

Multi-User Chat

Stable

Rooms: join with a nickname, receive groupchat messages, answer when mentioned.

XEP-0085

Chat State Notifications

Final

composing while the core generates; active at the end.

XEP-0114

Jabber Component Protocol

Active, historical type

The component mode: a sub-domain with a shared secret, used for gateways and multi-identity services.

XEP-0184

Message Delivery Receipts

Stable

Optional; confirms that the user’s client received an answer.

XEP-0198

Stream Management

Stable

Resumes a dropped TCP connection without losing stanzas; valuable for a long-running bot.

XEP-0199

XMPP Ping

Final

Keep-alive and dead-connection detection.

XEP-0280

Message Carbons

Stable

Lets the bot’s other resources (for example a second worker) see a copy of messages for a single account.

XEP-0308

Last Message Correction

Stable

Pseudo-streaming: edit one message as tokens arrive, at a bounded rate.

XEP-0313

Message Archive Management

Stable

Reading conversation history from the server for continuity and audit.

The Python bot: slixmpp over the LangChain core

slixmpp is an asyncio library, which matches the async generator of the LangChain core. The bot registers the plugins it needs by their xep_NNNN names, handles the session_start event to send presence, and handles the message event. The plugin names used here are xep_0030 (discovery), xep_0085 (chat states), xep_0199 (ping) and xep_0308 (the replace plugin, whose helper correct_message(msg, body) builds and sends a correction of an earlier message).

import asyncio
import os
import time

import slixmpp

from docs_assistant.core import ask  # async generator of {"type": "token"|"citation"|"done", ...}

EDIT_INTERVAL = 1.5  # seconds between corrections


class DocsAssistantBot(slixmpp.ClientXMPP):
    def __init__(self, jid: str, password: str):
        super().__init__(jid, password)
        self.register_plugin("xep_0030")  # Service Discovery
        self.register_plugin("xep_0085")  # Chat state notifications
        self.register_plugin("xep_0199")  # XMPP Ping
        self.register_plugin("xep_0308")  # Last message correction
        self.add_event_handler("session_start", self.on_start)
        self.add_event_handler("message", self.on_message)

    async def on_start(self, _event):
        self.send_presence()
        await self.get_roster()

    def on_message(self, msg):
        if msg["type"] not in ("chat", "normal") or not msg["body"].strip():
            return  # ignore groupchat here, chat-state-only stanzas and empty bodies
        asyncio.create_task(self.answer(msg))

    def send_state(self, to, state: str):
        state_msg = self.make_message(mto=to, mtype="chat")
        state_msg["chat_state"] = state
        state_msg.send()

    async def answer(self, incoming):
        peer = incoming["from"]
        user_id = peer.bare
        conversation_id = f"{user_id}|{self.boundjid.bare}"

        self.send_state(peer, "composing")
        text, citations = "", []
        first = None
        last_edit = 0.0
        async for event in ask(conversation_id, user_id, incoming["body"]):
            if event["type"] == "token":
                text += event["text"]
            elif event["type"] == "citation":
                citations.append(event)
            now = time.monotonic()
            if text and now - last_edit >= EDIT_INTERVAL:
                if first is None:
                    first = self.make_message(mto=peer, mbody=text, mtype="chat")
                    first.send()
                else:
                    self["xep_0308"].correct_message(first, text)
                last_edit = now

        final = text + "".join(f"\n[{i + 1}] {c['url']}" for i, c in enumerate(citations))
        if first is None:
            self.make_message(mto=peer, mbody=final, mtype="chat").send()
        else:
            self["xep_0308"].correct_message(first, final)
        self.send_state(peer, "active")


async def main():
    bot = DocsAssistantBot(os.environ["XMPP_JID"], os.environ["XMPP_PASSWORD"])
    bot.connect()
    await bot.disconnected


if __name__ == "__main__":
    asyncio.run(main())

Notes on the sketch. The password comes from the environment and is never written to source or logs. The handler returns immediately and schedules the answer as a task so that one slow generation does not block the stream, which also keeps pings and presence flowing. The ask async generator is the one from the LangChain core (create_agent, retriever tool and Postgres checkpointer, streamed with astream; see LangChain: Streaming); because the checkpointer is keyed by conversation_id, a restart of the bot does not lose the thread. The first message needs an id for the correction to refer to; slixmpp assigns one when the message is created, and correct_message uses it. Two details are version dependent and should be checked against the installed slixmpp: whether bot.connect() needs an explicit address when the domain has SRV records, and whether disconnected is the awaitable you want for shutdown handling.

Joining a room

For a room, use the XEP-0045 plugin (self.register_plugin("xep_0045")) and its asynchronous join:

async def on_start(self, _event):
    self.send_presence()
    await self["xep_0045"].join_muc_wait(
        "docs@conference.chat.example.com", "DocsAssistant", maxstanzas=0, timeout=30
    )

# in __init__:
# self.add_event_handler("groupchat_message", self.on_room_message)

def on_room_message(self, msg):
    nick = "DocsAssistant"
    if msg["mucnick"] == nick or nick.lower() not in msg["body"].lower():
        return  # ignore our own echoes and messages that do not address us
    asyncio.create_task(self.answer_room(msg))

Source: https://slixmpp.readthedocs.io/en/latest/api/plugins/xep_0045.html (join_muc_wait, groupchat_message).

maxstanzas=0 asks the room not to replay history on join, so the bot does not answer old questions. In rooms the reply is sent as mtype="groupchat" to msg["from"].bare; the correction logic is identical to the one-to-one case.

Reading history

async def recent(self, peer_bare_jid: str, limit: int = 20):
    messages = []
    async for result in self["xep_0313"].iterate(with_jid=peer_bare_jid, reverse=True, total=limit):
        messages.append(result["mam_result"]["forwarded"]["stanza"]["body"])
    return messages

Source: https://slixmpp.readthedocs.io/en/latest/api/plugins/xep_0313.html (retrieve and the iterate async generator).

This needs register_plugin("xep_0313") and a server with MAM enabled. The exact shape of the yielded result differs between slixmpp versions; check the plugin’s reference before relying on that access path.

Running as a component

For component mode, replace the client class with slixmpp.ComponentXMPP and connect with the component JID, the secret and the server’s component host and port (read from environment variables again); the handler code is the same, except that the bot must set the from of every outgoing stanza explicitly because a component can speak as any JID under its domain, and it has no roster or archive of its own.

The Java bot: Smack over the Spring AI core

Smack’s chat2 package is the maintained API for one-to-one chats: ChatManager.getInstanceFor(connection) returns the manager, addIncomingListener registers an IncomingChatMessageListener, and the Chat object it hands to the listener has send(CharSequence) and send(Message). Chat states come from ChatStateManager.getInstance(connection) with setCurrentState(ChatState, Chat) and the ChatState enum, and corrections from org.jivesoftware.smackx.message_correct.element.MessageCorrectExtension, built from the id of the original message.

The core is a Spring bean with a reactive signature that returns the events of the Spring AI ChatClient (RAG advisor, JDBC chat memory, streamed with .stream(); see Spring AI: Streaming & Reactive):

public interface AssistantCore {
    Flux<AnswerEvent> ask(String conversationId, String userId, String text);
}

public record AnswerEvent(String type, String text, String url) {}

The adapter is a Spring component that owns the connection:

@Component
public class XmppAdapter {

    private static final Duration EDIT_INTERVAL = Duration.ofMillis(1500);

    private final AssistantCore core;
    private final AbstractXMPPConnection connection;

    public XmppAdapter(AssistantCore core,
                       @Value("${xmpp.domain}") String domain,
                       @Value("${xmpp.username}") String username,
                       @Value("${XMPP_PASSWORD}") String password) throws Exception {
        this.core = core;
        XMPPTCPConnectionConfiguration config = XMPPTCPConnectionConfiguration.builder()
                .setXmppDomain(domain)
                .setUsernameAndPassword(username, password)
                .setSecurityMode(ConnectionConfiguration.SecurityMode.required)
                .setResource("docs-assistant")
                .build();
        this.connection = new XMPPTCPConnection(config);
    }

    @PostConstruct
    void start() throws Exception {
        connection.connect().login();
        ChatStateManager chatStates = ChatStateManager.getInstance(connection);
        ChatManager.getInstanceFor(connection).addIncomingListener(
                (EntityBareJid from, Message message, Chat chat) -> {
                    if (message.getBody() == null || message.getBody().isBlank()) {
                        return; // chat-state-only stanzas have no body
                    }
                    answer(chat, chatStates, from.toString(), message.getBody());
                });
    }

    private void answer(Chat chat, ChatStateManager chatStates, String userId, String text) {
        String conversationId = userId + "|" + connection.getUser().asBareJid();
        send(() -> chatStates.setCurrentState(ChatState.composing, chat));

        AtomicReference<String> firstId = new AtomicReference<>();

        core.ask(conversationId, userId, text)
            .scan(Draft.EMPTY, Draft::plus)              // immutable cumulative state, no shared buffer
            .skip(1)                                     // drop the empty seed
            .sample(EDIT_INTERVAL)                       // at most one edit per interval; the last draft is kept
            .doOnNext(d -> publish(chat, firstId, d.text()))
            .last(Draft.EMPTY)
            .subscribeOn(Schedulers.boundedElastic())
            .subscribe(
                d -> {
                    publish(chat, firstId, d.withCitations());
                    send(() -> chatStates.setCurrentState(ChatState.active, chat));
                },
                error -> {
                    send(() -> chatStates.setCurrentState(ChatState.active, chat)); // clear "composing"
                    publish(chat, firstId, "Sorry, I could not finish this answer. Please try again.");
                });
    }

    private record Draft(String text, List<String> citations) {
        static final Draft EMPTY = new Draft("", List.of());

        Draft plus(AnswerEvent e) {
            if ("token".equals(e.type())) return new Draft(text + e.text(), citations);
            if ("citation".equals(e.type())) {
                List<String> next = new ArrayList<>(citations);
                next.add(e.url());
                return new Draft(text, List.copyOf(next));
            }
            return this;
        }

        String withCitations() {
            StringBuilder full = new StringBuilder(text);
            for (int i = 0; i < citations.size(); i++) {
                full.append("\n[").append(i + 1).append("] ").append(citations.get(i));
            }
            return full.toString();
        }
    }

    private void publish(Chat chat, AtomicReference<String> firstId, String body) {
        send(() -> {
            if (firstId.get() == null) {
                String id = "docs-" + UUID.randomUUID();
                firstId.set(id);
                chat.send(StanzaBuilder.buildMessage(id)
                        .to(chat.getXmppAddressOfChatPartner())
                        .ofType(Message.Type.chat)
                        .setBody(body)
                        .build());
            } else {
                chat.send(StanzaBuilder.buildMessage()
                        .to(chat.getXmppAddressOfChatPartner())
                        .ofType(Message.Type.chat)
                        .setBody(body)
                        .addExtension(new MessageCorrectExtension(firstId.get()))
                        .build());
            }
        });
    }

    private void send(ThrowingRunnable action) {
        try {
            action.run();
        } catch (Exception e) {
            // log and drop: a failed typing indicator or edit must not break the stream
        }
    }

    @FunctionalInterface
    interface ThrowingRunnable { void run() throws Exception; }

    @PreDestroy
    void stop() {
        connection.disconnect();
    }
}

Notes on the sketch. The password is injected from an environment variable. SecurityMode.required makes the connection fail instead of falling back to plain text if the server does not offer TLS. Smack invokes the incoming listener on its own processing thread, so the adapter hands the work to a Reactor scheduler instead of blocking on the stream, and sample implements the rate limit by keeping only the latest state per interval. That state is built with scan into immutable Draft values rather than a shared StringBuilder, because sample publishes from its timer thread while events arrive on another; since each draft is cumulative, the dropped ones lose nothing, and last hands the final draft to the subscriber, which adds the citations. The error consumer clears the composing state and replaces the partial answer with an apology, so a failed stream never leaves the user watching a typing indicator. Two points are not confirmed from the documentation consulted: the stanza builder entry point (StanzaBuilder.buildMessage(…​) in Smack 4.5; the MessageBuilder class documents setBody, ofType and the inherited to and addExtension) and the generation of a unique stanza id, which here is a plain UUID string and could simply be StanzaIdUtil.newStanzaId(); verify both against the Smack 4.5.0 Javadoc. Smack 4.6.0-alpha1 exists but is not used. Note also that Smack’s chat state manager only sends a state when it changes, which is why active at the end of the answer is sent after composing and not repeated.

Rooms and history in Smack

For rooms, obtain MultiUserChatManager.getInstanceFor(connection), call getMultiUserChat(roomJid), then join(Resourcepart nickname) (an overload accepts a MucEnterConfiguration to limit replayed history), register a MessageListener with addMessageListener, and leave the room on shutdown with leave(). Smack’s MamManager, obtained with MamManager.getInstanceFor(connection), offers isSupported() and queryArchive(MamQueryArgs).

Security

Concern Practice

Transport

Require TLS on the client-to-server connection (STARTTLS on port 5222 or direct TLS on 5223, depending on the server) and verify the server certificate against the domain. Never disable certificate or hostname verification to make a self-signed development server work in production code; add the development CA to the trust store instead. Smack’s SecurityMode.required and slixmpp’s default TLS handling both refuse to continue without encryption, but check the settings of the version you deploy.

Authentication

SASL: prefer SCRAM-SHA-256 or SCRAM-SHA-1 over PLAIN, and use PLAIN only inside a TLS session. A bot account should have a long random password stored in a secret manager (see Azure Key Vault for one option) and injected through the environment; components use a separate shared secret that is also a credential.

Authorization

Decide who may talk to the bot: a server-side allow-list, a restricted registration policy or roster-based filtering. Open federation lets any server on the internet send messages to the bot, which means unauthenticated senders from other domains; for an internal assistant, disable server-to-server connections or restrict them to trusted domains.

End-to-end encryption

TLS protects only the hops. The server, and the message archive, see clear text. If the assistant answers confidential questions, state that clearly; OMEMO-style end-to-end encryption is a separate set of specifications and clients and is not covered here.

Abuse and cost

Apply per-user rate limits and a maximum message length before calling the model, and limit concurrent generations per user. See Operating Chat Channels for the cross-channel view and Spring AI: Security & Guardrails for prompt-injection controls.

Logging

Do not log message bodies or stanza dumps in production; they contain user questions and retrieved text.

When to choose XMPP

Choose it when you control the server, want federation or self-hosting, or your users already run XMPP clients. Do not choose it for a consumer product that needs cards, buttons or file previews, since XMPP clients render only text and whatever extensions they happen to support. Among the channels in this sub-section it ranks lowest for presentation and highest for control, and its adapter is correspondingly small. For web users the better path is Streaming Chat APIs or a web chat widget.

References