Everything the README leaves out: running the server, the platform-operator powers, the CLI, and the parts of the design worth knowing before changing anything.
Pure Python standard library — ThreadingHTTPServer plus a hand-rolled router;
the only dependencies are the hotdata SDK and duckdb. There is no local
database. All state lives in hotdata: one system
database for accounts (orgs, users, sessions, invites, tokens) and one dedicated
database per organization for usage, so each org's data is physically
isolated rather than filtered out of a shared table.
uv venv && uv pip install hotdata 'duckdb>=1.4.2'
export HOTDATA_API_KEY=<workspace key> # or ~/.hotdata/hotdata.json
export HOTUSAGE_INGEST_TOKEN=<any secret> # unset = dev mode (accepts all)
.venv/bin/python server/server.py --host 0.0.0.0 --port 8377Or use the Dockerfile. Bind 0.0.0.0 only behind a VPN or reverse proxy: with
HOTUSAGE_INGEST_TOKEN unset the server now refuses to start on a non-loopback
address at all, since dev mode accepts anonymous writes (--allow-open-ingest
overrides that for a deliberate setup). The image also sets
HOTUSAGE_TRUSTED_PROXY=1, which is what lets the rate limiter believe
X-Forwarded-For; run directly, that header is whatever the caller typed and is
ignored. First boot seeds an initial admin user and prints its generated
password once.
Signing up is self-serve: /register creates the account, /setup asks for an
organization name and provisions its database — that person becomes the org's
first member and admin. Anyone invited to an existing org skips /setup.
Registration and invite endpoints are rate limited.
/healthz answers liveness checks; /healthz?deep=1 also reports whether
hotdata is reachable, classified rather than echoed, so a deployment can be
diagnosed without shell or log access.
Besides the dashboard the server exposes a small read-only API, which is what
the client's summary/chart/users commands call: GET /api/data,
/api/session/<id>, /api/orgs, /api/status.
Two allow-lists back that, both on Handler: READ_TOKEN_PATHS matches
/api/data, /api/orgs and /api/status exactly, and READ_TOKEN_PREFIXES
holds the single prefix /api/session/, which has to match by prefix because
the session id is in the path.
What matters is that it is an allow-list at all rather than "anything under
/api/": every other route either mutates something or hands back a credential
(invite links, machine tokens), and a token minted for reporting must reach
neither. Bearer credentials are never sent by a browser on its own, so these
routes need no CSRF defence beyond staying read-only.
A per-user token carries a set of scopes:
ingest— report usageread— query the organization through the API
A token with no scope row predates scopes and means ingest alone, so every
already-deployed client keeps working untouched. The client asks for both at
once: it runs the background sync and the agent skill on one machine, and two
approvals for one laptop would be ceremony rather than security. A server-side
script that should report but never read (or the reverse) can still be issued
one scope. listtokens shows what each holds.
Both failure modes are closed rather than open. A stored scope this build cannot parse grants nothing — falling back to the default would silently promote a read-only credential into one that can write usage rows. A device asking for an unknown scope fails the approval outright, rather than minting a token for different powers than the page just described to the person clicking Approve.
Per-user tokens identify their owner: ingest signed in as someone reports as
that account whatever the payload claims. HOTUSAGE_INGEST_TOKEN being set is
what makes a token required at all; the value itself is not accepted as a
credential unless HOTUSAGE_ALLOW_SHARED_INGEST=1, because it only admits —
anyone holding it could report as any registered colleague in any organization.
Leave that off in production.
A registered address is a claim until a link mailed to it is opened while
signed in as that account. Until then the account sees nothing (every page
bounces to /verify, every /api/ call and ingest is a 403), and anyone else
may register the same address over it — new password, old sessions revoked —
so an address cannot be squatted ahead of its owner. This is also what makes a
domain-restricted team link mean something: without it, anyone holding the link
could type any @yourdomain address and read the organization.
The link only counts together with the session on purpose. It lands in the mailbox whoever registered the address; if the click alone verified, a stranger could register your address, wait for you to click the mail, and end up with a verified account under your name.
Mail goes out through a transactional mail API key and a From address on a domain that provider has verified for you. Both are required or verification is off (the server says so at boot, and then behaves exactly as before this feature existed):
export RESEND_API_KEY=<mail API key>
export HOTUSAGE_EMAIL_FROM="hototel <no-reply@mail.example.com>"Accounts created before AuthStore.VERIFICATION_SINCE (the moment this
shipped) are trusted as they were. A person whose mail never arrives can be
vouched for by an operator:
server.py markverified jane@acme.comLinks live 24 hours; resends are rate limited per IP.
/insights is a LangChain agent built with
hotdata-langchain, in
server/insights.py. It gets two tools -- run SQL, describe tables -- both
built with database_id= set to the viewer's org database, so the scope is
fixed before the model sees a prompt and the SQL tool's only argument is the
query. The instant-database tools (list, create, load) are not registered; the
agent cannot write or reach another organization.
It needs a model. The default deployment is hototel's own model on Modal:
modal/hototel_llm.py serves Qwen3-30B-A3B-Instruct-2507 behind vLLM's
OpenAI-compatible API on one H100, scaling to zero after ten idle minutes, so
usage rows never leave the company's own accounts. Deploy it once and hand the
server its URL and key:
modal secret create hototel-llm HOTOTEL_LLM_API_KEY=<random string>
modal deploy modal/hototel_llm.py # prints the endpoint URL
export HOTOTEL_INSIGHTS_BASE_URL=https://<workspace>--hototel-server.<region>.modal.direct/v1
export HOTOTEL_INSIGHTS_API_KEY=<the same random string>modal run modal/hototel_llm.py waits for the server and sends one test
request with a tool attached, which is the quickest way to confirm tool
calling works after a model change. The very first ever boot also downloads
the weights (~60GB) into the hototel-hf-cache volume.
Cold starts. The container stops ten minutes after the last request, and
Modal's endpoint answers 503 no upstreams available while a new one boots
(about three minutes with the weights cached, and much longer when the volume
reads slowly -- the class allows thirty and loads the shards with vLLM's
parallel prefetch); that first refused request is what starts it. The server treats that 503 as "starting", not as a failure:
a successful sign-in starts the model booting in the background (people are
around; it still stops ten minutes after the last request), opening the
Insights page probes /health and wakes it too, /api/insights/ status reports modelState (ready/starting), a chat during the boot is a
503 with starting: true that the page waits out (polling status every ten
seconds, up to twenty minutes) before sending the question again, and the
takeaways thread simply waits for ready before running. The model is
deliberately not kept on around the clock: it runs only after someone signs
in, for as long as they keep using it.
The alternative is the Anthropic API: leave HOTOTEL_INSIGHTS_BASE_URL
unset and set ANTHROPIC_API_KEY; the model defaults to claude-opus-5-5.
Either way HOTOTEL_INSIGHTS_MODEL overrides the model name (on Modal the
server answers to the alias hototel) and HOTOTEL_INSIGHTS=0 forces the
page off. /api/insights/status says which provider is active or what is
missing. The packages (hotdata-langchain[agents], langchain-openai,
langchain-anthropic) are in the Docker image.
Two endpoints, both cookie-session only -- deliberately not in
READ_TOKEN_PATHS, since a read token was approved to pull numbers, not to
start paid model runs:
POST /api/insights/chattakes the whole transcript (messages, user and assistant turns only, at most 24 of them) and returns the answer plus the queries the agent ran. The server keeps no conversation state.GET /api/insights/takeawaysreturns the cached 30-day analysis for the org, orstatus: pendingwhile one generates in a background thread; the page polls.?fresh=1starts a new run. A finished analysis is written to the system database (insights_takeaways, one row per org database), so a restart, a deploy or a second App Runner instance shows the last published takeaways on first view and only regenerates once they are six hours old.
Both count against one per-IP limit (Handler.CHAT_RATE_LIMIT, 60 runs an
hour), and at most four agent runs execute at once per process so a burst of
questions cannot take the thread pool away from /ingest. A chat holds its
HTTP request open for the run, typically 20-90 seconds.
On Modal the cost is GPU time while a container is up (an H100 is a few dollars an hour, and it stops ten minutes after the last request); on the Anthropic API a chat run is a few dollars of tokens at most and a takeaways run somewhat more. Nothing here prices anything or writes to hotdata.
Creating or deleting an organization provisions or strands a hotdata database,
so it is a platform power, distinct from org admin. The system admin gets a
System item in the top nav (drawn for nobody else; /api/orgs says who)
leading to /system, the home for platform-level views: totals across the
server and All organizations -- every org with member counts, a create form
(optionally with an owner email, which mints an invite whose acceptor becomes
that org's admin), and delete for empty orgs. The page and its read,
/api/system/state, refuse everyone but a system admin (redirect and 403
respectively); the Organization page is about one team only.
.venv/bin/python server/server.py makesysadmin eddie@hotdata.devEverything on the Organization page has a CLI equivalent, for scripted setup and recovery. All commands run against the system database and take effect within the server's ~60s caches.
# organizations
server.py listorgs # slug, user count, database id
server.py addorg acme --name "Acme Inc" # provisions the org's database
server.py delorg acme # refuses while users remain; keeps the database
server.py delorg acme --delete-database # also destroys its usage data
# users
server.py listusers --org all # everyone (or --org <slug>)
server.py adduser jane@acme.com --org acme # prints initial password
server.py resetpw jane@acme.com # prints new password
server.py deluser jane@acme.com # + revokes their logins and machines
server.py makeadmin jane@acme.com # org admin (unadmin to demote)
server.py makesysadmin jane@acme.com # platform operator (unsysadmin to revoke)
server.py markverified jane@acme.com # vouch for a mailbox whose link never arrived
# invites and signed-in machines
server.py listinvites # both kinds, with uses + days left
server.py revokeinvite <token>
server.py listtokens # who is signed in, from where, scope, last used
server.py revoketoken <token> # or: --user <email> for all of theirsThere is no self-serve password reset — resetpw is the recovery path. An org
that loses its last admin is recovered with makeadmin, never by whoever
happens to join next.
The server never prices anything; it stores the cost_* columns the ingest
payload already carries. To reprice a provider, edit rates_claude /
rates_claude_fast / rates_openai in hototel-client's src/core.rs.
This repo's core.py used to carry a Python copy of those tables, for a
collector that no longer lives here. It had drifted from the live ones and was
not what the dashboard showed, so it is gone: core.py is now the hotdata ids,
the catalog names and the API key lookup, and nothing else.
For actual spend rather than list-price equivalents: the spend report at
claude.ai/admin-settings/usage exports per-user, per-model cost as a daily
CSV, and Claude Code's OpenTelemetry export emits claude_code.cost.usage in
real dollars per user. Neither is something this server can derive.
hotdata forks are cheap deep copies:
hotdata databases fork <dbid> --name <label>Snapshot an org database — or the system database — before a risky change.
for t in server/tests/test_*.py; do python3 "$t"; doneStandard library only, no hotdata account needed: AuthStore runs against a
stateful fake system database, and test_http_read_token.py drives the real
handler over a loopback socket to assert a read token cannot ingest and an
ingest token cannot read. CI runs all of them before the image is built.