B Blengi docs

Architecture

Stack & layout

Blengi is a single Laravel codebase with two frontends. Backend is Laravel 13 + Octane on PHP 8.3+; admin UI is Inertia v3 + React 19; visitor widget is Preact ≤50KB. AI stack defaults to Cloudflare (Workers AI + Vectorize + Browser Rendering) with OpenAI + Qdrant as fallback.

The stack

LayerTech
App frameworkLaravel 13 (PHP 8.3+)
ServerLaravel Octane on FrankenPHP
RealtimePolling (admin screens and widget). Laravel Reverb is installed and events are broadcast when a driver is configured, but no client subscribes.
QueueLaravel Horizon on Redis
AuthLaravel Fortify (sessions, 2FA, password reset) + Sanctum (API tokens)
BillingLaravel Cashier (Stripe)
DatabasePostgres 16
Cache / sessionsRedis 7
Admin frontendInertia v3 + React 19, Tailwind v4, shadcn/ui (Radix), Vite, strict TS
Typed routesWayfinder (TS bindings to Laravel routes)
Visitor widgetPreact 10 (aliased as React) + Vite + selective Tailwind v4
LLM (preferred)Cloudflare Workers AI — Llama 3.3 70B + bge-base-en-v1.5
LLM (fallback)OpenAI gpt-4o-mini + text-embedding-3-small (and OpenRouter as a router)
Vector store (preferred)Cloudflare Vectorize
Vector store (fallback)Qdrant (HTTP client)
CrawlerPlain HTTP first; then Cloudflare Browser Rendering /markdown, /content and a Workers AI vision pass, when Cloudflare is configured. Browserless is not in the chain.
Object storageCloudflare R2 (S3-compatible)
HostingLaravel Cloud
ObservabilitySentry + OpenTelemetry → Honeycomb / Grafana Cloud

Repository layout

One Laravel app at the repo root. The admin frontend ships as Inertia pages inside the same app; the visitor widget is a second isolated Vite build.

pitchbar/                     — Laravel app
├── app/
│   ├── Actions/Fortify/      — Fortify hooks (CreateNewUser, etc.)
│   ├── Concerns/             — BelongsToWorkspace, BelongsToAgent traits
│   ├── Http/
│   │   ├── Controllers/Admin/    — customer + admin Inertia controllers
│   │   ├── Controllers/Widget/   — /api/v1/widget/* (visitor-side, JWT)
│   │   └── Middleware/
│   ├── Models/               — Workspace, Agent, Conversation, Plan, …
│   ├── Services/
│   │   ├── Rag/              — Retriever, Chunker, PromptBuilder, CuratedAnswerMatcher
│   │   ├── Llm/              — OpenAiHttpClient, WorkersAiClient, Fakes
│   │   ├── Vector/           — VectorizeClient, QdrantHttpClient
│   │   ├── Crawl/            — CloudflareBrowserClient, AutoIndexPageVisit, PlainHttpCrawler
│   │   ├── Triggers/         — CtaSelector, LeadIntentDetector
│   │   ├── Analytics/        — EventStore (analytics rollups + gap detection ride in app/Jobs/Analytics)
│   │   ├── Billing/          — StripeProductSync, MeteredBilling, PayPalClient, RazorpayClient
│   │   ├── Tools/            — ToolRegistry + EscalateToHumanTool (Phase 2)
│   │   ├── Vertical/         — VerticalPresetRegistry + 7 preset classes
│   │   ├── I18n/             — LocaleResolver, LocaleCatalog (132 languages)
│   │   └── Widget/           — WidgetJwt, WidgetCopy, InlineBlockParser
│   ├── Jobs/Crawl/           — CrawlSourceJob, CrawlPageJob, IndexDocumentJob
│   ├── Jobs/Analytics/       — DetectGapJob (post-stream gap detection)
│   └── Events/               — TokenStreamed, TurnCompleted, TurnFailed
├── resources/
│   ├── js/                   — admin Inertia (default Vite build)
│   │   ├── pages/
│   │   ├── components/
│   │   └── …
│   ├── widget/               — visitor widget (separate Vite build)
│   ├── views/                — Blade (Inertia root + marketing + docs)
│   └── css/app.css           — Tailwind v4 entry
├── routes/
│   ├── web.php
│   ├── api.php
│   └── channels.php
├── database/{migrations,factories,seeders}
├── tests/{Feature,Unit,Browser}
├── docs/PLAN.md              — full engineering plan
└── public/widget/            — built widget bundle

Two frontends, one backend

The admin and customer surfaces are the same Inertia app — same Vite build, same component library. The roles are separated by route group and middleware, not by codebase. This keeps a single source of truth for design tokens, routing helpers, and authentication state.

The visitor widget is the opposite — it intentionally shares nothing with the admin code. It can't import from resources/js/; it has its own Vite config; it has its own router (just a Preact component tree) and its own state. The size budget is a hard 50KB gzipped — admin features cannot bleed in.

Live updates: polling, and Reverb

Nothing on screen listens to a websocket. There is no Echo or Pusher client in the admin SPA or in the widget; live screens poll:

  • Conversation screen — reloads its messages every 2 seconds while a person is requested or has taken over, every 8 seconds otherwise.
  • Widget — asks for team replies and the takeover state every 3 seconds while it is open.
  • Lead and handoff toasts — every admin tab polls /app/leads/feed every 30 seconds.
  • Operator presence — a heartbeat every 60 seconds while the tab is visible; an operator counts as online for 2 minutes after it.

Reverb is installed, and some events (for example HumanRequestedEvent, ConversationClaimedEvent, AgentReplyPostedEvent, LeadCapturedEvent) are broadcast on private channels (conversation.{id}, agent.{id}.events, workspace.{id}.leads) when BROADCAST_CONNECTION is a real driver. With log or null they are skipped. Either way no screen depends on them.

Cloudflare one-bill mode

Set CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN and the LLM, vector store, and crawler all auto-bind to Cloudflare. Total external infra cost: $5/month Workers Paid + per-request usage. Replace any one piece (e.g. swap Vectorize for Qdrant by setting QDRANT_URL) and the binding shifts.

Multi-tenant isolation

Every tenant-scoped query is filtered by the BelongsToWorkspace trait's global scope. Crossing the boundary requires an explicit withoutWorkspaceScope() with a justifying comment. There's a regression test that fails the build if a model with a workspace_id column doesn't use the trait. See Multi-tenancy.