User-visible changes in wacrm. Self-hosters: when pulling an update,
check this file for any migration required notes and apply the
matching SQL files from supabase/migrations/ against your Supabase
project before restarting the app.
Versions follow Keep a Changelog.
Pre-1.0, MINOR bumps cover new modules; PATCH bumps cover bug fixes
and polish.
Fixes inbound chats fragmenting into multiple threads for the same number.
Migration required: apply
supabase/migrations/036_conversation_contact_dedup.sql(merges any existing duplicate conversations into the oldest thread — no messages are lost — then adds aUNIQUE (account_id, contact_id)index so one contact can only ever have one conversation).
- Duplicate chats for a single contact. An inbound message could
create a second conversation for a contact under a race (Meta retries a
delivery, or a batch fans out to concurrent runs). Once two existed,
the
.single()lookup errored on every later message and the webhook created yet another conversation each time, snowballing into a wall of duplicate chats. The find-or-create now resolves to the oldest existing thread and a DB unique index makes the one-conversation-per-contact rule authoritative. The same hardening was applied to the public-API conversation resolver. (Issue #363)
Polishes the AI auto-reply bot: it's now visible and controllable from the inbox, its handoff actually hands off, and its token spend is logged.
Migration required: apply
supabase/migrations/033_ai_reply_polish.sql(addsmessages.ai_generated,ai_configs.handoff_agent_id,conversations.ai_handoff_summary, and theai_usage_logtable).
- "AI" badge in the inbox. Replies the bot sent are tagged with a
small ✨ AI badge, so agents can tell an automated reply from their own
or a Flow's at a glance. (New
messages.ai_generatedflag; only the auto-reply bot sets it.) - Take over / Resume from the thread. A banner on AI-handled
conversations lets an agent Take over (pauses the bot for that
thread and assigns it to them) or Resume AI (hands the thread back
and clears the pause). Backed by
POST /api/ai/autoreply/[id]. - Real handoff. When the bot bails (can't help, or hits the reply cap) it now (1) routes the conversation to a configurable handoff target — a specific agent, or the unassigned queue — and (2) leaves a short internal note summarizing the exchange for whoever picks it up. Assigning fires the existing assignment notification. Pick the target under AI Agents → Setup → Hand off to.
- Token-usage logging + dashboard. Every draft and auto-reply records
its provider token counts to the new
ai_usage_logtable (admin-readable). A new AI Agents → Usage tab (admin-only) charts daily token spend on your BYO key with per-mode and per-model breakdowns, backed byGET /api/ai/usage. Counts only — no message content is stored or shown.
- Auto-reply now has an account-wide rate limit (30/min) on top of the existing per-conversation cap, so a burst of inbound can't run your provider key past its limit. Over the limit, inbounds simply wait in the inbox for a human instead of being auto-answered.
Promotes the AI assistant to a first-class AI Agents section in the sidebar — it's no longer tucked inside Settings.
- AI Agents (sidebar). A dedicated
/agentsarea with two tabs:- Playground — a test chat to message your agent and see its
grounded, multi-turn replies (and where it would hand off to a human)
before it ever answers a real customer. Runs the exact same path as
the auto-reply bot (knowledge-base retrieval + your provider), and
works even before you flip the master switch on, so you can try, then
enable. Backed by
POST /api/ai/playground. - Setup — the provider/key, business context, knowledge base, and auto-reply controls (moved here from Settings → AI Assistant).
- Playground — a test chat to message your agent and see its
grounded, multi-turn replies (and where it would hand off to a human)
before it ever answers a real customer. Runs the exact same path as
the auto-reply bot (knowledge-base retrieval + your provider), and
works even before you flip the master switch on, so you can try, then
enable. Backed by
- The AI configuration moved out of Settings → AI Assistant into the new AI Agents section. No data change — same account config, new home. No migration required.
Adds an AI knowledge base so the assistant (0.5.0) can answer from your own content instead of handing off. Paste FAQs, policies, or product details under Settings → AI Assistant → Knowledge base; the relevant excerpts are retrieved into every draft and auto-reply.
- Knowledge base with hybrid retrieval. Lexical Postgres full-text
search works for every account with no extra credentials. Optional
semantic search (pgvector, OpenAI
text-embedding-3-small) turns on when you add an embeddings key — semantic-primary, topped up with lexical to fill the result set. Anthropic-only accounts (Anthropic has no embeddings API) keep the lexical path with zero extra setup. - Knowledge base manager in Settings — add/edit/delete documents and
a Reindex action to backfill embeddings after adding a key. Both
drafts and the auto-reply bot are grounded in the retrieved excerpts,
and the prompt still instructs the model to hand off (auto-reply) or
say it will follow up (draft) when the KB doesn't cover the question.
Migration required: apply
supabase/migrations/030_ai_knowledge.sql(enablespgvector; addsai_knowledge_documents+ai_knowledge_chunksand anembeddings_api_keycolumn onai_configs).
Adds the AI reply assistant — bring-your-own-key. Each account pastes its own OpenAI or Anthropic key under Settings → AI Assistant; wacrm calls the provider directly with that key, so there's no per-seat AI fee and your conversation data never leaves your own infrastructure for a wacrm-run service. The key is stored AES-256-GCM-encrypted at rest (same as WhatsApp tokens) and never returned to the client after saving.
- AI-drafted replies in the inbox. A ✨ button in the composer
(agent+) reads the recent conversation and drops a suggested reply
into the box for the agent to edit and send. Read-only server-side —
POST /api/ai/draftnever sends or stores anything. Respects your business context / persona from the settings prompt. - AI auto-reply bot. When enabled, inbound messages that no
deterministic Flow consumed and that have no agent assigned get an
automatic LLM reply. Bounded by a per-conversation cap
(
auto_reply_max_per_conversation, default 3) and a clean human handoff: when the model can't confidently help — or the customer asks for a person — it stays silent and leaves the message for a human, and won't auto-reply on that thread again until re-enabled. Flows always win over the bot. - Settings → AI Assistant (admin+ to edit): pick provider + model, paste your key, add business context/tone, toggle the assistant and auto-reply, set the per-conversation cap, and Test key against the provider before saving.
- Providers: OpenAI (Chat Completions) and Anthropic (Messages) behind
one interface; model is a free-text field with sensible defaults, so
you can point it at any current model your key can access.
Migration required: apply
supabase/migrations/029_ai_reply.sql(addsai_configs+ per-conversation auto-reply columns onconversations).
Completes the public API (#245): outbound event webhooks so automations can react to activity instead of polling.
- Outbound event webhooks (
/api/v1/webhooks). Register an HTTPS endpoint (scopewebhooks:manage) to be POSTed to when an event happens in your account —message.received,message.status_updated, orconversation.created. Manage endpoints withGET/POST /api/v1/webhooksandGET/PATCH/DELETE /api/v1/webhooks/{id}. Each delivery is signed with anX-Wacrm-Signature(HMAC-SHA256 overtimestamp.body) so receivers can verify authenticity and reject replays; the signing secret is returned once at creation and stored encrypted. Delivery is best-effort — an endpoint that fails repeatedly is auto-disabled after a threshold of consecutive failures. Seedocs/public-api.md. Migration required: applysupabase/migrations/028_webhook_endpoints.sql. (#245)
Multi-user accounts ship. Every wacrm install is multi-tenant on the
database side: a single user's signup creates a fresh "account", and
every row is scoped to that account rather than to the user directly.
This release also opens the user-visible Members surface — invite
teammates by link, manage their roles, transfer ownership — to all
users. The 'account_sharing' beta gate that hid it during
development is removed (mirrors the Flows soft-GA in 0.2.0). Existing
self-hosted instances keep working: every existing user is backfilled
as the sole owner of their own account and sees identical data, and a
solo owner who never invites anyone sees the same single-user app they
always did.
- Public REST API (
/api/v1) — groundwork. A scoped, revocable API key system so you can drive wacrm from your own scripts and automations. Create keys under Settings → API keys (admin+), grant only the scopes each integration needs, and authenticate withAuthorization: Bearer <key>. Keys are account-scoped and stored hashed (plaintext shown once). This release ships the auth layer, scopes, per-key rate limiting, the management UI, and aGET /api/v1/meprobe to verify a key. Seedocs/public-api.md. Migration required: applysupabase/migrations/026_api_keys.sql. (#245) - Public REST API — data endpoints. Built on the key auth above,
so external automations can read and drive the CRM:
POST /api/v1/messages— send a text / template / media message to a phone number; finds-or-creates the contact + conversation (messages:send).GET/POST /api/v1/contacts,GET/PATCH /api/v1/contacts/{id}— list (search + tag filter), create (find-or-create by phone), read, and update contacts, including tags (contacts:read/contacts:write).GET /api/v1/conversations,GET /api/v1/conversations/{id}, andGET /api/v1/conversations/{id}/messages— browse conversations and their message history with delivery status (conversations:read/messages:read).POST /api/v1/broadcasts+GET /api/v1/broadcasts/{id}— launch a template broadcast to a recipient list and poll its progress (broadcasts:send). All list endpoints share one cursor-pagination contract ({ data, meta: { next_cursor } }). No migration required — the scopes already existed and the tables are unchanged. Outbound event webhooks (react to inbound messages) are the remaining roadmap item. Seedocs/public-api.md. (#245)
- Tenancy moves from per-user to per-account. RLS on every
domain table (contacts, conversations, messages, broadcasts,
automations, flows, pipelines, templates, tags, …) now checks
account membership via a new SECURITY DEFINER helper
is_account_member(account_id, min_role)instead ofauth.uid() = user_id. Theuser_idcolumns stay on every row for assignment / audit but no longer enforce isolation. - WhatsApp config is one-per-account, not one-per-user. The
whatsapp_config.UNIQUE(user_id)constraint is replaced byUNIQUE(account_id). flow_runsidempotency key swaps to(account_id, contact_id)so two accounts sharing a contact phone number can each run their own flows independently.- The signup trigger (
handle_new_user) now also creates a personal account and links the new profile to it asowner.
- Flow-media storage is now account-scoped. Migration 016
pathed uploaded files under
auth.uid()/..., which orphaned flow media when a teammate left a shared account. New uploads go underaccount-<account_id>/...and any account member with the right role can edit them. Legacy paths remain writable by the original uploader for backward compatibility. - Webhook contact lookup now pre-filters in SQL. Previously
pulled every contact in an account just to JS-filter to one
row by phone — fine when account = one user, painful when
account = team. Pre-filter by phone suffix on the database
side; re-apply
phonesMatchon the (typically 0-2 row) candidate set.
-
supabase/migrations/020_account_sharing_followups.sql— composite partial indexes onautomations(account_id, trigger_type) WHERE is_activeandflows(account_id) WHERE status='active'for the engine dispatch hot path; updatedflow-mediastorage RLS to allow account-member writes under the new path convention. Idempotent. -
Role-aware UI gating across the app. The inbox composer's send button + textarea, the "New broadcast / automation / flow" buttons, the "Add pipeline / deal" buttons, and the "Add / Import contact" buttons are now disabled-with-tooltip for viewers (and for agents on settings-class actions). Choice: show-but-disable rather than hide, so the UI never feels silently broken to a teammate looking at a feature they don't yet have permission for.
-
Sidebar surfaces the active account above the user info whenever the account name differs from your own — i.e. once you've renamed the account or joined a shared one. A default solo account is named after you, so the strip stays hidden to avoid duplicating your name in the footer.
-
Members is open to all users. The
account_sharingbeta flag that hid the Settings → Members tab and the sidebar account strip during development is gone; the multi-user surface is now part of the standard app. (Same soft-GA move as Flows in 0.2.0.)
- Inbound WhatsApp messages now land in the shared inbox. The
webhook + automations + flows engines used to route inbound
events by
user_id, which after the 017 migration only matched the WhatsApp config owner's automations / flows — teammates' rules never fired. PR 8 of the multi-user series flips every lookup toaccount_idso any member of the account sees the inbound message and any teammate's automation or flow can react to it. Also fixes incipient NOT NULL violations onautomation_logs,automation_pending_executions,flow_runs, anddeals— those tables gainedaccount_id NOT NULLin 017 but the engines hadn't yet been updated to populate it.
- Duplicate phone numbers are now prevented across contacts. A phone number can no longer become more than one contact in the same account. Adding a contact whose number already exists is blocked with a link to the existing record (and a softer warning for near-matches that share their last 8 digits); CSV import de-dupes within the file and against existing contacts, reporting "X imported, Y duplicates skipped". The rule is enforced by a database unique index on the normalized number, so the WhatsApp webhook, the form, import, and any future path all agree. Existing duplicates are merged into the oldest contact on upgrade (their conversations, deals, notes, and tags are re-pointed, nothing is lost). Closes #212.
- Configurable default deal currency. Each account can now pick its default currency under Settings → Deals (admin+); the app previously hardcoded USD throughout. New deals default to it, and pipeline-stage totals, the dashboard "Open Deals Value" card, the pipeline-value donut, and automation-created deals all use it. Existing deals keep the currency they were saved with — totals are shown in the account default with no exchange-rate conversion (one currency per account). Full guide: Default currency.
- Members tab in Settings. The user-facing surface for the
multi-user APIs below, available to everyone (no beta flag). From
Settings → Members an admin or owner can: see who's on the
account with their role and join date, invite teammates by
generating a one-time share link (pick the role + optional
expiry), revoke pending invites, change a member's role, remove a
member, and — as owner — transfer ownership. Recipients accept via
a public
/join/[token]page. Full guide: Members docs. - Account & member management API — server-side endpoints
backing the Members tab. All routes are role-gated and
return Supabase-RLS-scoped data.
GET /api/account— caller's account + role. Any member.PATCH /api/account— rename the account. Admin+.GET /api/account/members— list members. Email visible to admin+ only; agents/viewers see name + avatar + role + joined date.PATCH /api/account/members/[userId]— change a member's role. Admin+. Owner promotion/demotion goes through the transfer endpoint instead.DELETE /api/account/members/[userId]— remove a member. Admin+. The removed user keeps their login and is moved to a freshly-created personal account (mirror of the signup flow).POST /api/account/transfer-ownership— owner only. Atomic swap with the named member.
- Invitation API + redeem flow — the no-email, link-only
invite path that powers the Members tab's "Invite member" button
and the
/join/[token]accept page.GET /api/account/invitations— list outstanding (admin+).POST /api/account/invitations— create an invite, returns the plaintext token + share URL exactly once (we store only the SHA-256 hash on the row). Body{ role, expiresInDays?, label? }. Admin+.DELETE /api/account/invitations/[id]— revoke (admin+).GET /api/invitations/[token]/peek— public, per-IP rate-limited. Returns{ ok, account_name, role, expires_at }or{ ok: false, reason }so the join page can render "You're being invited to as ".POST /api/invitations/[token]/redeem— authenticated. Atomically moves the caller's profile to the inviter's account and cleans up the orphan personal account. Refuses with 409 if the caller's current account already contains domain data (no silent data loss).
Apply against your Supabase project before deploying this version:
supabase/migrations/017_account_sharing.sql— introduces theaccountsandaccount_invitationstables plus anaccount_role_enumtype; addsaccount_idto every user-scoped table and backfills it; rewrites every RLS policy; replaces the new-user trigger. Idempotent. No data loss — every existing user is mapped to a freshly-created account with roleownerand every existing row of theirs is linked to that account.supabase/migrations/018_account_member_rpcs.sql— adds threeSECURITY DEFINERRPCs (set_member_role,remove_account_member,transfer_account_ownership) that back the member-management API. They self-check the caller's role and raise SQLSTATE42501/22023on forbidden / bad input so the API layer can map cleanly to 403 / 400. Idempotent.supabase/migrations/019_invitation_rpcs.sql— adds twoSECURITY DEFINERRPCs:peek_invitation(anonymous read by token hash, returns a fixed-shape JSON envelope) andredeem_invitation(authenticated atomic move + orphan cleanup, with a domain-data safety check). Both bypass the RLS that would otherwise block their reads/writes. Idempotent.supabase/migrations/021_account_default_currency.sql— addsaccounts.default_currency(TEXT NOT NULL DEFAULT 'USD', with a 3-letter-codeCHECK) backing the configurable default currency. Idempotent; existing accounts backfill toUSD. Apply before deploying — the app now reads this column when loading the account, so an un-migrated database breaks account loading.supabase/migrations/022_contact_phone_dedup.sql— adds the generatedcontacts.phone_normalizedcolumn, merges existing duplicate contacts into the oldest (re-pointing conversations, deals, notes, tags, custom values, and broadcast recipients — no data loss), then adds aUNIQUE (account_id, phone_normalized)index. Idempotent. Apply before deploying — CSV import readsphone_normalized, and the index is what enforces de-duplication for every write path. The one-shot merge runs inside the migration.
Flow nodes can now send media. Closes the most-requested gap from user feedback after the v0.2.0 Flows launch — flows were text-only and couldn't deliver an invoice, receipt, product photo, or short demo video mid-conversation.
send_mediaflow node. Send an image (PNG / JPEG / WebP), video (MP4 / 3GP), or document (PDF, Word, Excel, PowerPoint, TXT) to the customer from any point in a flow. Pick a file in the builder, it uploads to the newflow-mediaSupabase Storage bucket, and Meta fetches the public URL at send time. Optional caption (1024 char cap, supports{{vars.X}}interpolation); documents also take an optional filename shown in the recipient's chat. Auto-advances after send — same suspend semantics assend_message. (#156)
Apply against your Supabase project before deploying this version:
supabase/migrations/016_flow_media.sql— does two things:- Adds
'send_media'to theflow_nodes.node_typeCHECK constraint. Without this thesend_medianode fails to save with a constraint violation. - Creates the public
flow-mediaSupabase Storage bucket (16 MB file-size cap, image / video / document MIME allowlist) plus per-user RLS policies (path prefix =auth.uid()). Without this the builder's file picker fails on upload. Same shape as theavatarsbucket from migration 008 — the bucket is public so Meta can fetch the URL without credentials.
- Adds
The migration is idempotent and safe to re-run.
Bug-fix release. Plugs a silent inbound-message drop that triggered
when two users on the same instance saved the same WhatsApp
phone_number_id.
- Inbound WhatsApp messages no longer silently disappear when two
users have claimed the same
phone_number_id. Previously the webhook used.single()to look up the owning config, which errorsPGRST116for both 0 rows and ≥2 rows — the second user's save put the DB into the ≥2-row state and every inbound message was dropped while the log misleadingly reported "No config found for phone_number_id". Three layers of fix:POST /api/whatsapp/confignow returns 409 when another user has already claimed the number, the webhook lookup distinguishes 0 rows from ≥2 rows and logs the conflictinguser_ids, and a new DB constraint (UNIQUE(phone_number_id)) prevents the bad state at the storage layer. Reported in #136, fixed in #143.
Apply against your Supabase project before deploying this version:
-
supabase/migrations/013_whatsapp_config_phone_number_id_unique.sql— addsUNIQUE(phone_number_id)towhatsapp_config. Fails loudly with a copy-pasteable resolution hint if duplicate rows already exist; auto-deduping would destroy encrypted tokens, so the operator picks which row keeps the number. To check first:SELECT phone_number_id, array_agg(user_id) AS owners, count(*) AS n FROM whatsapp_config GROUP BY phone_number_id HAVING count(*) > 1;
If that returns rows,
DELETEthe duplicate row(s) you want to drop, then re-run the migration.
wacrm is intentionally single-tenant per WhatsApp number. RLS on
conversations/messages is auth.uid() = user_id, so a second
user physically cannot read messages routed to a different owner —
two users sharing one number was never supported. If you need
multiple humans handling the same inbox, run them under one shared
account.
The Flows release. Adds a no-code, branching, button-driven WhatsApp conversation engine that runs alongside Automations. Also ships a 5-theme color picker in Settings and opens Flows to all users.
- Module + schema. New
flows,flow_nodes,flow_runs,flow_run_eventstables with partial unique indexes that enforce one active run per contact. Widenedmessages.content_typeCHECK to accept'interactive'; addedinteractive_reply_idcolumn so the inbox can render button/list taps. (#112) - Runner engine.
dispatchInboundToFlowsparses every inbound webhook, decides whether the message is a reply on an active run or a fresh trigger, advances the state machine, and reports back to the webhook so consumed messages don't also fire automations. Idempotent on Meta'smessage_id. (#114) - No-code builder UI at
/flows. Linear-list editor with per-node config forms, live validator, draft/active/archived status, and a 5-route REST API (GET/POST /api/flows,GET/PUT/DELETE /api/flows/[id],POST /api/flows/[id]/activate,GET /api/flows/[id]/runs,GET /api/flows/templates). (#115) - Templates + v1.5 node types. Three starter templates
(Welcome menu, FAQ bot, Lead capture) cloneable from the New-flow
dialog. Three new node types:
collect_input(capture customer text into a variable),condition(branch on var / tag / contact field),set_tag(add or remove a tag).{{vars.X}}interpolation in send_message + collect_input prompts. Per-flow run-history viewer at/flows/[id]/runs. (#117) - Stale-run sweep cron at
GET /api/flows/cron— marks runs past their configured timeout (default 24h) astimed_outso abandoned conversations free up the contact for new triggers. ReusesAUTOMATION_CRON_SECRET. (#114)
- 5 color themes (Violet default, Emerald, Cobalt, Amber, Rose)
selectable from a new Appearance tab in Settings. CSS variables
scoped under
html[data-theme="..."], applied at runtime viadataset.theme, persisted tolocalStorage. Inline boot script inlayout.tsxreplays the choice before first paint so there's no flash of the default. (#132) - Theme tokenization sweep — every previously hard-coded
violet-*Tailwind class replaced withprimarytokens across ~49 files. Picking a non-violet theme now themes the whole app, not just the chrome. (#133)
- Flows is now available to every authenticated user. The per-account beta gate is gone; the sidebar entry + page header carry a small "Beta" chip as the only remaining signal. (#134)
- Editor UX:
- Internal
node_key+ per-button/rowreply_ididentifiers hidden behind a per-node "Show advanced" disclosure. (#118) send_listnodes can have multiple sections. (#119)- Collapsed node cards show a 1-line content preview per node type (text excerpt, button titles, condition summary, etc.). (#120)
- Validation issues are clickable: jump to + flash the offending node. (#121)
- Unsaved-changes "● Edited" indicator +
beforeunloadreload guard. (#122) - New-flow dialog actually widens to fit the 3 template cards
(was capped at 384px by a baked-in
sm:max-w-smfrom shadcn). (#129, #131) - Validation panel pinned to the viewport bottom so activate-readiness follows the user as they scroll through nodes. (#130)
- Internal
- Atomic
execution_countincrement via SECURITY DEFINER RPC — prevents lost counts when two webhooks start runs concurrently. Mirrors the automations engine pattern. (#124) - Preload all flow_nodes once per dispatch — one SELECT per inbound instead of one per advance-loop iteration. A 5-node auto-advance chain now costs 1 round trip, not 5. (#125)
- Wasted re-read dropped after reprompt reset;
loadActiveRunswitched to defensive.limit(1)so a migration glitch producing duplicates can't crash dispatch. (#126)
- PII redacted from
reply_receivedevent payload — customer text is no longer persisted toflow_run_events.payload; only the length is. Acollect_inputprompt asking "what's your card number?" used to leave the PAN sitting in the events table. (#123) - Constant-time cron-secret compare on
/api/flows/cron(crypto.timingSafeEqual) to close a theoretical timing-side-channel on thex-cron-secretheader check. (#127)
/flowsno longer spuriously redirects to/dashboardwhen navigating in. Root cause:useAuthflippedloading: falsebefore the profile fetch resolved.use-authnow exposes a separateprofileLoadingboolean. (#128)
Apply, in order, against your Supabase project:
supabase/migrations/010_flows.sql— Flows core tables, indexes, RLS policies, and themessagesschema widening.supabase/migrations/011_profile_beta_features.sql— adds theprofiles.beta_featurescolumn. Surviving for future betas; Flows no longer reads it.supabase/migrations/012_flows_increment_counter.sql— atomic counter RPC. Without this the engine still runs butflows.execution_countis racy.
Each migration is idempotent — safe to re-run if you're not sure whether you applied a previous one.
src/lib/flows/feature-flag.ts+ its tests. Flows is open to all users; theprofiles.beta_featurescolumn itself survives for future beta gates. (#134)
- Chat actions in the inbox: emoji reactions, reply-with-quote, and copy-text on individual messages. Hover on desktop, long-press on touch. Outbound reactions and replies forward to WhatsApp via the Cloud API; inbound reactions and swipe-replies from customers arrive through the webhook and appear in real time.
- Apply
supabase/migrations/009_message_actions.sqlto your Supabase project. It addsmessages.reply_to_message_idand the newmessage_reactionstable (with RLS and realtime). The migration is idempotent — safe to re-run.
- The webhook no longer stores inbound customer reactions as fake
text messages. They are written to
message_reactionsinstead, so any custom queries that counted reactions as messages will need updating.
Initial template release. Core CRM: inbox, contacts, pipelines, broadcasts, automations (with a Wait-step cron drain), WhatsApp Cloud API integration, Supabase auth + RLS.