Operate
Environment variables
Blengi reads its configuration from the standard Laravel
.env, with overrides for some keys available via the
platform admin's App Settings page so you can swap
Stripe / mail / LLM credentials without redeploying.
Required
| Variable | Why |
|---|---|
APP_KEY | Encryption master key. Generate with php artisan key:generate. Never rotate without a re-encrypt migration. |
APP_URL | Public URL, used to build widget snippets, OAuth callbacks, signed URLs. |
DB_* | Postgres connection. |
REDIS_HOST | Cache, sessions, queue, hot-path retrieval cache, conversation history cache. |
QUEUE_CONNECTION | Set to redis in production. |
SESSION_DRIVER | redis in production. |
CACHE_DRIVER | redis. |
WIDGET_JWT_SECRET | HS256 signing secret for visitor JWTs. ≥ 32 random bytes. |
LLM provider
At least one of the following:
| Variable | Provider |
|---|---|
CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN | Cloudflare Workers AI (preferred). Auto-binds Llama 3.3 70B + bge-base-en-v1.5. |
OPENAI_API_KEY | OpenAI direct. |
OPENROUTER_API_KEY | OpenRouter (router across many providers). |
Set LLM_PROVIDER to force a binding (cloudflare,
openai, openrouter). When unset, the resolver
picks based on which keys are available, in the priority above.
Vector store
| Variable | Provider |
|---|---|
CLOUDFLARE_VECTORIZE_INDEX | Cloudflare Vectorize index name. Uses CLOUDFLARE_ACCOUNT_ID + token. Default pitchbar-chunks. |
QDRANT_URL + QDRANT_API_KEY | Qdrant. |
Set VECTOR_PROVIDER to force. Auto-binds based on
available keys (Vectorize preferred).
Crawler
| Variable | Strategy |
|---|---|
| (none) | Plain HTTP. Tried first; free; no JS rendering. |
(uses CLOUDFLARE_*) | After plain HTTP: Cloudflare Browser Rendering /markdown, then /content, then Workers AI vision. Only when the Cloudflare keys are set. |
BROWSERLESS_TOKEN + BROWSERLESS_URL | Read into config, but the default crawler chain does not use Browserless. |
| Variable | Purpose |
|---|---|
CRAWL_MAX_PAGES_PER_SOURCE | Max pages fan-out per Source. Default 500 (raised from 25 — buyers adding 100-URL sitemaps were silently losing 75 pages). Cap applies after sitemap-index recursion + dedupe. |
CRAWL_PAGE_DISPATCH_DELAY | Per-page delay (seconds) when fanning out sitemap pages so Cloudflare Browser Rendering's small per-account concurrency doesn't 429. Default 2. |
CLOUDFLARE_BROWSER_RENDERING | Set to false to skip the Cloudflare tiers (plain HTTP only) even when Cloudflare keys are present. Default true. |
RAG / retrieval
| Variable | Purpose |
|---|---|
RAG_CONFIDENCE_THRESHOLD | Default similarity threshold for newly created agents. 0.5 for Cloudflare bge-base-en-v1.5 (its ANN cosine peaks lower than OpenAI's); 0.78 for OpenAI text-embedding-3-small. Auto-resolves based on LLM_PROVIDER. |
RAG_QUERY_REWRITE | Hands the retrieval query to a small LLM instead of the built-in stitch. Off by default, and the safe default — the stitch is deterministic and costs nothing, while the rewrite adds a round-trip to every context-dependent turn. If you enable it, set RAG_QUERY_REWRITE_MODEL to a small fast model; leaving it empty runs the rewrite on your main chat model. Whatever it returns, any product name it dropped is added back before the search runs. php artisan pitchbar:diagnose-widget reports the current state. |
RAG_CURATED_BOOST | How much a source you authored (pasted text, Google Doc/Sheet, Notion, uploaded file, SQL) outranks a page crawled from your own website on a near-tie. Default 0.10; 0 weighs every source equally. Precedence only — crawled pages still answer everything your documents don't cover. |
RAG_INTENT_ROUTER | Classify each visitor message with Cloudflare's Clef-flash decision model (problem, vague, off-topic, manipulation, product question …) so service problems, vague asks and off-topic messages get the right kind of answer (card #823). Needs the Workers AI credentials. Default false; adds ~0.25 s before the first word. |
RAG_INTENT_TIMEOUT | Seconds the intent call may take before the message is answered without it. Default 1.0 — it runs before the first word of every answer (median ~0.3 s). |
RAG_SITE_TIMEZONE | The timezone of the date and time the AI is told with every answer, so it can apply working days, cut-off times, opening hours and promotion end dates (card #818). Default Europe/Amsterdam. A workspace whose business hours set their own timezone uses that one. |
RAG_CLAIM_CHECK | The claim check before sending (see Claim check before sending): product facts in an answer are checked against the product records and corrected, sentence by sentence. Default true. false streams every answer word by word without the check — for a staged rollout, or a quick way back. |
RAG_CLAIM_JUDGE_UNSTATED_CONFIDENCE | The confidence from which a "not stated" verdict rewrites a sentence (card #846). Default 0.8; a "contradicted" verdict uses RAG_CLAIM_JUDGE_MIN_CONFIDENCE (0.5). |
RAG_CLAIM_JUDGE | Every factual sentence of every answer, on every AI employee, judged by Cloudflare Clef against what the AI was given (card #827, see Every factual sentence, every AI employee). Default false; needs the Workers AI credentials. |
RAG_CLAIM_JUDGE_TIMEOUT | Seconds one judge call may take before the sentence goes out unchecked. Default 2.0. |
RAG_CLAIM_JUDGE_MIN_CONFIDENCE | How sure the judge must be that a sentence is contradicted or not stated before it is rewritten. Default 0.5. |
RAG_SHOPPING_STATE | What a shopping conversation has established — the visitor's needs, the products discussed (ranked by fit), the stage — given to the AI on every turn (see What the AI keeps during a shopping conversation). Default true; false switches it off. |
RAG_RERANK_ENABLED | Set to false to disable the bge-reranker-base cross-encoder pass. Default true when Cloudflare keys are present. |
ATTRIBUTION_DIRECT_WINDOW_DAYS | Revenue attribution: max days between a recommended product's click and an order containing that product for the order to count as direct. Default 7. Changes apply to new orders only — recorded matches are never revisited. See Revenue attribution. |
ATTRIBUTION_INFLUENCED_WINDOW_DAYS | Revenue attribution: max days between a conversation and an order for the order to count as influenced (last touch wins). Default 30. |
LLM_STREAM_READ_TIMEOUT_SECONDS | Max seconds a single streamed-response read may block (default 30). A healthy token stream delivers bytes subsecond, so this can never truncate a real answer — it turns a provider that stops sending mid-stream into a clean retryable timeout instead of a request that hangs until PHP's execution limit kills the Octane worker (which then serves 500s until it recycles). |
LLM_STREAM_CONNECT_TIMEOUT_SECONDS | Max seconds a streamed Workers AI request may spend on connect + TLS + response headers (default 20). Headers arrive before the first token, so this is also the ceiling on a cold prefill. Without it a dropped packet waits out PHP's 60-second default_socket_timeout on every failover hop. |
FAKE_LLM_FIRST_BYTE_MS / FAKE_LLM_PER_TOKEN_MS | Load testing only — leave unset in production. When no provider keys are configured the app falls back to a built-in fake LLM that answers instantly. That makes it useless for capacity testing, because a real turn holds an Octane worker for seconds while an instant one never reveals the concurrency ceiling. These two make the fake wait before its first token and between tokens, so you can measure how many simultaneous visitors an instance really serves without spending a cent on a provider. Both default to 0. See Concurrency and worker sizing. |
VECTOR_DIM | Embedding dimension override. Leave unset to auto-resolve from the configured embed model via the known-model map (bge-base=768, bge-m3=1024, text-embedding-3-small=1536, etc.). Set explicitly only when pointing at an unlisted model. Changing this on an existing install needs php artisan vector:rebuild-index — the Vectorize index is provisioned at the dim active when it was first created and cannot be resized in place. |
Cloudflare AI Gateway (optional)
| Variable | Purpose |
|---|---|
CLOUDFLARE_AI_GATEWAY_URL | If set, Workers AI calls route through Cloudflare's AI Gateway — observability, caching, rate limits. |
CLOUDFLARE_CHAT_MODEL | Default @cf/meta/llama-3.3-70b-instruct-fp8-fast. |
CLOUDFLARE_EMBED_MODEL | Default @cf/baai/bge-base-en-v1.5. |
CLOUDFLARE_VECTORIZE_INDEX | Vectorize index name. Default pitchbar-chunks. |
OPENROUTER_CHAT_MODEL | OpenRouter chat model slug. Default meta-llama/llama-3.3-70b-instruct:free. |
OPENROUTER_EMBED_MODEL | OpenRouter embedding model. Default text-embedding-3-small. |
QDRANT_COLLECTION | Qdrant collection name when using the Qdrant vector store fallback. Default pitchbar_chunks. |
S3 / R2 (object storage for file uploads)
| Variable | Purpose |
|---|---|
AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY | Bucket credentials. For Cloudflare R2, use the R2 token's key/secret pair. |
AWS_DEFAULT_REGION | Region of the bucket. Use auto for R2. |
AWS_BUCKET | Bucket name. |
AWS_ENDPOINT | Custom S3-compatible endpoint. Required for R2 (e.g. https://<accountid>.r2.cloudflarestorage.com). Leave blank for native AWS S3. |
AWS_USE_PATH_STYLE_ENDPOINT | true for R2 and most non-AWS S3 implementations; false for native AWS. |
Frontend (Vite)
| Variable | Purpose |
|---|---|
VITE_WIDGET_CDN_URL | Base URL the visitor widget loader uses. Defaults to http://localhost:5173 in dev. In production, point at the public origin serving /widget/widget.js. |
Stripe
| Variable | Purpose |
|---|---|
STRIPE_KEY | Publishable key. |
STRIPE_SECRET | Secret key. Used for plan sync and Cashier. |
STRIPE_WEBHOOK_SECRET | Signing secret for incoming webhooks (whsec_…). |
CASHIER_CURRENCY | Defaults to usd. |
Reverb (broadcasting)
Used only for broadcasting events. No screen subscribes to them: the conversation screen, the lead toasts and the widget all poll.
| Variable | Purpose |
|---|---|
REVERB_APP_KEY | Public app key. Embedded in widget init payload. |
REVERB_APP_SECRET | Secret. Server-side only. |
REVERB_APP_ID | App identifier. |
REVERB_HOST | Public hostname for the Reverb server. |
REVERB_PORT | Default 8080. |
REVERB_SCHEME | wss in production, ws locally. |
| Variable | Purpose |
|---|---|
MAIL_MAILER | smtp / postmark / resend / etc. |
MAIL_FROM_ADDRESS | Sender address. Required. |
MAIL_FROM_NAME | Display name. |
MAIL_HOST / MAIL_PORT / MAIL_USERNAME / MAIL_PASSWORD | SMTP credentials. |
BLENGI_TEAM_NOTIFY_EMAIL | Shared inbox the delivery team monitors for one-time add-on purchases (Professional AI Setup, etc.). Leave blank to fall back to MAIL_FROM_ADDRESS. Consumed by NotifyTeamOfAddonPurchaseJob. |
Branding
| Variable | Purpose |
|---|---|
BRANDING_LABEL | Default "Powered by Blengi" label. Override in app_settings for white-label. |
BRANDING_URL | Where the label links to. |
BRANDING_FOOTER_LOGO_PATH | Storage path to the footer logo image. |
Observability
| Variable | Purpose |
|---|---|
SENTRY_LARAVEL_DSN | Error reporting. (Some hosts still ship the legacy SENTRY_DSN name; the Laravel SDK reads SENTRY_LARAVEL_DSN as of v4.x — keep both in .env for back-compat if you migrated.) |
OTEL_EXPORTER_OTLP_ENDPOINT | OpenTelemetry collector. Honeycomb / Grafana Cloud. |
OTEL_SERVICE_NAME | Service name in traces. Default pitchbar. |
Payment gateways
| Variable | Purpose |
|---|---|
PAYPAL_ENABLED | Set to true to expose PayPal at checkout. |
PAYPAL_MODE | sandbox or live. |
PAYPAL_CLIENT_ID + PAYPAL_CLIENT_SECRET | REST API credentials. |
PAYPAL_WEBHOOK_ID | For verify-signature webhook checks. |
RAZORPAY_ENABLED | Set to true to expose Razorpay at checkout. |
RAZORPAY_API_KEY + RAZORPAY_SECRET_KEY | HTTP Basic auth keys. |
RAZORPAY_WEBHOOK_SECRET | HMAC-SHA256 signing secret for webhook bodies. |
OAuth integrations
| Variable | Purpose |
|---|---|
NOTION_CLIENT_ID + NOTION_CLIENT_SECRET + NOTION_REDIRECT_URI | Notion public-integration credentials for connecting Notion pages as knowledge sources. |
GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + GOOGLE_REDIRECT_URI | Google OAuth (Drive + Docs + Sheets) — register an OAuth client in the GCP console with drive.readonly, documents.readonly, and spreadsheets.readonly scopes. Omitting spreadsheets.readonly blocks the Google Sheets source type. |
WhatsApp channel (Meta Cloud API)
| Variable | Purpose |
|---|---|
META_APP_ID + META_APP_SECRET | The platform's Meta app. The secret verifies every WhatsApp webhook; without it they are refused. See WhatsApp channel. |
WHATSAPP_WEBHOOK_VERIFY_TOKEN | Token Meta echoes when it checks the webhook subscription. |
WHATSAPP_EMBEDDED_SIGNUP_CONFIG_ID | Embedded Signup configuration id; enables Connect with Facebook. |
META_GRAPH_VERSION | Graph API version (default v24.0). |
WHATSAPP_BUNDLE_SECONDS | Seconds to wait for more customer messages before one answer (default 4; per number in the WhatsApp tab). |
WHATSAPP_TIMEOUT | Seconds per Meta API call (default 10). |
Marketing demo widget
| Variable | Purpose |
|---|---|
MARKETING_DEMO_AGENT_ID | UUID of a published agent that powers the live demo widget on the marketing pages. Add the marketing site URL to that agent's allowed_origins. |
DEMO | Set to true to open Login / Get started links in a new tab on the marketing site — useful for reviewers exploring a CodeCanyon live demo without losing the marketing page when they hop into the app. |
Queue tick (Cloudflare Worker cron)
| Variable | Purpose |
|---|---|
INTERNAL_QUEUE_TOKEN | Shared bearer secret protecting POST /api/v1/internal/queue-tick. The Cloudflare Worker cron pings this every 60s to drive the queue when in-cluster scheduling isn't available. Only set it when this host has no queue daemon. If PM2 or supervisor already runs queue:work, the tick is redundant — leave the token empty. The tick runs the worker in its own subprocess, so it can no longer take the web worker down with it, but two things draining the same queues buys you nothing. |
App Settings overrides
The app_settings singleton row stores plaintext-encrypted
overrides for:
- Stripe secret + webhook secret + publishable key.
- Mail driver settings.
- Cloudflare / OpenAI / OpenRouter keys.
- Branding (label, URL, logo).
The AppSettingsOverrideServiceProvider reads this on boot
and merges into config(). Setting things via the admin
panel is preferred for production — you don't have to re-deploy when a
key rotates.
APP_KEY rotation requires a manual migration. The
encrypted columns in app_settings were sealed with the
old key; rotating without re-encrypting renders them unreadable.