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 /api/jobs/daily endpoint, whose cron in
apps/web/vercel.json is "17 6 * * *" and stays that way. Since 2026-09-01 a
second endpoint, /api/jobs/tick, runs the same sweep for operators who can
schedule more than one cron — see "When it goes out" below. A self-hosted
instance has no Vercel cron at all and drives both itself. The two are safe together;
they cannot produce a second brief. (A third endpoint, /api/jobs/deliver, runs
once a minute, but it does not touch the brief at all — it only empties the nudge
queue. Same section below.) That endpoint was briefly called
/api/jobs/digest; it is /api/jobs/tick now because it also drives the
pre-event nudge sweep, and a name advertising only the brief invited exactly the
mistake that loses nudges silently.
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/.
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 a settings record per user per channel:
daily_brief_last_local_day for the email (covering the brief and the nudge
together, unchanged since 2026-08-09) and daily_brief_chat_last_local_day for
the linked chat. Two records rather than one is deliberate — a shared record
forces an all-or-nothing choice the moment a second channel exists, where marking
it after the first success lets one WhatsApp outage cost the user tomorrow's
email, and marking it only when both succeed re-sends an email that already
arrived. Each channel now retries exactly its own failure. The email key is
unchanged, so nobody receives a second brief on the day this ships. 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).
When it goes out, and down which channels
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.
Since 2026-09-01 each user also picks the local time it should arrive —
Settings → Suggestions, "When to send your daily summary", stored as
digestHour / digestMinute in the existing schedule_prefs blob, default
09:00, minutes restricted to :00 / :15 / :30 / :45. The minute is not
decoration: India is UTC+05:30 and Nepal +05:45, so "09:00 for the user" is not
expressible as a whole UTC hour at all. The gate asks "has their chosen local
time passed today?" (>=, never ==), which combined with the per-local-day
record above means the first run at or after their time delivers and every later
run that day is a no-op — the same code path whether the job is driven once a day
or every fifteen minutes. There is no environment variable for any of this;
the retired EMAIL_JOBS_HOURLY / MORNING_REMINDER_HOURLY flags are deleted and
ignored, so delete them from your env if you set them.
Whether the gate is enforced is decided by the endpoint driving the sweep, not by config:
| Endpoint | Enforces the user's send time | Meant to run |
|---|---|---|
/api/jobs/daily | No | Once a day — "17 6 * * *", unchanged |
/api/jobs/tick | Yes | Every ~15 minutes |
/api/jobs/deliver | — it never touches the brief | Every minute |
/api/jobs/daily deliberately ignores the preference because it fires at one
fixed UTC minute: "17 6 * * *" is 07:17 in Europe/London and 23:17 the previous
day in Los Angeles, both before a default 09:00 preference — so enforcing the
gate there would not delay those briefs, it would end them. That endpoint
therefore behaves exactly as it always has and nobody is dropped.
/api/jobs/tick is the endpoint on which the preference becomes real. Two jobs
run on it — the brief, and the pre-event nudge sweep (lib/jobs/nudges/),
which sends "your meeting starts in 10 minutes" and "your follow-up is due in 10
minutes" down a user's linked WhatsApp or Telegram chat. The other ten jobs in
the nightly aggregator stay there: those are once-a-day work and running them
every fifteen minutes would be wasteful at best. Drive it with the same bearer
header the daily endpoint uses (GET and POST both work; with no CRON_SECRET set
it always 401s) — and drive /api/jobs/deliver every minute alongside it:
* * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
https://your-domain/api/jobs/deliver
*/15 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" \
https://your-domain/api/jobs/tickTwo sub-daily scheduler lines, not one, and the split is about cost. The nudge sweep
has two halves. Materialising — which fills the queue — is O(tenants) and
expensive per tenant: a settings read, a follow-up read and an OAuth round-trip
to Google or Microsoft for every account on the instance, every run. At a
one-minute cadence that is 1,440 passes a day, tens of thousands of calendar API
calls, to discover a window that barely moved; it stays quarter-hourly, on
/api/jobs/tick. Delivering is the opposite shape: one indexed statement
across every tenant at once (the partial index on scheduled_sends (fire_at) WHERE sent_at IS NULL), and then work only for the handful of users owed
something in this minute. Nothing due costs a single index probe. That asymmetry
is what the queue table exists for, and it is why the cheap half gets its own
per-minute route.
/api/jobs/tick still delivers too, which looks like duplication and is not.
The two runs have to be safe together in any case — the minute cron overlaps
itself whenever a run is slow — so once that is true, leaving delivery on the
tick costs nothing and buys something specific: an operator who schedules only
the quarter-hourly line still gets their nudges, up to ~14 minutes late, instead
of silently none at all.
The safety is in the database, not in a convention. claimDueScheduledSends
(lib/repo/scheduled-sends/claim.ts) is an UPDATE … RETURNING that stamps a
claimed_at column in the same statement that reads the due rows, with
FOR UPDATE SKIP LOCKED on the bounded inner select, so a concurrent run sees
none of them. claimed_at is the lock; sent_at is the receipt — stamped
only after a send succeeds (or is dropped as "nothing to say"), so a failed send
stays retryable rather than being marked delivered. A failed send releases its
claim immediately; a run that is killed releases nothing, so the claim expires
after NUDGE_CLAIM_LEASE_MINUTES (5) and the row is picked up again.
What that buys the user: QUICK_REMINDER_MIN_MINUTES is 1, so "remind me in 2
minutes" is something the bot accepts. Before the per-minute route the queue took
it and the sweep delivered on the next quarter-hour — up to ~13–14 minutes late,
with nothing failing and nothing logged to say so. Pre-event meeting nudges
drifted the same way. Both are now on time to within a minute.
NUDGE_GRACE_MINUTES stays 30 deliberately: it is sized against the reader,
not the cadence. A nudge 40 minutes late is a confusing message about a meeting
that already started, and the window still has to cover an outage.
It is /tick rather than /digest on purpose. An operator adding one cron line
and reading "digest" would reasonably conclude it was a second copy of the email
/api/jobs/daily already sends, skip it, and silently lose every nudge on the
instance — nothing fails, nothing is logged, and no setting admits it. /tick
says when it runs rather than what it happens to run today.
Nothing sub-daily happens until you drive /api/jobs/tick and /api/jobs/deliver
A self-hosted instance has no Vercel cron and must schedule both routes
itself — any system crontab or container timer will do, with the two curl
lines above. On Vercel the entries live in apps/web/vercel.json's crons
array: the hosted deployment added /api/jobs/tick on a quarter-hourly schedule
on 2026-09-04, and /api/jobs/deliver on * * * * * alongside it. On Vercel
Pro the minimum interval is one minute and crons fire with per-minute
accuracy; on Vercel Hobby you get neither entry, because Hobby caps crons at
once per day and a more-frequent one can break the deploy — the same reason
/api/jobs/messaging/flush is still unscheduled. Hobby also only triggers the
one cron it allows within ±59 minutes of its schedule, which is worth knowing
even for the daily brief. Note which paths take the frequent schedules: they are
/api/jobs/tick and /api/jobs/deliver. /api/jobs/daily must stay on
"17 6 * * *" — it drives eleven once-a-day jobs, and speeding it up would
re-send transactional email.
Until something drives the tick route: digestHour/digestMinute are a stored
preference with no effect — the brief keeps arriving on the nightly run, so
nobody loses mail — and no pre-event nudge is ever sent, because nothing else
fills the queue. Users can ask the bot to remind them and nothing will arrive.
Late nudges are dropped rather than caught up (a 30-minute
grace), so the interval between ticks decides whether the feature works at all.
Schedule the tick but not /api/jobs/deliver and nudges still go out, just late
— up to ~14 minutes, because the tick delivers as well as materialises. That is
the deliberate fallback, not the intended setup: a one-minute reminder is only
honest to the minute when both lines are running.
Driving the route makes the sweep fire; it does not make delivery proven. No Meta WhatsApp template has been approved and none of this has been checked against a real WhatsApp or Telegram message, so treat email as the channel to trust.
Each user also chooses the channels it arrives on (digestChannels, same
Settings card): the email, and/or the WhatsApp/Telegram chat they linked to the
bot. Both default on, but a channel with no linked chat is skipped whatever
the preference says, so nothing starts messaging anyone who never linked one. The
chat rendering is a different rendering of the same brief, not a second brief: it
lists at most DIGEST_CHAT_MAX_ITEMS (5) names per section, carries no note
bodies, action text or date labels — names and counts only, because that content
leaves your infrastructure for Meta's or Telegram's — and ends with a link into
the app.
A scheduled WhatsApp message needs an approved template
Everything Dhaga sent over WhatsApp before this was a reply inside a webhook, which Meta always allows. A message the bot starts is different: WhatsApp rejects free-form text more than 24 hours after the recipient's last inbound message, and a 09:00 digest is by definition outside that window for most people. So the digest needs a Utility message template that Meta has approved.
The deployment now submits both templates itself. A job on the daily cron
(/api/jobs/daily) resolves the WhatsApp Business Account behind
WHATSAPP_PHONE_NUMBER_ID, lists what is already there, and creates only what is
missing — dhaga_daily_brief and dhaga_event_nudge, category Utility, wording
in apps/web/src/utils/constants/messaging/templates.ts — then records each
template's status. It exists because WHATSAPP_ACCESS_TOKEN is a write-only
("Sensitive") deployment value that nothing outside the running server can read,
so the server is the only thing that can call Meta with it. Approval takes hours
to days, and a template is used for sending only once Meta marks it
APPROVED; until then sends degrade to free-form text exactly as they do with
nothing configured.
It never resubmits a rejected template, and that is deliberate. A rejection
means the wording has to change; reposting the same body on a schedule is what
gets a WhatsApp Business Account flagged and then restricted. So a rejection is
recorded and logged, and a human owns the fix: write new copy, submit it in
WhatsApp Manager → Account tools → Message templates under a new name, and
set WHATSAPP_TEMPLATE_DIGEST (or WHATSAPP_TEMPLATE_NUDGE) to that name.
Those variables are now the override rather than the only way in — set one
and it wins over anything the deployment provisioned, at send time, so recovery
costs an env edit and not a redeploy. Set WHATSAPP_TEMPLATE_LANG too if your
template was approved as anything but en; it is used for both submitting and
sending, and Meta matches the tag exactly.
The job needs whatsapp_business_management on the access token. A token
minted only for whatsapp_business_messaging cannot resolve the business
account; the job logs Meta's error code, skips, and leaves the deployment
sending exactly as before — create the two templates by hand in WhatsApp Manager
in that case. Unset and unprovisioned is safe and not a broken deployment:
inbound capture, replies and the email brief are unaffected, and the chat digest
degrades to free-form text — landing for anyone who messaged the bot in the last
24h and dropped by Meta for everyone else. Telegram has no equivalent rule and
needs no template. Note that no template has yet been approved for Dhaga's own
deployment, and no digest has been delivered to a real chat.
The pre-event nudge template must carry no placeholders
The second template, dhaga_event_nudge, is the one used for "your meeting
starts in 10 minutes" outside the same 24-hour window. Unlike the digest
template, its body has no {{n}} parameters at all — the sweep sends
params: [] deliberately. The only things a nudge could name are a meeting title
or a follow-up's action, which are third-party contact data, and template
parameters are submitted to Meta and rendered in their systems. So the templated
nudge says only that something is coming up and links into the app, with the
deployment's own app URL baked into the body rather than passed at send time.
The provisioning job submits exactly the string
nudgeTemplateFallbackText produces
(apps/web/src/utils/constants/messaging/nudge-replies.ts), with SITE_URL
already substituted; submit the same wording by hand under
WHATSAPP_TEMPLATE_NUDGE if you are overriding it. Unset and unprovisioned is
safe: anyone who messaged the bot inside 24 hours still gets the full free-form
nudge naming the meeting; only those outside the window lose the ping.
No email provider means no chat brief either
The job returns early when Resend is not configured, so an install with no email provider sends no chat brief either. That is a real gap and it is stated rather than papered over — closing it means enumerating tenants for a chat-only run, which buys nothing until someone deploys without Resend.
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 send-time gate: the brief's per-user
digestHour/digestMinute does not apply here, since holding a billing notice
for someone's chosen local time only delays the one action it asks for. It also
never rides the chat channel — it goes by email or not at all.
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 |
| (no variable) | — | When the brief goes out is no longer configured by env. Each user picks the local time and the channels in Settings → Suggestions, and whether that time is honoured depends on which endpoint drives the job (/api/jobs/tick does, /api/jobs/daily does not). The old EMAIL_JOBS_HOURLY / MORNING_REMINDER_HOURLY flags are deleted and ignored — delete them from your env. See "The daily brief" above |
WHATSAPP_TEMPLATE_DIGEST, WHATSAPP_TEMPLATE_LANG | No | OVERRIDE for the Meta-approved utility template used for SCHEDULED WhatsApp sends (the daily brief to a linked chat), which free-form text cannot carry outside Meta's 24-hour window. The daily cron now provisions this template itself as dhaga_daily_brief and sends under it once Meta marks it APPROVED; set the name here only to point at a template you submitted yourself — typically after a rejection, since the job never resubmits one. WHATSAPP_TEMPLATE_LANG (default en) is used for BOTH submitting and sending, and Meta matches it exactly. Unset with nothing approved: replies are unaffected and the chat brief degrades to free-form text, landing only for users who wrote to the bot in the last 24h. Telegram needs neither |
WHATSAPP_TEMPLATE_NUDGE | No | The same override for PRE-EVENT nudges ("your meeting starts in 10 minutes"), auto-provisioned as dhaga_event_nudge under the same 24-hour window and the same WHATSAPP_TEMPLATE_LANG. Its body carries no {{n}} placeholders — the sweep sends params: [] on purpose, because the only things a nudge could name are a meeting title or a follow-up's action, i.e. third-party contact data, and template parameters are submitted to Meta and rendered in their systems; the deployment bakes its own app URL into the body instead. Wording submitted: nudgeTemplateFallbackText in utils/constants/messaging/nudge-replies.ts. Unset with nothing approved: anyone who messaged the bot inside 24h still gets the full free-form nudge, only those outside the window lose the ping. Telegram needs neither |
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 | Bearer secret for the /api/jobs/* cron routes (detect-signals, daily, tick, deliver, messaging/flush, contact-sync) — 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.