Every product we've shipped at Maladeta Studio eventually needs to talk to people outside of a browser tab. Lueira sends WhatsApp replies. The marketplace platform sends order confirmations and shipping updates by email. Internal tooling needs to ping someone when a job fails or a threshold is crossed. Each of those is a small feature on its own — until you've built it three or four times and realized you're solving the same problems over and over: retries when a provider hiccups, rate limits so you don't get your sending domain flagged, idempotency so a webhook retry doesn't send the same message twice, and an audit trail because someone will eventually ask "did we actually send this?"
Earlier this year we stopped solving that per product and built Maladeta Communications: a standalone, multi-tenant, event-driven platform whose only job is to receive events and turn them into delivered messages, over WhatsApp or email, with optional AI-assisted content generation. This post is about why we built it as its own service, how it's put together, and a boundary we were careful to draw around it.
1. Why every product solving this on its own doesn't scale
This is the same argument that's run through other posts on this blog — the one about e-invoicing, the one about eIDAS signatures. Complexity that's genuinely shared across products is worth absorbing once, in one place, rather than re-solving per team, per repo, per deadline. Message delivery has exactly that shape. Resend and WhatsApp (through our provider, Kapso) each have their own quirks, rate limits, and failure modes. Getting retries and backoff right is fiddly enough that you don't want three different implementations of it drifting out of sync. And compliance and support both eventually ask for a durable record of what was sent, to whom, and when.
So instead of every product calling Resend or Kapso directly, they publish an event. The communications engine decides what happens next.
2. Events in, messages out
The shape of the system is a straight pipeline, deliberately boring:
Sources (Kapso webhook, Lueira event stream)
|
v
ConnectorRegistry
|
v
EventPublisher
|
+----+----+
| |
v v
Postgres Redis Stream
(audit) (real-time)
|
v
Consumer (XREADGROUP, consumer groups)
|
+----------+----------+
| | |
v v v
email Q whatsapp Q ai Q
| | |
v v v
EmailWorker WhatsAppWorker AIWorker
(Resend) (Kapso) (Claude routing/content)
External sources — a webhook from Kapso, our WhatsApp provider, and an event stream coming from Lueira itself — land on a ConnectorRegistry, which normalizes them and hands them to an EventPublisher. That publisher writes to two places: PostgreSQL, as a permanent, queryable audit trail — every event that ever entered the system, durably recorded, no exceptions — and a Redis Stream, for real-time processing.
A consumer reads that stream using XREADGROUP, Redis's primitive for reliable, at-least-once fan-out to a group of workers. It fans events out into three separate BullMQ queues — email, whatsapp, and ai — each backed by its own worker. EmailWorker talks to Resend. WhatsAppWorker talks to Kapso. AIWorker handles routing and content generation for cases where a message needs to be composed rather than just relayed. Separating the queues means a slow or failing WhatsApp provider never backs up email delivery.
3. A deliberate boundary: delivery, not reasoning
If you've read the earlier posts on this blog about Lueira's WhatsApp AI agent and its function-calling setup, you'll know that RAG, prompt construction, model calls, and embeddings already live somewhere — in the Lueira assistant repository, with its own Qdrant instance and its own Mastra-based orchestration. It would have been easy, while building a new service that also touches WhatsApp and also touches Claude, to let some of that reasoning creep back in here. We were explicit that it shouldn't. The contributor guide for this repo says, verbatim: "AI agents, prompts, model calls, article embeddings, Qdrant, and transcription belong in the Lueira assistant repository. Do not reintroduce a local AI service or direct Mastra/Qdrant SDK dependency here."
What that means in practice: when the AIWorker needs actual AI capability — replying to a message, generating content, transcribing audio — it calls Lueira's separate assistant-service over HTTP, hitting endpoints like /ai/reply, /ai/generate, and /ai/transcribe. This engine decides whether and how to route and send a message. It is delivery infrastructure, not a second place where reasoning happens.
4. Scaling the pieces independently
Because the API/webhook layer, the queue workers, and the stream consumer do genuinely different jobs, they don't need to scale together. A PROCESS_TYPE environment variable — all, api, worker, or consumer — controls what a given process instance actually runs. In production, we can run more worker processes during a WhatsApp send spike without touching API capacity, or scale the consumer if the Redis Stream starts to lag.
This is a genuinely new piece of infrastructure — we built it in 2026, and it hasn't had years to get battle-tested the way some of our other systems have. But the shape of it already feels right: one place that absorbs the annoying, shared parts of sending a message reliably, and a hard line keeping it out of the business of deciding what that message should say.