B Blengi docs

Architecture

Stack & layout

Pitchbar 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
RealtimeLaravel Reverb (WebSocket)
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)
Crawler (preferred)Cloudflare Browser Rendering
Crawler (fallback)Browserless β†’ plain HTTP
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.

Reverb & WebSocket

Reverb runs as a separate process and powers:

  • Inbox live updates β€” operators see new messages as they arrive.
  • Human takeover events β€” the visitor's widget gets a "human is here" event when an operator claims the conversation.
  • Operator presence β€” Available / Away states sync across team members.

Channels are private by default β€” the widget joins conversation.{id} using its JWT, and the operator app joins workspace.{id} using its session.

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.