Most products end up with three separate chat systems bolted on: a support widget from one vendor, a messaging feature built in-house, and a bot glued to whichever of the two was easier to reach. Three integrations, three places your user data lives, three bills, and a bot that can't see your catalogue.

VBWD takes a different route. The messenger is a plugin inside the platform, the end-to-end encryption is a second plugin that layers onto it, and the bots are a transport-neutral framework that plugs into either the messenger or Telegram. Here's how the pieces actually fit — including the parts that are deliberately missing.

meinchat: the messenger

meinchat is a real 1-on-1 and group messenger living in your own application. What ships:

What's just as interesting is what's not there. There are no typing indicators. There is no live presence — "last seen" is derived from the last message, and that's a locked decision, not a to-do. There are no channels. And there is no conversation-content inspector in the admin panel: an operator can manage nicknames, ban abuse, and audit token transfers, but there is no screen anywhere that lets an admin read users' messages. That's a design position, and it's enforced by the absence of the feature.

The transport, and a lesson that cost a production outage

Messages arrive over Server-Sent Events, not websockets. The browser opens an EventSource authenticated by a short-lived stream token (60 minutes), because EventSource can't send headers. Across multiple server workers, fan-out runs through Redis pub/sub, with an in-process fallback for single-worker setups. iOS polls rather than streaming.

The war story is worth telling because it's the kind of thing you inherit for free by using something that already hit the wall. Early on, production froze — unrelated pages hanging for thirty seconds. The cause wasn't capacity: the API ran synchronous workers, and a synchronous worker serves exactly one request at a time. Every open chat tab permanently parked a worker. Four chat tabs anywhere on the platform and everything else queued until the browser gave up. Raising the worker count would only have changed how many tabs it took to freeze.

The fix, now baked into the shipped config, was threaded workers sized to the database pool — roughly four to two-hundred-odd concurrent connections — plus releasing the database connection during the idle wait, and a cap on stream lifetime (the browser silently reconnects, so the cap is invisible). Specifically threaded workers and not greenlets, because the event bus uses a blocking queue that cooperates with OS threads and not with un-patched greenlets. There's a second, separate lesson in the docs about proxy buffering: every proxy hop has to disable it or messages only appear on refresh — and nginx consumes the relevant header rather than forwarding it, so each hop must re-assert it.

None of that is glamorous. All of it is the difference between a chat demo and a chat feature.

Retention: short by default, and honest about it

Server-side message retention defaults to two days. Attachments, the same. A nightly job hard-deletes expired rows and attachment objects — no tombstones, no soft-delete graveyard. Set retention to zero and the server becomes effectively amnesic, dropping messages on the next tick.

There's also a suggested client-side retention window, and the documentation is refreshingly blunt about what it is: a best-effort UX default, not a security guarantee, because a forked client can ignore it. That sentence tells you more about the project's posture than any marketing page could.

On the client, the local message cache is encrypted at rest — AES-GCM over IndexedDB under a key derived from the device-pairing passphrase with Argon2id, held only for the tab session; on iOS, file protection plus a Keychain-held key.

meinchat-plus: end-to-end encryption, as a separate plugin

Here's the part to be precise about, because precision is the whole point in cryptography: base meinchat is not end-to-end encrypted. It has an encrypted local cache; the messages themselves are readable by the server. If you want E2E, you enable meinchat-plus, a separate plugin that layers onto meinchat through its extension ports.

What plus adds is a Signal-style double ratchet, and the architectural claim is stated plainly in its README: the server holds no keys and never encrypts or decrypts. Clients encrypt. The server validates that the opaque envelope has a legal shape and size, stores it, broadcasts it, and tracks per-device delivery. That's the entire server role.

Around that sit the things a real E2E implementation needs and a demo skips:

One honest wrinkle that shows the pieces are genuinely wired together: the loader refuses to enable meinchat-plus if you've set server retention to zero. Asynchronous first-message delivery requires the server to hold the ciphertext until the recipient comes online. You can have amnesia or you can have offline delivery. The platform makes you pick, out loud, instead of silently dropping messages.

