Deskbell

Environment variables

Every environment variable Deskbell reads, what it defaults to, and which ones change whether visitors get an answer at all.

Deskbell is configured entirely through environment variables. .env.example in the repository is the annotated original; this page is the same set organised by what each one affects.

Every variable below has a working default except DATABASE_URL and the provider credentials.

Core

VariableDefaultWhat it does
DATABASE_URLpostgres://deskbell:deskbell@localhost:5432/deskbellPostgres connection string. The only required infrastructure
BETTER_AUTH_SECRETchange-meSigns dashboard session cookies. Required in production: compose refuses to start without it. Generate with openssl rand -base64 32
BETTER_AUTH_URLhttp://localhost:3000Externally visible origin. Also the base of the invitation links the Members page renders
GOOGLE_CLIENT_IDunsetWith GOOGLE_CLIENT_SECRET, adds "Continue with Google" to the sign-in and sign-up pages. Register the redirect URI <BETTER_AUTH_URL>/api/auth/callback/google with Google. See Authentication
GOOGLE_CLIENT_SECRETunsetThe OAuth client secret that goes with GOOGLE_CLIENT_ID; one without the other is logged and ignored
DESKBELL_PUBLIC_URLDerived from request headersExternal origin used when rendering embed snippets. Set it when a proxy strips x-forwarded-*

Outbound email

Optional. Powers self-serve password reset and invitation emails on the dashboard. Both variables are required together; with them unset, development prints reset links to the server console and production disables the forgot-password flow (the page says so). Any SMTP provider or local catcher works; for Postmark, the Server API token is both username and password: smtp://TOKEN:TOKEN@smtp.postmarkapp.com:587.

VariableDefaultWhat it does
SMTP_URLnonesmtp:// or smtps:// URL, credentials included
EMAIL_FROMnoneSender of outbound mail. Must be one your provider accepts, e.g. Deskbell <deskbell@your-domain.com>

Models

Model ids are provider/model. See Models and providers for what each provider supports.

