Architecture Overview

Deskly is a fork of an underlying AI SaaS template, not a rewrite. Auth, billing, and the RAG pipeline carry over unchanged; what Deskly adds is a multi-tenant layer so one account can run many independently-branded bots instead of a single hard-coded assistant.

The bots table

Everything about the widget layer traces back to one table:

create table bots (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references profiles(id) not null,
  name text not null,                -- internal label, e.g. "Joe's Detailing"
  public_key text not null unique,   -- pk_..., embedded in customer HTML
  allowed_domains text[] not null default '{}',
  display_name text,                 -- visitor-facing name
  welcome_message text,
  primary_color text,
  system_prompt text,
  is_active boolean not null default true,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);
  • public_key is not secret — it ships in plain HTML on the customer's site, the same trust model as a Stripe publishable key. It exists to identify a bot, not to authorize it.
  • allowed_domains defaults to an empty array, which means a freshly-created bot refuses to run anywhere until the owner adds at least one domain. Secure by default, not fail-open.
  • documents.bot_id (nullable) scopes uploads to a specific bot. Existing per-account chat documents from the base template's dashboard feature have no bot_id and are unaffected.

Request flow for a visitor message

  1. POST /api/widget/chat receives { publicKey, visitorId, message, conversationId? }
  2. The route looks up the bot by public_key using the service-role Supabase client — anonymous visitors have no session, so there's nothing for RLS to check against; this endpoint bypasses RLS by design
  3. Rate limits run: per-visitor, per-bot-hourly, per-bot-daily (see Abuse Protection)
  4. The bot owner's credit balance is checked — a widget bills its owner, never the visitor
  5. The conversation is loaded or created, verified to belong to this bot
  6. RAG retrieval runs against documents scoped to bot.id only (see RAG Pipeline)
  7. The response streams back; on completion, credits are deducted from the owner and the message is persisted
  8. The visitor's message is scanned for contact info and, if found, written to leads (see Lead Capture)

RLS boundary

Direct browser access (the dashboard, using the owner's session) is governed by RLS: an owner can only select/insert/update/delete their own rows in bots, and can only select (read-only) their own bots' widget_conversations, widget_messages, and leads. Anonymous website visitors never talk to Supabase directly — every widget-facing write goes through the service-role client inside /api/widget/*, where the application code (not RLS) enforces bot scoping.

What's reused, unchanged, from the base template

  • Supabase Auth (magic link + OAuth)
  • Postgres RLS patterns for the dashboard
  • Stripe checkout, customer portal, credit ledger (credit_transactions)
  • The RAG pipeline's chunk/embed/retrieve mechanics — now called with an extra documentIds scope instead of "all of this account's documents"

See Getting Started to set this up locally, or Embeddable Widget for how a bot actually gets onto a customer's page.