Both plugins are source-available under BSL 1.1, same as the rest of the platform. Plus is a separate, optional deployment rather than a locked tier — there's no feature gate in the code.

The bots: a framework, not a bot

bot-base is the interesting one, and it isn't a bot at all. It's a transport-neutral core: neutral message types (text, links, choice buttons), a registry of messenger providers, a command dispatcher, conversation-ownership tracking with an idle timeout, and one-time tokens for linking an external messenger account to a platform user. It has no HTTP surface of its own and depends on nothing.

The extension model is the part worth stealing. There's no registration call to remember: being an enabled plugin that implements the seam is the registration. The dispatcher collects command providers from the enabled plugin set — so a disabled plugin contributes exactly nothing, automatically.

Around that core sit four adapters and consumers:

bot-meinchat bridges bots into the messenger — in-process, with no webhook and no polling. The bot is provisioned as a real user with a bot role and a nickname, so anyone can find it in nickname search and start a conversation with it like a person. Its inbound hook only ingests messages in conversations where the bot is actually a participant; every human-to-human chat is untouched. And it negotiates crypto adaptively: plaintext protocol when plus isn't installed, E2E when a real device directory exists — detected through the seam, never hard-imported.

bot-telegram is the same bot logic on a different transport. Webhooks are the production path, validated with a per-bot secret token; long-polling exists for development when you have no public HTTPS. Multiple bots, one pipeline.

bot-search adds a /search command over your catalogue — and it's a lovely illustration of the agnostic-core rule. It reads only a core search registry and never imports a catalogue model. Shop, booking, GHRM, and subscription plugins each register their own search provider when they're enabled. Better still, the core registry hard-blocks a set of entity types — users, user details, invoices — by raising if a plugin tries to register one. There is no code path by which a bot can be made to search your customers or their invoices, even by mistake.

bot-meinchat-llm is a RAG-grounded consultant. It answers via an explicit /consultant command or ambiently — a guest in the widget can just ask a question. Retrieval runs on Postgres full-text search over your own documents: no external vector database to operate. It keeps two separate corpora, which is a genuinely good idea — a knowledge corpus of product docs, and a training corpus of how-to-sell lessons and example dialogues that's always injected. Prices and recommendations come from the live catalogue through the platform's pricing engine, so the bot never invents a price. And on a buy intent it can mint a referral coupon and a checkout deep link, so a sale it closes is attributable.

For guests there's a word-based token economy — one token per word, a starting balance, and a switch to stop charging guests for the bot's own answers, on the reasoning that a sales bot's long pitch shouldn't drain a prospect's balance.

One place for the API key

Every LLM-consuming plugin here — the consultant bot, the CMS's AI panel, and the rest — holds no API key at all. Keys live in a core registry of LLM connections that an admin configures once in Settings. A plugin's only LLM setting is an optional connection slug; leave it empty and it uses the active default. OpenAI and Anthropic are supported, and the provider is derived from the model name unless you override it, so callers never branch. The key itself is encrypted at rest and only ever returned as a masked preview — the admin API cannot hand back a full key it already stored.

The honest limits

No presence, no typing indicators, no channels — by choice, but if you need them, you'd be building them. Base meinchat is not E2E; that's a second plugin to deploy. The consultant's retrieval is full-text rather than semantic, which is cheaper and simpler to run but not the same as embeddings. Short default retention is great for privacy and a poor fit if your users expect years of scrollback — it's a config change, but a deliberate one. And it's self-hosted: you run it.

Why it's built this way

The thread running through all of it is that chat, encryption, bots, and your catalogue aren't separate products stitched together — they're plugins over one core that knows about users, money, events, and permissions. That's why the bot can quote a real price, why sending tokens is a message, why the bot's search physically cannot reach your invoices, and why turning on end-to-end encryption is enabling a plugin rather than a migration project.

Explore the plugin catalogue, the architecture, and the developer docs, or browse the source on GitHub. VBWD is free for commercial use while VBWD-attributable sales stay under the value of 6.7 BTC a year.