G Growreplies 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.