VariableDefaultWhat it does
OPENAI_API_KEYnoneCredentials for openai/*
ANTHROPIC_API_KEYnoneCredentials for anthropic/*, chat only
GOOGLE_GENERATIVE_AI_API_KEYnoneCredentials for google/*
OLLAMA_BASE_URLhttp://localhost:11434Endpoint for ollama/*, and for anything else speaking the OpenAI-compatible protocol
DESKBELL_CHAT_MODELopenai/gpt-5-miniWrites answers
DESKBELL_REASONING_EFFORTlowHow hard a reasoning model may think on the answer path: minimal, low, medium, high, or provider to leave it to the provider. openai/* only
DESKBELL_EMBEDDING_MODELopenai/text-embedding-3-smallEmbeds chunks and questions
DESKBELL_PLANNER_MODELThe chat modelRewrites follow-up questions before retrieval
DESKBELL_RERANK_MODELThe chat modelReorders retrieved candidates when DESKBELL_RERANK=llm
DESKBELL_JUDGE_MODELThe chat modelGrades answers when the eval harness runs with an LLM judge
DESKBELL_GAP_ANALYST_MODELThe chat modelWrites the finding and recommendation for a gap cluster on the Gaps page
DESKBELL_GAP_ANALYST_TIMEOUT_MS30000Past it an analysis fails and the cluster records the error

DESKBELL_PLANNER_MODEL and DESKBELL_JUDGE_MODEL exist as separate settings so that changing the model which writes answers cannot quietly change how questions are resolved or how answers are graded. A cheap fast model is the right choice for the planner. For the judge, use a different model from the one under test: grading a model's answers with that same model is self-assessment, and the harness labels it as such in the report when you do.

Ingestion

VariableDefaultWhat it does
DESKBELL_CHUNK_TARGET_CHARS2800Characters a chunk aims for when pages are cut before embedding
DESKBELL_CHUNK_MAX_CHARS3600Hard cap; a single block longer than this is split at a sentence
DESKBELL_CHUNK_OVERLAP_CHARS350Tail of the previous chunk carried into the next, so a fact straddling a boundary is in one of them

Chunk size changes what every embedding is computed from. Stored chunks keep the size they were cut at, so after changing it re-sync your sources; to re-embed an existing corpus after changing the embedding model instead, run pnpm exec tsx scripts/reindex-project.ts --project <id> against the deployment's database, or use Reindex on the Sources page.

Retrieval and planning

VariableDefaultWhat it does
DESKBELL_QUERY_PLANNINGfollowupsfollowups, always, or off
DESKBELL_RETRIEVAL_MIN_SCORE0 (off)Cosine-similarity floor, from 0 up to but not including 1
DESKBELL_RETRIEVAL_MODEvectorhybrid adds a full-text leg beside the vector leg and fuses the two
DESKBELL_RETRIEVAL_MULTI_QUERYoffon also searches the planner's variant phrasings and pools the candidates
DESKBELL_RETRIEVAL_LEG_DEPTH50Candidates each leg contributes before results fuse
DESKBELL_RETRIEVAL_EF_SEARCH100How many candidates the vector index examines per query (pgvector hnsw.ef_search); deeper is more exact and slightly slower
DESKBELL_RETRIEVAL_POOL24Pooled candidates handed to final ordering (and the reranker)
DESKBELL_RETRIEVAL_MAX_PER_DOC1Max chunks from one document in the final selection; 0 turns the cap off
DESKBELL_RETRIEVAL_ORDERfusedFinal ordering with no reranker: fused or cosine
DESKBELL_RERANKoffllm (one structured chat-model call) or local (an in-process cross-encoder) reorders the pooled candidates
DESKBELL_RERANK_TIMEOUT_MS10000llm only: past it the answer proceeds in fusion order
DESKBELL_VECTOR_STOREpgvectorweaviate moves the search index to a Weaviate cluster you run; see Vector store
DESKBELL_CLARIFYoffon lets the engine ask which reading the visitor meant when the retrieved pages answer the question in more than one way

always expands first questions as well as follow-ups, which improves retrieval at the cost of one extra model call on every question. off searches the message exactly as typed.

With DESKBELL_CLARIFY=on, a question the retrieved pages answer in more than one way — "how do I configure it?" when three pages each configure something different — comes back as one question with the pages as options, instead of an answer for whichever reading the model picked. The options are always titles of pages that were retrieved; the engine drops anything else, and asks at most once per conversation. It costs accuracy on questions that had one obvious reading, so measure it before turning it on for visitors: Clarifying questions says how, and How it works has the mechanics.

The shipped defaults preserve plain vector search. Everything below the floor in that table changes ranking, and the sizing knobs are a latency budget, not a quality dial: turning them up buys latency and tokens first, and quality only if a measurement says so. Hybrid mode exists because exact tokens (error codes, config keys, identifiers) can match literally where embeddings miss them; the per-document cap exists because one long page can otherwise crowd out a short page that answers the question outright. Reranking never runs as a separate service and degrades to fusion order on any failure or timeout. Two drivers exist. llm asks the configured chat model for an ordering: zero extra dependencies, but seconds of latency and tokens on every answer. local runs a real cross-encoder in-process on CPU in the web app, tens of milliseconds per candidate and no tokens. The Docker images and pnpm install include the library it needs (@huggingface/transformers); a production install that prunes development dependencies adds it with pnpm add @huggingface/transformers. On first use the model weights (~25 MB for the default Xenova/ms-marco-MiniLM-L-6-v2) download from the Hugging Face hub into node_modules/@huggingface/transformers/.cache/. In a container that directory lives in the writable layer, so it survives restarts but not a rebuilt image; mount it or pre-seed it for air-gapped deployments. If the library is missing the driver fails loudly per answer and the engine keeps fusion order. Flip any of these against eval numbers; the harness sweeps them with pnpm sweep.

DESKBELL_RETRIEVAL_MIN_SCORE is not a quality dial. It decides whether the visitor gets an answer at all: if no chunk clears the floor, the question is refused before the model is consulted. Correct documents score lower than people expect, so raising it trades answers for silence. Tune it against eval numbers, not by feel. A malformed value is ignored with a warning rather than taking the deployment down.

Conversations

VariableDefaultWhat it does
DESKBELL_THREAD_TITLESfirst-messageHow a new conversation gets its title: the first question trimmed to eighty characters, or model for one short chat-model call per conversation, run by the worker and counted against the project's daily token budget
DESKBELL_NOTIFICATION_SUMMARYmodelWhether a notification carries a one-sentence model summary of what went wrong (model, one short chat-model call per event, run by the worker and counted against the daily token budget) or falls back to the conversation title (none)

Actions

Servers and per-tool policies are set per project under Settings › Actions (Actions). These bound what one turn may do.

VariableDefaultMeaning
DESKBELL_ACTION_MAX_STEPS4Model steps per turn; each tool call is a step
DESKBELL_ACTION_TIMEOUT_MS30000How long one tool call may take before it is reported as failed
DESKBELL_ACTION_RESULT_MAX_CHARS8000How much of a tool result the model is shown; longer results are cut and marked
DESKBELL_ACTION_APPROVAL_TTL_MIN10How long a confirmation card can be answered before it expires

Confirmation requests are signed with a key derived from BETTER_AUTH_SECRET; visitor identity tokens are signed with keys the instance generates on first use and publishes at /api/v1/actions/jwks.

Gaps

VariableDefaultWhat it does
DESKBELL_GAPS_WEAK_SCORE0.35Top retrieval score under which an answered question counts as a weak answer on the Gaps page
DESKBELL_GAPS_CLUSTER_SIMILARITY0.80Cosine similarity at which a new wording joins the cluster of the closest wording instead of starting one
DESKBELL_GAPS_AUTO_ANALYZE_AT0 (off)Occurrences in 30 days at which a cluster without an analysis is analysed without a click

A gap is a question the content could not answer. Two of the three signals come from the visitor: a refusal they saw, or a thumbs-down they gave. The third comes from the engine: an answer whose best retrieved passage scored under this floor. Those answers read fine and nobody complains, which is why they need a number. The default was chosen by measuring judged answers against their retrieval scores on the shipped embedding model, and it is a conservative floor: on that set it catches the clearest weak answers and flags about one correct answer in ten. A different embedding model moves the scale, so measure again before trusting the default with one. The floor changes only what the dashboard flags, never what visitors get.

Gap questions are grouped by meaning. The worker embeds each new phrasing with the configured embedding model and joins it to the closest existing cluster when the similarity reaches the threshold, otherwise it starts a cluster. Both values are properties of the embedding model, so after changing the model or the threshold use the Re-cluster action on the Gaps page, which rebuilds every cluster in the project from the questions. An exact repeat of an earlier question never needs an embedding call, and if the embedding provider is unavailable the question still lands in a cluster by its text; the hourly catch-up job fills in what was missed.

An analysis asks the chat model what a cluster of questions is missing from the content and what to write, and suggests marking criteria for the eval case. It runs in the worker, counts against the project's daily token budget, and is skipped when nothing about the cluster changed since the last run. Editors trigger it per cluster or for every open cluster without one, twenty per click. The automatic rule is off by default because it spends tokens on its own: set a threshold and clusters that reach that many occurrences in 30 days are analysed once, without a click.

Abuse protection

The defaults are on and sensible. Security explains what each one guards against.

VariableDefaultWhat it does
DESKBELL_ALLOW_ANY_ORIGINfalseAnswer any origin even when the allowlist is empty
DESKBELL_ABUSE_PROTECTION_DISABLEDfalseMaster off switch
DESKBELL_RATE_LIMIT_ASK_PER_MIN10Answers per IP per minute
DESKBELL_RATE_LIMIT_ASK_PER_HOUR100Answers per IP per hour
DESKBELL_RATE_LIMIT_PROJECT_PER_MIN120Answers per project per minute
DESKBELL_RATE_LIMIT_READ_PER_MIN60Cheap endpoints, per IP per minute
DESKBELL_MAX_CONCURRENT_ASKS2Simultaneous streams per IP, per instance
DESKBELL_TRUSTED_PROXIES0How many reverse proxies sit in front of this deployment
DESKBELL_IP_SALTBETTER_AUTH_SECRETHMAC key for IP hashes. Raw addresses are never stored

An empty allowlist behaves differently by environment: development answers any origin so the demo works, production refuses. DESKBELL_ALLOW_ANY_ORIGIN is the documented way out, for the case where you embed on many domains you cannot enumerate.

DESKBELL_TRUSTED_PROXIES is the setting people get wrong. It counts your own proxy hops, and x-forwarded-for is read right to left so a client cannot claim an identity. Set it too low and every visitor behind your proxy shares one rate-limit bucket. Set it too high and each visitor can pick their own by forging a header. For a direct run it is 0.

Per-project spending is configured in the dashboard rather than here. Rate limits protect the deployment, so an operator sets them from outside; the daily token budget belongs to a project, so it lives on the Install page next to the number of tokens that project has used today.

Enterprise Edition

VariableDefaultWhat it does
DESKBELL_EE_LICENSE_KEYnoneUnlocks the features listed in packages/ee/FEATURES

Everything not in that manifest is open source, including the whole answering path. See Licensing for the licence map.

On this page