Self-hosting
Run Dhaga on your own infrastructure — what a self-hosted deployment includes, what stays in Dhaga Cloud, and how the hosted-mode switches work.
Self-hosting is available to enterprise customers on request. Dhaga is not distributed publicly and the source is not something you fetch yourself: a self-hosted deployment comes with an agreement and a licensed build. Write to admin@ekasmi.com and we will scope it with you.
This page is the operator's reference for such a deployment — what a self-hosted instance includes, what stays in Dhaga Cloud, and how the switches work. It assumes a licensed build already in hand.
One codebase runs in two shapes:
- The core product — the whole CRM (capture, notes, graph, search, drafts,
export, Telegram, the browser extension API, the per-user
/apptheme and font presets) plus real user accounts (better-auth). This is what a self-hosted deployment runs. Nothing here is crippled or trial-limited. packages/ee— Dhaga Cloud only: multi-tenant row-level security, the pending-approval gate (open signup,/pendinguntil an admin or a payment lets you in), the admin panel, and Stripe billing. Not part of a self-hosted deployment, and not required to run the core.
A deployment for one person or a small trusted group wants just the core.
TL;DR
- Don't set
DHAGA_HOSTED_MODE. That's it — every EE feature goes inert. - You do not need to delete
packages/eefrom your build. It's harmless dead weight until that flag is set to"true". - Registration is open (no invite/approval step) whenever hosted mode is off, but the core is single-user: the first account is created normally and every subsequent signup is rejected (see "Single-user by design" below).
- There is no admin panel, no "Admin" nav item, and no billing UI in this mode — not hidden, not disabled, just not rendered at all.
Two levels of "without EE"
Level 1 (recommended): leave DHAGA_HOSTED_MODE unset
This is the default state of apps/web/.env.example — the var isn't even
listed there, only in packages/ee/.env.example.
With it unset:
- Every one of the extension points in
apps/web/src/lib/hosted/gate/(TenantGate,SignupGate,BillingGate,ApprovalGate,AdminGate,ReferralGate) short-circuits to its permissive default before it ever tries to load@dhaga/ee— so it doesn't matter whether the package is physically present. - The EE-only routes (
/api/access-requests,/api/stripe/webhook,/api/razorpay/order,/api/razorpay/verify,/api/razorpay/webhook) additionally check the flag themselves and return404if it's off, so an unrelated visitor can't accidentally trigger EE's schema setup against your database even ifpackages/eehappens to be installed andDATABASE_URLhappens to point at real Postgres. /app/admin404s for everyone (theisAdmincheck always resolvesfalse), so there's no dead link to a panel that doesn't work.
Nothing to delete, nothing to configure. This is the state a self-hosted build starts in.
Level 2 (advanced): physically remove packages/ee
Do this only if your deployment must contain no Dhaga Cloud code at all — for example, an audit that requires the hosted-mode billing, admin and multi-tenant components to be absent from the tree rather than merely inert. Delete:
packages/ee/
apps/web/src/app/app/admin/
apps/web/src/app/api/access-requests/
apps/web/src/app/api/stripe/
apps/web/src/app/api/razorpay/
apps/web/src/lib/actions/admin/
apps/web/src/components/app/admin/
apps/web/src/components/app/table/AdminTables.tsxAlso remove the "@dhaga/ee": "*" line from apps/web/package.json
dependencies (and the "@dhaga/ee" entry in transpilePackages in
apps/web/next.config.ts), then re-run npm install.
Everything else builds and runs unchanged — these are exactly the files that
statically import @dhaga/ee; nothing else in the core references it. If you
delete packages/ee but forget one of the route folders above, next build
will fail with a clear Module not found: Can't resolve '@dhaga/ee/...'
naming the exact file to remove.
Note the asymmetry with Level 1: lib/hosted/gate.ts itself does not
need to be deleted or edited — its dynamic import("@dhaga/ee") is wrapped
in a try/catch specifically so this file survives the package's removal.
Single-user by design (core only)
With hosted mode off, the core is single-user — it enforces exactly one
account, and this is a hard rule, not a suggestion. The reason is structural:
per-user data isolation (row-level security scoping every query to its owner)
lives entirely in packages/ee. The core's getDb() hands every request
one unscoped connection over one shared graph
(apps/web/src/lib/db/request-scope.ts).
That is completely safe for one person, but a second account on the same core
instance would land in — and read and edit — the first user's contacts, notes,
and facts. There is no per-user wall to hide behind.
So the signup path refuses to create a second account when hosted mode is off:
the first signup succeeds normally, and any later one is rejected with a
403 explaining why (see beforeUserCreate in
apps/web/src/lib/auth/config/index.ts).
If you need more than one user with real isolation between them, that's exactly
what hosted mode (packages/ee) provides — enable it (DHAGA_HOSTED_MODE=true
plus real Postgres; see Deploying) and multi-tenant RLS
takes over. Self-hosting the core for a genuinely shared, trusted household
where everyone is fine seeing everyone's data is not supported by relaxing this
guard — the guard is what keeps "single-user" honest.
The Me page and share links (both core)
/app/me — your own profile, your company's details and a library of reusable
document templates and email drafts — and per-contact/per-company share links
are both core. They work with packages/ee removed, they need no configuration,
and they make no AI calls at all: template variables ({{me.name}},
{{contact.firstName}}, {{today}}) are expanded by a pure function
(apps/web/src/lib/templates/render.ts), so an instance with no
ANTHROPIC_API_KEY gets the whole feature.
Three new tables, all created by core's auto-applied DDL — nothing to migrate by hand:
| Table | DDL | What it holds |
|---|---|---|
me_profile | apps/web/src/lib/db/ddl/me.ts | One row per user: your name, headline, bio, contact details, plus your company's name, website, tagline, description and address |
me_templates | apps/web/src/lib/db/ddl/me.ts | Document templates and email drafts (kind, title, optional subject, body), stored with their {{tokens}} unexpanded |
share_links | apps/web/src/lib/db/ddl/share-links.ts | One row per link: the token, the owner, what it points at, the three disclosure flags, a mandatory expires_at, revoked_at, and a view count |
Two things worth knowing as an operator:
me_profile.idis the owner's user id, and that is what keeps it to one row per user. There is noUNIQUE (user_id)— on a core-only install there is no such column, sincepackages/ee's RLS DDL adds it only on a hosted build — so the primary key does the work instead, and a save is oneINSERT … ON CONFLICT (id) DO UPDATE. Reads order byupdated_at DESC, id ASCbefore taking one row, which matters only if a row written by an earlier build (keyed on a random UUID) is still sitting in your table beside the user-keyed one: the newest write wins, deterministically.share_linkscarries an explicituser_ideven here, where there is only one user. It is not an oversight and it is not multi-tenant leftovers: a visitor opening/s/<token>has no session, so the row has to name its owner before any scoping can exist, and the owner read off that row is what opens the connection that reads the actual contact or company. On a hosted build this is the whole tenancy boundary; on a single-user self-host the column is written and matched identically, there simply being one owner to find. For the same reason the table is deliberately excluded frompackages/ee'sTENANT_TABLES— an RLS policy there would compareuser_idagainst a session variable that is unset at exactly the moment the link is resolved, match nothing, and break every link.
share_links has no foreign key to contacts or companies, on purpose: a
plain REFERENCES is RESTRICT in this schema, so an outstanding link would
block "forget this person". A link whose subject has been deleted resolves to
nothing and renders "this link is no longer available" — deleting the record is
itself a revocation.
Expiry is enforced at read time, not by a sweeper, so there is no cron to
configure and nothing to clean up: a row past its expires_at is dead the moment
it is read. Expired and revoked rows are kept as the owner's record of what was
shared. All three tables are covered by self-serve account deletion — nothing
cascades them otherwise, and an outstanding link must not outlive the account
that made it.
One deployment note: the public page lives at /s/<token>, outside
src/app/app/, which is where the session guard lives. If you front the app with
a reverse proxy or an SSO gateway that requires authentication for everything,
allow /s/ through, or share links will fail for exactly the audience they exist
for. /s/ is also in the robots.txt disallow list and the page sets
noindex. What a recipient can and cannot see is covered in the
Me & share links guide.
Attached files, and the database they live in (core)
Attachments — a signed contract, a deck, a spreadsheet hung off a contact or a
company — are fully core. The repo (apps/web/src/lib/repo/attachments.ts),
the POST /api/attachment upload and the GET/DELETE /api/attachment/[id]
routes import nothing from @dhaga/ee, so they're unaffected by Level 1 and
Level 2 and don't belong on the deletion list above. There is no object
storage, no third-party bucket and no new external dependency: the bytes are
base64 in a Postgres text column, the same shape card photos already use, so a
self-hosted instance holds its users' documents on its own disk. No AI touches
them either — no extraction, no enrichment, zero credits — so the whole
feature works on an instance with no LLM provider configured at all.
What it does cost you is database size, and that is the one thing to plan
for. base64 inflates a file by ~33%, so a 4 MB attachment is ~5.3 MB of text,
and it lands in the row itself (Postgres TOASTs a value that large out of line
and de-TOASTs it on every read of the payload). Card photos have exactly this
shape, but they are downscaled JPEGs; an attachment is whatever document the user
picked, so the per-row cost is far higher — and it rides along in every
pg_dump. Size your storage and your backup window for the documents your users
will actually keep, not for the graph alone.
The 4 MB cap is a Vercel constraint, and a self-host does not share it.
MAX_ATTACHMENT_BYTES (apps/web/src/utils/constants/app/attachments.ts)
is 4 MB because Vercel Functions cap a request or response body at 4.5 MB and
reject anything past it with 413 FUNCTION_PAYLOAD_TOO_LARGE before the
handler runs — an infrastructure limit no configuration raises
(Vercel function limits). 4 MB
leaves headroom for the
multipart envelope on top of the file. A Docker/Node deployment has no such
platform limit, so raising that constant is safe on a self-host, and the only
real ceiling there is your own database and backup budget. It is not safe on
Vercel: a larger number there only buys a file that passes the picker, passes the
repo check, and is then refused by the platform with an error the app never sees.
Raise the constant, not just the picker — saveAttachment re-checks it at the
write, because that is the last point before the bytes become a permanent row.
Accepted formats are one list, ATTACHMENT_TYPES (PDF, Word, PowerPoint, Excel,
plain text, CSV, JPEG/PNG/WebP). Downloads are always served
application/octet-stream with Content-Disposition: attachment and nosniff,
never the stored media type, so a stored .html or .svg can't execute script
on your instance's own origin.
Getting the files back out is core too. GET /api/export/archive streams one zip
holding the JSON dump plus every stored file, reading payloads a row at a time so
memory stays at one file rather than the account. It uses fflate, a
zero-dependency pure-JS writer, so there is no native module and nothing
platform-specific to install. The maxDuration = 300 on that route is a Vercel
ceiling and does not apply to a Docker/Node deployment — a self-hosted instance can
stream for as long as its own proxy allows. What does still apply is the zip format
itself: the writer emits a plain, non-ZIP64 archive, so 65,534 files or 4 GB is a
hard bound, checked before a byte is written and refused with a 413 rather than
silently truncated.
The EE-side touches are additive and inert without hosted mode: packages/ee
adds attachments to its TENANT_TABLES with a bespoke WITH CHECK policy —
the contact or company a file names must be in the same tenant — so the table
gets RLS when multi-tenancy is on. The core's own DDL creates the table either
way, and attachments is on ACCOUNT_OWNED_TABLES and on both the contact and
the company delete cascade, so forgetting a person takes their documents with
them.
Facts and enrichment on a company page (core)
A company page carries the same two ways of learning something a person page does, and they sit on opposite sides of the LLM line:
- Adding a fact by hand needs no LLM and no credits.
addFacttakes a contact or a company owner, and a hand-typed fact is written with a nullsource_note_id— no source note, no extraction job, no model call, no AI budget check. It works on a core-only self-host with noANTHROPIC_API_KEYset, exactly as manual person facts and manual follow-ups already do. The only thing it spends is the free on-device embedder, so the fact stays semantically searchable. - "Enrich from public web" on a company needs a provider. The gate is
identical to person enrichment on purpose: the same
enrichmentplan feature, the same AI budget check, and one 20-creditenrichmentaction — researching a company must not become a cheaper back door into the same web searches. Findings are saved as a note on the company with cited sources, and the facts extracted from it landunverifiedwith that note as their receipt, one tap to confirm. Delete the note and everything derived from it goes.
On a self-host the plan half of that gate is a no-op — with billing not running
currentPlan() resolves to self_hosted, which holds every feature including
enrichment. What actually decides whether the button works is whether you
configured an LLM provider: without one the action answers "Configure an LLM
provider to enable enrichment" rather than enqueuing a job that could never run.
Disabling just billing (keep admin + the approval queue)
If you're running the hosted product but not ready to charge (a free beta,
for instance), you don't need to touch DHAGA_HOSTED_MODE. Simply leave the
processor credentials unset — both of them: STRIPE_SECRET_KEY, and
RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET. The settings page's "Plan & billing"
section checks for them itself and renders nothing — not a broken "Upgrade"
button, no section at all — while the admin panel and the approval queue keep
working normally. /pending then shows no "skip the queue" section either,
since the only way in is an admin. Setting just one of the two is a valid
configuration, not a half-off switch: the section appears and sells through
whichever processor is configured.
Credentials alone are not enough to sell anything, and the difference bites
quietly. RAZORPAY_KEY_ID/RAZORPAY_KEY_SECRET decide whether the processor
is enabled; the per-plan ids (RAZORPAY_PLAN_PRO_MONTHLY,
RAZORPAY_PLAN_PRO_YEARLY, RAZORPAY_PLAN_POWER_*, and STRIPE_PRICE_* on
the other side) decide whether a plan can be bought — availableCombinations
skips every combination whose id is unset. Keys with no ids behind them
therefore render no buttons at all, with no error anywhere. If you expect INR
checkout and see none, check the plan ids before you suspect the keys.
The four introductory prices are a separate, Razorpay-only set of
vars — RAZORPAY_OFFER_PRO_MONTHLY, RAZORPAY_OFFER_PRO_YEARLY,
RAZORPAY_OFFER_POWER_MONTHLY, RAZORPAY_OFFER_POWER_YEARLY — and they hold
Razorpay Offer ids, not plan ids. An introductory purchase is the standing
Plan with the offer attached to subscriptions.create as offer_id: the offer
discounts the first N cycles and Razorpay steps the price up by itself when they
run out. Offers are Dashboard-created — the API cannot create, list or read one —
so an environment variable is the only way an id reaches the app; one per
(tier, cadence) is enough, because Razorpay resolves the payment-rail variant
itself. Each pair is checked on its own (getIntroOffers() / hasIntroOffer()),
so an instance may legitimately sell two of the four and simply not offer the
others, and with none of them set every surface shows the standing price and
every button sells the standing plan — the correct default, not a degraded one.
Checkout fails closed the other way: an introductory cadence that reaches it with
no offer id configured is refused outright rather than silently charging the
standing amount. The four older RAZORPAY_PLAN_*_INTRO_* plan ids are not a
purchase path any more — they sit in the resolve-only table beside the
_LEGACY ids so that a plan id which could appear on a historical row still
resolves to a tier rather than throwing. An introductory price is still
first-purchase-only: nothing on the plan ladder moves an existing subscriber onto
one, and changing plan forfeits it.
That offer has no closing date — it is a per-customer 12-month term
(INTRO_TERM_MONTHS, billing/intro/term.ts): a buyer holds the introductory
price for their own first twelve months, counted from their own subscription
start, then pays the standing price for that tier and cadence. A 12-cycle monthly
offer and a 1-cycle yearly offer both mean "your first year" — a yearly plan
bills once a year, so twelve cycles there would discount twelve years. Whether
the prices are still sold is a runtime admin toggle rather than an env var or
a constant — introOfferOpen() / setIntroOfferOpen()
(billing/intro/availability.ts) over the intro_offer_availability key in
EE's billing_settings table, defaulting to open, closing only to new buyers
and never touching anyone already subscribed. The step-up needs no code of ours
at all, which is the point of the design. It used to be a nightly sweep that
booked each finished term onto the standing Plan, and that sweep was a confirmed
defect: measured against Razorpay on 2026-08-20, PATCH /subscriptions/{id} with
{plan_id, schedule_change_at:"cycle_end"} was refused with a 400 on both
Indian payment rails — card: "Only offers can be updated for subscriptions
when payment mode is domestic card."; UPI: "subscriptions cannot be updated
when payment mode is upi" — and the e-mandate registered max_amount at the
plan amount, ₹199, so the ₹499 debit would have been refused anyway (the
₹99,000 SDK default previously cited belongs to the registration flow, not to
Subscriptions; that inference was wrong). Under the offer model neither
constraint is met by any code path: the mandate registers at the standing
amount — that is what makes the step-up automatic, and why a buyer's bank shows a
cap higher than their first charge — and no update call is ever made.
runIntroPriceStepUps, billing/intro/step-up/ and updateSubscriptionPlan()
are deleted. Honest limits: the offer path was measured end to end in the
sandbox, on both rails (₹499 plan + limited-cycle offer → ₹199 invoice paid,
mandate max_amount ₹499), and nothing has been sold at an introductory price
yet, so no term has ended in the field. A separate, pre-existing bug fell out of
the same refusal — ordinary tier changes
went through the same call — and that one is fixed (2026-08-21) by a
different design again: a Razorpay
plan change now mints a second, future-dated subscription the customer
authorises, so upgrading Pro → Power works again. None of this is
reachable on a self-hosted deployment, which runs no billing at all. What still
rides the cron is the warning, re-keyed: /api/jobs/daily runs
runIntroStepUpNotices (apps/web/src/lib/jobs/intro-step-up/notices.ts, email
template lib/email/intro-step-up.ts) on the same CRON_SECRET as the other
jobs there. The old "sweep before notice" ordering went with the sweep — there is
no booked change left to read, so the ~30-day notice derives the term from
subscriptions.intro_offer_id (a nullable column recording the offer a purchase
was made with; the row's cadence stays monthly/yearly, because the
subscription is on the standing Plan) plus the deterministic introTermEndsAt(),
and quotes the computed anniversary. The final-month in-app banner ships
alongside the email (apps/web/src/components/app/billing/, mounted in
app/app/layout.tsx): it reads those same two things, so the two warnings quote
one date, and it is pure over the plan summary the app shell already loads, so it
costs no extra read for anyone not on an introductory price. Both survived the
sweep's deletion deliberately — a customer whose price is about to rise deserves
the warning whichever mechanism raises it. Two channels, and
neither has yet reached a customer.
The whole subscription lifecycle sits behind the same wall. Plan changes
(packages/ee/src/billing/plan-change/), cancel and resume, the introductory
term, availability toggle and term-end notice (billing/intro/), the
charge/refund/dispute ledger
(billing/payments/) and the webhook receivers (billing/webhook/,
billing/razorpay/) are all EE-only, and the subscriptions, payments and
billing_settings
tables they write are created by EE's schema — a core-only self-host has neither
the code nor the tables, and needs neither. Its hosted-gate defaults
(apps/web/src/lib/hosted/gate/defaults.ts) hand back an empty offer list, so
there is no introductory pricing to configure and
nothing to schedule. The Level 2 removal list above
already covers this: it deletes packages/ee/ and the two API route folders
wholesale, so new billing modules never add entries to it.
The pending-approval gate (hosted only)
On a hosted instance signup is open — anyone can create an account — but a
new account is created unapproved (user.approved_at is null, a column
packages/ee adds; core's own schema never has it). An unapproved account can
authenticate and reach exactly three things: /pending, the checkout that pays
for it, and sign-out. Every other /app/* page redirects to /pending and
every authenticated API route refuses it, enforced once in
apps/web/src/lib/auth/guard.ts.
Approval is granted by an admin approving the access request, by a payment the processor has confirmed (the webhook — never at checkout-intent time, so an abandoned checkout grants nothing), or by an admin comp plan. A refund or chargeback revokes it; a cancellation does not.
None of this exists on a self-host. Without packages/ee the ApprovalGate
falls back to its permissive default — isApproved is always true, /pending
is unreachable, and the approved_at column is never even created. Same
hosted-gate pattern as billing and the admin gate
(apps/web/src/lib/hosted/gate).
That default is also what keeps the welcome email honest here. The one
onboarding email a new account gets (apps/web/src/lib/auth/config/welcome.ts)
asks the same gate at send time and only mentions the waiting list — and the
option to subscribe past it — when the account is unapproved. On a
core-only self-host that never happens, so your users get the product-guide
link and nothing else: no queue is promised where no queue exists, and nothing
is offered for sale on an instance with no billing.
Referral rewards (hosted/EE only)
The two-sided referral program (a free month of Pro for both advocate and
referee) lives entirely in packages/ee and only functions in hosted mode — it
extends a user's subscriptions row, which the core has no concept of, and a
self-host is single-user anyway (there's nobody to refer). On a self-host the
referral surfaces are simply absent: /api/referral returns
{ referral: null } and /app/referral shows an "unavailable" note — the same
permissive-fallback pattern as billing (getReferralGate() in
apps/web/src/lib/hosted/gate).
In hosted mode the reward is delivered Stripe-safely. An advocate who already
has a live Stripe subscription is given a Stripe coupon — set
STRIPE_REFERRAL_COUPON_ID to a duration: once, 100%-off coupon you create in
the Stripe dashboard — while free/comp users get an additive comp Pro month
(their current_period_end is extended, never downgrading a higher tier). If
STRIPE_REFERRAL_COUPON_ID is unset when a paying advocate qualifies, the grant
fails loud and the referral stays pending for retry rather than silently
half-rewarding.
Creating the first admin user
There's a deliberate chicken-and-egg problem here: the admin panel can only
promote a user to admin if you're already an admin, and (in hosted mode) a
new account lands unapproved on /pending, which normally only an admin can
clear. DHAGA_ADMIN_EMAILS breaks that circle:
- Set
DHAGA_HOSTED_MODE=trueandDHAGA_ADMIN_EMAILS=you@yourdomain.com(comma-separated if more than one) inpackages/ee's environment. - Go to
/signupand create an account with that exact email address. These emails are approved on the way in rather than parked on/pending, and an admin is let through regardless ofapproved_at— so an admin can never be locked out of their own instance. - You're now an admin automatically —
isAdminchecksDHAGA_ADMIN_EMAILSin addition to the database flag, so nothing needs to be flipped manually./app/adminis live for that account immediately. - From
/app/admin/users, you can now promote other accounts by setting theirisAdminflag through the UI — they don't need to be inDHAGA_ADMIN_EMAILSthemselves once that's done.
DHAGA_ADMIN_EMAILS is safe to leave set permanently as a break-glass path
(e.g. if you ever lock yourself out of the only admin account) — it's
env-config, not a stored credential, and only your deployment operator
controls it.
Managing a user's subscription and AI allowance (hosted/EE admin)
From a user's detail page (/app/admin/users/[id]) an admin can, without
Stripe, comp that user's access:
- Plan — set
free,pro, orpower.freerevokes the comp — usually by removing the subscription row, so the account falls back to the instance default allowance (see the rule below);proandpowermove it onto that plan's monthly allowance — 300 credits a month for Pro, 1,000 for Power. - Expiry — an optional date on a paid plan. Once it passes the plan stops being in play and the account drops back to the instance default allowance (leave it blank for no expiry).
- AI credits — a per-user monthly cloud-AI credit allowance, stored as the
ai_monthly_cap_overridesetting. It sits at the top of the precedence ladder for that one user, beating a running promotion, the plan allowance and the instance default alike; blank or0clears it. Credits are charged per user-visible action, not per model call — a card scan costs 1 credit whether it takes one round-trip or three, and deep research costs 20 (packages/core/src/metering/credits.ts, BRD §8.3).
An admin can comp a plan up, and can lower one only as far as the tier the user actually pays for. That floor is the whole rule — an admin may take back exactly what an admin gave, and no more. Raising a tier is always allowed.
- Nothing billing (no row, a pure comp, a cancelled or never-completed
subscription) — the floor is
free, so every option is settable. - A goodwill comp granted on top of a live subscription (bumping a paying Pro to Power) — the floor is that paid tier. The bump is reversible back to Pro, and no further, because below it is where the money is.
- A genuinely paying subscription — the floor is the user's own plan, so nothing below it can be set at all. Those changes belong in the customer's own Plan & billing settings, or in the processor dashboard: our row and the processor would otherwise disagree and the card would keep being charged for access we just revoked.
The plan selector disables exactly the options the server would refuse and states why — but the refusal itself is enforced server-side and re-checked inside the transaction, so it holds however the request arrives.
Two facts are recorded on the row at the moment a comp is granted, rather
than inferred from it afterwards: that an admin granted it
(subscriptions.admin_granted) and what it was granted over
(admin_granted_over_plan / admin_granted_over_status). Inferring broke
exactly where it mattered, because comping an existing row keeps that row's
processor ids and sets the status to active, so a plan comped to unblock a user
whose first charge never settled read back as a paying customer and could never
be lowered again. The underlay can't be re-read from the processor either: the
whole plan/entitlement path is deliberately DB-only, and the comp overwrote the
columns that held the answer. A comp granted before those columns existed has no
underlay, which reads as "nothing paid underneath" — the behaviour it already
had.
The flag is cleared the moment a processor reports a paying status (active
or past_due) on any write path, so a comp never outlives the comp: when a stuck
3DS charge finally settles through customer.subscription.updated, the row
becomes an ordinary paying customer and the lock comes back. A status that bills
nobody leaves the flag alone — a comp over an abandoned checkout is still a comp.
Setting an account back to free revokes the comp; it does not necessarily
cancel a subscription. Those are different things, and conflating them aborted
payments that were still on their way:
- The row carries a processor subscription still waiting on its first charge
(
incomplete) — the comp is undone: the row goes back to the plan and status the comp overwrote and keeps its processor ids. Nothing is cancelled and nothing is deleted, so "move them to Pro, then back to free until their payment goes through" leaves the payment able to go through, and the later webhook still finds the row by subscription id. Deleting it would be worse than cancelling — the charge would settle against no row at all and the user would pay for access they never receive. - Anything else — whatever processor subscription the row still carries is cancelled before the row is deleted, so a downgrade can't leave a processor billing a subscription the database has forgotten.
These controls live only in the EE admin panel. On a core-only self-host billing
isn't running, so no plan is ever in play and every user resolves through the
instance-wide default — which is what DHAGA_AI_MONTHLY_CAP seeds (see the env
table below).
Instance-wide AI credit controls (/app/admin/ai-credits, hosted/EE)
Beside that per-user override, /app/admin/ai-credits (titled "AI cost &
credits") carries five levers that apply to the whole instance — three that size
the credit allowance, and two that size the independent dollar ceiling
behind it:
- Plan-cap enforcement — a master switch that is on by default
(
AI_PLAN_CAP_ENFORCEMENT_DEFAULT = true). In the shipped state every user is held to the monthly allowance for their plan: Free, Pro and Power each have a number. That is what the pricing page states — it sells Pro and Annual as 300 credits a month and says what runs out when they do — so leave it on unless you have a reason not to. Turning it off is an escape hatch (a migration, an incident), not a resting state: the allowances below are then stored but ignored, every plan resolves through its raw billing entitlement (hasUnlimitedAi) instead, and users with no plan fall back to the instance default. Promotions and grants keep working either way. - Monthly allowance per plan — runtime-editable overrides of the shipped
numbers (
PLAN_AI_CREDITS_PER_MONTH), per plan, each of which can also be set to "no cap". Free is editable here exactly like Pro and Power, and it does double duty: whatever it is set to is also the instance-wide default (rung 4 below). The card names the live number and where it came from — e.g. "Effective default: 10 credits / month — from the shipped default in code", or from "the Free allowance set here", or from "theDHAGA_AI_MONTHLY_CAPseed". - Promotional month — lifts every user to one allowance for a window ("everyone gets 1,000 credits this month"). It works whether or not enforcement is on, and it ends at the start of its end date, evaluated on every read — so it expires by itself with no cron job and no admin cleanup.
- Dollar-ceiling enforcement — a second master switch, also on by
default (
AI_DOLLAR_CAP_ENFORCEMENT_DEFAULT = true), for a per-user monthly ceiling denominated in real inference dollars rather than credits. It exists because credits stopped bounding spend: three metered features cost 0 credits on purpose (the nightly signal, person-classification and goal-match sweeps — billing them would be ~26× their real cost), so an uncredited sweep moves no counter but still costs money. The gate is enforced inside the same metering path as the credit cap, so it covers every action including those three, and it is checked after credits — the credit message is the one a user can act on (upgrade); this one is the operator's backstop. - Multiplier and floor — the two numbers that turn a subscription into a
ceiling: its monthly revenue × multiplier (default
3.0), or, for any plan with no recurring revenue, a flat floor in USD (default$0.50). So Pro bought at the standing monthly price (₹499 ≈ $5.74) resolves to a $17.21 ceiling, and the same tier bought on the introductory monthly offer (₹199 ≈ $2.29) resolves to $6.86. The floor is not a rounding detail — Free earns $0, and $0 × any multiplier is $0, which would refuse every AI action a free user takes, including the ten their credit allowance is meant to buy. The card shows a per-plan ceiling table live as you change either number, but read it as representative: that screen lists plans, not subscribers, so it has no cadence or currency to resolve against and feeds the arithmetic the standing-USD figure (PLAN_MONTHLY_REVENUE_USD), where a real user's gate feeds it their own subscription. A per-userai_monthly_dollar_cap_overridebeats both (0is a valid override, unlike its credit sibling).
The revenue basis was reversed on 2026-08-19, and the multiplier moved with
it. The ceiling used to be sized from the plan's standing list price, with
offer prices structurally kept out of the arithmetic. It is now sized from what
this subscriber actually pays — tier × cadence × the currency the processor
charges, introductory offer prices included — resolved by
monthlyPlanRevenueUsd() in
apps/web/src/utils/constants/ai-budget/plan-revenue.ts. (That path is a
directory, not a file: the old utils/constants/ai-budget.ts was split under
the 150-line rule, and the import path @/utils/constants/ai-budget is
unchanged.) A subscription with no cadence to read — an admin comp, a referral
grant, a processor we could not reach — falls back to the standing price rather
than to $0, because refusing it a basis would be a $0 refusal. INR is converted
through a pinned INR_PER_USD = 87 (utils/constants/pricing/currency.ts) that
exists only to denominate this backstop and never charges anyone: a few
percent of FX drift moves a ceiling by cents, where a live rate would put a
network call — and an outage mode — behind every metered AI action.
The multiplier went 2.0 → 3.0 in that same change, and the reason matters
more than the number. Sizing the ceiling off the price paid pulls the thinnest
charged row down to Pro introductory yearly, ₹167/month ≈ $1.92. The measured
heavy-user month in BRD §8.3 is $2.84 of inference, plus an estimated (never
measured) ~$0.40 of uncredited watchlist scanning ≈ $3.24 — at 2.0 that is 84%
of a $3.84 ceiling, so real paying customers would have started being refused
mid-month by a gate they had never met, with nothing on screen explaining why.
At 3.0 the ceiling is $5.76 and the same month sits at 56%: a backstop again.
The constraint did not disappear when the invariant flipped, it changed shape —
it used to be "an offer price must never reach the ceiling", and it is now "the
multiplier must clear the measured heavy month on the cheapest thing we sell",
which has to be re-checked every time a cheaper offer is added or an uncredited
feature grows. apps/web/src/lib/__tests__/ai-action-metering/dollar-cap.test.ts
fails if it stops holding.
On a core-only self-host the dollar gate is inert. Its bottom rung is
deliberately no ceiling rather than a number: with billing not running no plan
is ever in play, so effectiveMonthlyDollarCap() resolves to null and never
refuses an action. That is the opposite of the credit ladder, whose bottom rung
(the instance default) is a real number — and it is intentional, because a
self-hoster pays their own provider bill and inventing a dollar ceiling they
never asked for would break their instance. There is no new environment
variable: the multiplier, floor and switch live in ai_budget_settings and
there is no DHAGA_AI_MONTHLY_DOLLAR_CAP. DHAGA_AI_MONTHLY_CAP is still
credits, and still the only AI-budget env var. Resolver:
apps/web/src/lib/ai/metering/dollar-cap.ts.
The grant form on this page is additive make-good credits for everyone on
the instance — it always broadcasts, with no free-text user id field —
with a required reason and an expiry that defaults to the end of the current
month. Granting to one specific user instead happens from that user's own
/app/admin/users/[id] page, where the same card is pinned to them. Either
way, a grant only moves the ceiling — ai_actions, the only record of what
cloud AI actually cost, is never rewritten, and "End now" stops a grant
counting without deleting its row.
Every grant ever made lives in its own searchable, paginated ledger at
/app/admin/ai-credits/grants (linked from this page) — search matches the
recipient's name or email, or the word "everyone" for broadcast grants.
Precedence, highest first
(apps/web/src/lib/ai/metering/cap/index.ts):
- Per-user admin override (
ai_monthly_cap_override) — wins outright, including over a running promotion. - Active instance-wide promotion — applies whether or not enforcement is on.
- Plan allowance — when the master switch is on (it is, by default) and a
paid plan is in play. The admin-edited value if one is set, else the
constant in
apps/web/src/utils/constants/plans/;nullmeans no ceiling. - The instance default (
instanceDefaultCap()inapps/web/src/lib/ai/metering/cap/instance-default.ts): the admin-set Free allowance, elseDHAGA_AI_MONTHLY_CAP, else the shippedFREE_TIER_AI_CREDITS_PER_MONTH(10 credits a month).
Then, on top of whichever rung won, every active grant for this user is added.
Rung 4 is the one that catches a free user, a user no plan governs (a self-host,
where billing isn't running), and everyone when the master switch is off. Free
users resolving there rather than through the plan ladder is deliberate: it
means DHAGA_AI_MONTHLY_CAP means the same thing on a self-host as it does on
an instance that has billing. Note what "seed" implies — the env var supplies
the instance default only while nothing has been set in the database. The moment
an admin sets a number (a per-user override, a promotion, a plan allowance, or
the Free allowance), that stored number wins and the env var stops mattering.
Nothing is copied into the database at boot; env is simply read last, so there
is one live number and the admin screen can say where it came from.
What a core-only self-host gets. The two tables this feature stores its
state in — ai_budget_settings and ai_credit_grants
(apps/web/src/lib/db/ddl/ai-budget.ts)
— belong to the core, so they are created on your database whether or not
packages/ee is present. Nothing else follows from that: there is no admin UI
to write to them (both simply stay empty), and no row-level security on them
either — ai_credit_grants gets its bespoke
user_id IS NULL OR user_id = <tenant> policy only from
packages/ee/src/db/rls-ddl.ts, and ai_budget_settings deliberately gets none
anywhere, being operator config rather than user data. With both tables empty and
no billing running, every user resolves at rung 4 and one number governs the
whole instance: whatever DHAGA_AI_MONTHLY_CAP seeds, else the shipped 10
credits a month — and the dollar ceiling resolves to none, as described above.
Nothing here is required from packages/ee to self-host,
and the Level 2 removal list above needs no additions — the new admin page and its
server actions and components live under apps/web/src/app/app/admin/,
apps/web/src/lib/actions/admin/ and apps/web/src/components/app/admin/,
which are already on it. The dollar gate added files in those same three
directories (plus packages/ee, which Level 2 removes whole); its own resolver,
cost helper and constants are core, and the ai_actions.batch column it reads is
created by the core DDL like any other.
Running with docker compose up
The repo root has a Dockerfile and compose.yml
that run the web app plus a Postgres 16 + pgvector container. The app creates
its own schema (including the vector extension) on first connection — there
is no migration step.
-
Create a
.envfile next tocompose.yml:BETTER_AUTH_SECRET= # openssl rand -base64 32 # Optional: ANTHROPIC_API_KEY, BETTER_AUTH_URL (defaults to # http://localhost:3000), POSTGRES_PASSWORD (defaults to "dhaga" — # change it if the DB port is ever exposed), RESEND_*, DHAGA_* -
docker compose up --build -
Open http://localhost:3000 and sign up. Contact data lives in the
dhaga-dbvolume;docker compose downkeeps it,down -vdeletes it.
None of the packages/ee vars are wired into compose.yml — this is the
plain self-host path (Level 1 above).
Contact import and contact sync are both fully core — the .vcf/CSV file
importer, the mobile POST /api/import endpoint, and the OAuth contact
connectors all live in the core (no @dhaga/ee). The Connect Google /
Outlook buttons are env-gated: they appear only when
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET or
MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET are set, so a self-host with none
of them simply shows file import — no missing-feature errors. See
docs/CONTACT_IMPORT_SETUP.md to configure the connectors.
A connected account is a row in contact_connections, one per
(provider, account_email), so several Google and several Microsoft accounts can
be connected at once. Its tokens are encrypted at rest with AES-256-GCM
(lib/crypto/tokens.ts, keyed off CALENDAR_TOKEN_SECRET falling back to
BETTER_AUTH_SECRET) — a different mechanism from better-auth's
encryptOAuthTokens, which covers social sign-in tokens in the account
table. Connected accounts re-sync on a schedule: the daily cron at
/api/jobs/daily covers it, and /api/jobs/contact-sync (same CRON_SECRET
bearer auth) can be driven more often if you want changes to land sooner.
Custom database deployments
compose.yml is a working reference, not a requirement — DATABASE_URL can
point at any Postgres 15+ (self-hosted, RDS, Neon, Supabase, …). What the
app needs from the database:
pg_trgm— always; it's a contrib extension every Postgres ships, and the app's boot DDL runsCREATE EXTENSION IF NOT EXISTSitself.pgvector— needed by the default semantic search, optional if you setDHAGA_VECTOR_STOREto a registered external vector store (see Providers); the boot DDL skips the vector schema entirely in that case.- Any pooling mode works (hosted mode only) — tenant scoping is
transaction-local: each unit of work runs inside one
BEGIN … COMMITwhose first statement isset_config('app.current_user_id', …, true), and Postgres discards that setting at COMMIT. Because the scope never outlives its transaction, a transaction-mode pooler (Supabase's port 6543, PgBouncer, Supavisor, Neon's-poolerendpoint) can't run a query unscoped or leak the setting onto another user's backend — so a direct connection, a session-mode pooler, and a transaction-mode pooler are all safe, with no pooling-mode boot guard to satisfy (the earlier one, and itsDHAGA_ALLOW_TRANSACTION_POOLERoverride, were removed as obsolete). Supabase specifically: moving from 5432 to 6543 is aDATABASE_URLchange with no code change — the 6543 path is designed-correct but not yet verified against a live transaction pooler; seedocs/SCALING.md§2. - A role without
BYPASSRLSorSUPERUSER(hosted mode only) — either attribute makes the role ignore RLS (a superuser bypasses it unconditionally even whilerolbypassrlsreads false), and the boot guard rejects both. Runpackages/ee/scripts/create-app-role.sqland connect asdhaga_app; see Deploying's "The Postgres role DATABASE_URL connects as matters" for why the provider default role is dangerous. Plain single-user self-hosting (hosted mode off) needs none of this — any role that can create tables works.
Geocoding, the Home globe, and calling windows
The city map resolves a contact's free-text location through the core geocoding
gateway (GEOCODING_PROVIDER, default nominatim). Public Nominatim needs no
API key, but Dhaga enforces its one-request-per-second ceiling and stores each
distinct answer in geocode_cache; use NOMINATIM_URL for your own instance.
Every map keeps OpenFreeMap/OpenStreetMap attribution visible.
Home's globe is a read-only projection of that cache. It never queues geocoding
while rendering and sends only city coordinates, counts, time-zone ids and
calling-window status to the browser — no contact names. tz-lookup
(CC0-1.0) converts cached coordinates to an IANA zone offline on the server;
Intl supplies current daylight-saving rules. There is no time-zone API, key,
account, request bill, or new contact-data recipient.
The globe adds no hosted service. Three.js (MIT) runs in the browser; bundled
NASA-derived Earth textures and the Dhaga-generated thread illustration are
ordinary same-origin static assets. Exact sources, usage notes and credits live
in apps/web/public/assets/{globe,home}/README.md. None of this depends on
packages/ee.
Nightly signal detection (job-change + news watchlist, opt-in)
The web-search sweep behind a contact's "Watch for job changes & news"
toggle (BRD §6.7) runs from /api/jobs/detect-signals, not a background
process — there is no job queue to run in a container. Point any scheduler
at it:
0 6 * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
https://your-domain/api/jobs/detect-signalsRequires CRON_SECRET and a search provider. Since 2026-08-08 the default
provider is Anthropic's own server-side web_search tool, so
ANTHROPIC_API_KEY on its own is enough — FIRECRAWL_API_KEY is optional and
only takes precedence where you set it. See "Search providers" in
Providers and .env.example. Without
CRON_SECRET the route always returns 401, so it's safe to leave unconfigured if
you don't want the feature. Without any search provider the sweep returns
{ skipped: "no_search" } and writes nothing, and the contact-page toggle that
would enrol someone in it is greyed out "Coming soon" rather than arming a scan
that can't run — see "Optional providers, and what the UI does without them"
below.
Two honest caveats. This path has never been run against a live Anthropic key — it typechecks and is unit-tested, but no end-to-end sweep has been observed, so treat it as armed rather than proven. And it is not free: Anthropic bills $10 per 1,000 searches on top of charging every retrieved page as input tokens to the searching model. The token half is recorded against your instance's dollar ceiling; the per-search charge is not.
Nightly curation passes (person/service classification, goal matching)
Two more sweeps ride the /api/jobs/daily endpoint below rather than a queue,
both over the Anthropic Message Batches API and both zero-credit (see
BRD §8.3):
- Person-vs-service classification labels imported address-book rows ("Ola Support", "Vegetable Vendor") so they stop appearing on proactive surfaces. Nothing is deleted or hidden — the row stays in People, search, merge, Wrapped and every export.
- Goal matching judges contacts against the one objective a user has set,
writing the cohort that Home's goal tile and
/app/goaldraw their daily slice from. Anything the model scores belowGOAL_MIN_FITis stored rejected rather than dropped, so the next night's pass reaches new people instead of re-judging the same ones. Goal people are not merged into the daily suggestions — that surface is theirs alone.
Both are two-phase, exactly like signal detection: one invocation applies the
previous run's batch and submits a fresh one, so a graph drains over several
nights rather than in one call. Each is capped per run
(PERSON_CLASSIFICATION_RUN_CAP 1,000 contacts, GOAL_MATCH_RUN_CAP 150) and
each reports a remaining count in the endpoint's JSON — contacts still to
classify, and cohort slots still to fill — so you can watch a backfill drain.
A goal batch pointer that is never applied expires after
GOAL_MATCH_POINTER_MAX_AGE_MS (36h) and is abandoned, so one wedged or
expired batch cannot freeze a user's goal matching indefinitely.
Both need a working LLM provider; without one they
return skipped: "no_llm" and change nothing. They run before the daily
brief on purpose, so its reach-out section reflects the freshest labels.
The daily brief (one scheduled email a day)
Dhaga sends at most one scheduled email per person per calendar day, the
daily brief, from the one /api/jobs/daily endpoint, on the single Vercel cron
in apps/web/vercel.json ("17 6 * * *", unchanged).
Until 2026-08-09 the reach-out digest, the confirmations digest, the morning
follow-up reminder, the due-follow-up sweep, the birthday/anniversary reminder
and the LinkedIn-export nudge each decided independently whether they were
allowed to send, so one person could receive three messages inside a minute.
They are now sections of one message (lib/jobs/daily-brief/, one file per
section under sections/) and the send decision is made once, in sweep.ts.
Sections appear in urgency order — the subject line comes from the first one that has content — and each still honours the toggle it always had in Settings → Suggestions:
| Section | What it lists | Per-user toggle |
|---|---|---|
| Follow-ups due | Commitments due inside FOLLOW_UP_LEAD_DAYS (3), plus overdue ones | morning_reminder_enabled |
| Important dates | Birthdays and anniversaries inside the user's lead time | important_date_reminders_enabled |
| Waiting for your review | Queued confirmations | confirmations_digest_enabled |
| People to reach out to | Today's suggestions, or threads going quiet | daily_digest_enabled |
| Also waiting | Totals for the whole open backlog, not just the lead window | morning_reminder_enabled |
| Your LinkedIn export | The day-1/3/6/7 upload nudge | None — clicking "Get contacts from LinkedIn" is the opt-in, unchanged |
Three consequences worth stating plainly:
- If every enabled section is empty, nothing is sent. There is no "you have nothing today" email; a long-time user having a quiet Tuesday hears nothing.
daily_digest_enabledis the daily check-in. When the suggestion engine has nobody due, the reach-out section falls back to contacts going quiet (listQuietContacts, capped atQUIET_CONTACTS_IN_BRIEF= 3) rather than vanishing. If that is empty too the section is omitted, so a check-in day can still end in no email at all.- Empty accounts get an activation nudge instead. If there is nothing to
report and the graph has zero contacts and
morning_reminder_enabledis on, the brief is replaced by a short email pointing at adding a first contact and the product guide. At mostACTIVATION_NUDGE_MAX(3) sends,ACTIVATION_NUDGE_INTERVAL_DAYS(7) apart, tracked in theactivation_nudges_sentsettings key, then silence forever. It is an alternative to the brief, never an addition, and shares the same one-send-per-local-day record.
Both the brief and the activation nudge carry an opt-out line — "If you'd
rather not receive these, turn them off in Settings → Suggestions", linking to
/app/settings#suggestions (notificationEmailShell, lib/email/send.ts).
Transactional mail deliberately does not: welcome, email verification, password
reset, magic link, access-request and approval notices, admin notices, feedback,
the user-triggered event digest and background-job notifications keep the plain
shell, because no setting turns those off and the line would be a promise the
Settings page cannot keep.
Duplicate suppression is one settings record per user,
daily_brief_last_local_day, covering the brief and the nudge together. The five
retired per-job records (morning_reminder_last_local_day,
daily_digest_last_local_day, confirmations_digest_last_local_day and friends)
are inert and left in place — no migration deletes them. One-off consequence on
upgrade: a user who already received the old emails on the morning this ships can
also receive one brief that day, because the new record starts empty.
The whole thing degrades to a clean no-op without RESEND_API_KEY /
RESEND_FROM_EMAIL (and, on a single-user self-host, DHAGA_OWNER_EMAIL), and
the endpoint reports it under a single dailyBrief key —
{ sent, activation, skipped } — where there used to be six (digest,
confirmationsDigest, reminder, followUpReminders, importantDateReminders,
linkedinReminders).
Each user's time zone (Settings → Suggestions → Time zone, default UTC)
decides which calendar day the brief is reasoning about, so a birthday lands on
the recipient's day rather than the server's, and a re-triggered cron is a no-op
for someone already emailed on their local day. What the time zone does not yet
change is when the mail goes out: every send still happens on the one cron
run, at whatever UTC time it fires. Per-user local-morning delivery needs the
endpoint driven hourly with EMAIL_JOBS_HOURLY=true (see the env table
below).
Don't add a sub-daily cron on Vercel Hobby
Vercel Hobby caps crons at once per day, and a more-frequent entry in
apps/web/vercel.json can break Hobby deploys — so hourly delivery is not
available there. Off Vercel, an hourly system crontab or container timer hitting
the same URL with the same Authorization: Bearer $CRON_SECRET header is enough.
The plan-lapse notice ("your plan has ended")
One more email rides the same /api/jobs/daily endpoint, and it is deliberately
not inside the one-scheduled-email-a-day budget above: lib/jobs/plan-lapse.
When a paid plan stops — a subscription that stopped renewing, an admin comp that
hit its expiry, a spent referral month — nothing flips a column, because
entitlement is decided at read time. The paid features simply stop being there,
silently. This is the message that says so.
It is transactional, like a password reset, so it renders through the plain
emailShell and carries no opt-out footer — there is no toggle in Settings
that silences a billing state change. It names the tier ("Your Dhaga Pro plan has
ended"), says the account is now on Free and that nothing has been deleted, and
links to /app/settings#billing. A plan that was granted rather than bought
gets one extra line pointing at DHAGA_OWNER_EMAIL — as well as the buy
button, never instead of it, because an expiring comp is exactly when someone
might pay and "ask your admin" as the only route leaves them waiting.
Idempotency is the subscriptions.lapse_notified_for column, not the daily
brief's per-local-day record. The two answer different questions: a digest asks
"has this person been emailed today?", while a lapse must be announced exactly
once and never again — a day-scoped guard would repeat it every night forever.
The column also re-arms itself when someone resubscribes (a later
current_period_end moves past the stored value), so no cleanup job exists.
There is consequently no hour gate: EMAIL_JOBS_HOURLY does not apply, since
holding a billing notice for someone's local 08:00 only delays the one action it
asks for. PLAN_LAPSE_NOTICE_BATCH_LIMIT (200) caps a single run and the query
is ordered oldest-lapse-first, so a backlog drains in the order people were cut
off.
Two statuses are never notified: past_due (Stripe and Razorpay run their
own dunning, and our mail would contradict a processor mail still asking for a
card, possibly while a retry is about to succeed) and incomplete (a checkout
whose first charge never settled — the plan never started, so it cannot have
ended).
Nothing to configure on a core-only self-host
Without packages/ee the billing gate's lapse sweep returns no rows and its mark
is a no-op — nobody can lapse on an instance that sells nothing — so the job
runs, finds nobody and reports { sent: 0, skipped: 0, failed: 0, reason: null }.
Without RESEND_API_KEY / RESEND_FROM_EMAIL it returns reason: "no_email"
and never touches the database. The endpoint reports it under a
planLapseNotices key. No new environment variable.
Optional providers, and what the UI does without them
Everything above is core and self-hostable, but four capabilities need something this repo deliberately doesn't ship: a third-party provider, or a browser feature. Dhaga is in beta, and the product rule is that a control which cannot do its job is greyed out with a "Coming soon" label and the reason — never rendered live to silently no-op. So an unconfigured provider is visible in the UI rather than a mystery. Every one of these is a runtime check, so the control lights itself up the moment the missing piece is in place, with no code change and no extra flag to flip.
| Capability | Needs | What happens without it |
|---|---|---|
| Job-change detection + news watchlist | ANTHROPIC_API_KEY (the default provider is Anthropic's own server-side web search), or FIRECRAWL_API_KEY, or another registered SEARCH_PROVIDER | hasSearch() is false, so /api/jobs/detect-signals returns { skipped: "no_search" } and writes no signals, and the contact page's "Watch for job changes & news" toggle is greyed out "Coming soon" instead of arming a scan that would never run. Since 2026-08-08 an instance that set ANTHROPIC_API_KEY for the other AI features has this on by default — the same runtime hasSearch() check un-greys the toggle with no code change and no extra flag. Unproven, though: no end-to-end sweep has been run against a live key, and searches cost $10/1k on top of tokens |
| Semantic (vector) search | DHAGA_EMBEDDINGS left unset (it defaults on), plus pgvector or a DHAGA_VECTOR_STORE | With DHAGA_EMBEDDINGS=off, embeddingsEnabled() is false: search runs on keywords + trigram only, and the search palette's Semantic similarity weight slider is greyed out "Coming soon" because it would be weighting an empty result set |
| SMS | TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + TWILIO_FROM_NUMBER | smsEnabled() is false and no code can be delivered. This one stays gated even with Twilio configured: there is no phone-number sign-in path anywhere in the app (email, magic link, passkey and social are the ways in), so Settings → Security → Phone number is "Coming soon" either way — only the wording changes, to name which half is missing |
| Voice notes (in-browser dictation) | WebGPU in the visitor's browser — not a server setting you can supply | Dhaga Voice runs the Moonshine speech model on the user's own device and has no CPU/WASM fallback, so on iOS Safari and most mobile browsers the mic button renders greyed out "Coming soon" up front rather than failing after a tap. Chrome or Edge on desktop works today. (Transcribing voice notes forwarded to a WhatsApp/Telegram bot is a separate, server-side gateway keyed off TRANSCRIPTION_PROVIDER, which ships no provider yet either) |
The copy for all four lives in one place,
apps/web/src/utils/constants/coming-soon.ts,
and the affordance is
apps/web/src/components/app/ComingSoonNotice.tsx.
Nothing in it links to pricing: "coming soon" is an admission that nobody can
have the feature yet, not an upsell.
Two things this table does not cover, because they degrade rather than
gate: ANTHROPIC_API_KEY (see the env table below — AI features fall back to
heuristic parsing or switch off), and RESEND_* (every recurring email becomes
a clean no-op).
Self-host env var reference
Everything below lives in apps/web/.env.local — see
apps/web/.env.example for the full annotated list.
None of the packages/ee/.env.example vars (DHAGA_HOSTED_MODE,
DHAGA_ADMIN_EMAILS, STRIPE_*) are needed for a plain self-host.
| Var | Required? | Notes |
|---|---|---|
BETTER_AUTH_SECRET | Yes | openssl rand -base64 32 |
BETTER_AUTH_URL | Yes | Your instance's base URL |
BETTER_AUTH_TRUSTED_ORIGINS | No | Extra allowed origins beyond BETTER_AUTH_URL (comma-separated or wildcard) — avoids INVALID_ORIGIN; Vercel preview URLs are auto-trusted |
NEXT_PUBLIC_SITE_URL | No | Canonical origin for sitemap/robots/OG/llms.txt; defaults to the production deployment origin when unset |
DATABASE_URL | Only on serverless (Vercel) | Otherwise defaults to embedded PGlite |
ANTHROPIC_API_KEY | No | AI features degrade to heuristic parsing / disabled without it |
RESEND_API_KEY, RESEND_FROM_EMAIL, DHAGA_OWNER_EMAIL | No | User-triggered event digests, plus the one scheduled email: the daily brief (follow-ups due, important dates, confirmations, reach-outs, backlog totals, the LinkedIn-export nudge) and the activation nudge that replaces it on an empty account. All degrade to a clean no-op when unset |
EMAIL_JOBS_HOURLY | No | Set true only if you drive /api/jobs/daily hourly — then the daily brief sends only on the run matching the recipient's local ~08:00 (their Settings time zone). Leave it unset on a once-a-day cron, including Vercel Hobby's: the single run always sends, and a per-user local-day record is what stops duplicates. See "The daily brief" above |
MORNING_REMINDER_HOURLY | No | Deprecated alias for EMAIL_JOBS_HOURLY, still honoured for one release. Despite the name it gates the whole daily brief, not just the follow-up sections — prefer the new name |
TELEGRAM_* | No | Owner-only bot capture |
DHAGA_WEBHOOK_URL | No | Outbound automation |
SEARCH_PROVIDER, FIRECRAWL_API_KEY | No | Job-change detection + news watchlist. Both optional: leave them unset and search runs on ANTHROPIC_API_KEY via Anthropic's own web-search tool, which is the default. Set FIRECRAWL_API_KEY and Firecrawl wins instead; SEARCH_PROVIDER overrides both. With no key at all the nightly sweep no-ops and the watch toggle is greyed out — see "Optional providers" above |
TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER | No | SMS delivery. Note phone-number sign-in is unbuilt regardless, so the Settings phone section stays gated either way — see "Optional providers" above |
CRON_SECRET | No | Required to enable /api/jobs/detect-signals — see above |
DHAGA_EMBEDDINGS | No | Defaults on. Set off to skip local semantic indexing — search then runs keyword + trigram only and the semantic weight slider is greyed out; see "Optional providers" above |
DHAGA_AI_MONTHLY_CAP, DHAGA_DATA_DIR | No | See .env.example for defaults |
See Deploying for the full deploy walkthrough (Vercel and
single-server options), including the additional packages/ee vars if you
do want the hosted-product features.
To add an LLM, search engine, embedding model, or external vector store, see Providers. Providers can be distributed as independent npm packages and registered from the server startup bootstrap.
AI credits
The Credits tab in Settings — how many AI credits you have left this month, what each action costs, and exactly where the ones you spent went.
Deploying
The three ways Dhaga deploys — Vercel, a single persistent server, and Docker — plus the Postgres role and pooling rules that matter in hosted mode.