Skip to content

Latest commit

 

History

History
281 lines (224 loc) · 13.6 KB

operating.md

File metadata and controls

281 lines (224 loc) · 13.6 KB

hototel server — operating

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.

Architecture in one paragraph

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.

Running it

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 8377

Or 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.

The read API

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.

Tokens and scopes

A per-user token carries a set of scopes:

  • ingest — report usage
  • read — 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.

Email verification

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.com

Links live 24 hours; resends are rate limited per IP.

Insights (the chat page)

/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/chat takes 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/takeaways returns the cached 30-day analysis for the org, or status: pending while one generates in a background thread; the page polls. ?fresh=1 starts 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.

Platform operators (system admins)

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.dev

CLI reference

Everything 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 theirs

There 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.

Pricing happens on the client

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.

Backups

hotdata forks are cheap deep copies:

hotdata databases fork <dbid> --name <label>

Snapshot an org database — or the system database — before a risky change.

Tests

for t in server/tests/test_*.py; do python3 "$t"; done

Standard 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.