20 KiB
Getting Started
This guide takes you from an empty machine to a locally running arah² — the Telegram bot (arah2bot), its Cloudflare Worker, and the Mini App webview — in five steps.
For what each piece does, see ARCHITECTURE.md. For the full list of environment variables, config files, and per-package scripts, see CONFIGURATION.md. For a package overview, see the root README.md. For what arah² honestly does not do yet, see KNOWN-LIMITATIONS.md.
Prerequisites
| Requirement | Why | Notes |
|---|---|---|
| Node.js + npm | Every build, dev, test, and deploy script in the repo is an npm script; all five workspaces are ESM ("type": "module") |
No engines field, .nvmrc, or .node-version exists anywhere in the repo. Use a current Node LTS — the repo's development and all four test runners run on Node v24, and the search/routing corpus tooling relies on Node 24's native TypeScript type-stripping. |
| A Telegram account | You chat with arah2bot and receive the Mini App webview | — |
| A bot token from @BotFather | BOT_TOKEN is required for the Worker and every registration script |
Also used as the HMAC key that validates Mini App initData |
| Internet access | wrangler dev, the Telegram Bot API, and OpenFreeMap tile fetches all need the network |
— |
A Cloudflare account (deploy and npm run bot:commands only) |
wrangler deploy, the Workers KV namespace, and the R2 road-graph bucket live there |
Wrangler prompts for browser-based auth on first use |
Only required for specific commands, not for a normal first run:
- Python 3 —
npm run galleryserves the design gallery viapython3 -m http.server 8123(theme/package.json, scriptbasemap:preview). - A Playwright-managed Chromium —
npm run captureandnpm run web:captureboth call barechromium.launch()(theme/scripts/screenshot.mjs:103,web/scripts/capture.mjs:201), so they neednpx playwright install chromiumfirst. No system Chrome is used. sharp(inrouting/'s devDependencies) — only thenpm run glyphs -w routingglyph-builder needs it; nothing else in a first run imports it. Install is still onenpm installfrom the root.
There is no database to install, no Docker step, and no .env file to author — local secrets
live only in bot/.dev.vars (created in step 3). See
CONFIGURATION.md for what each variable does.
Installation
-
Clone the repository.
git clone http://gitmirah:3000/c-fu/arah2.git -
Enter the project directory. The clone is named
arah2after the repo slug; the workspace root is whatpackage.jsonlives in.cd arah2 -
Install all five workspaces from the root. npm workspaces hoist dependencies into a single root
node_modules, so one install coverstheme,bot,web,search, androuting— including@araharah/theme, whichwebconsumes by relative path and never needs to be published.npm install
First run
flowchart TD
CLONE["git clone"] --> INSTALL["npm install"]
INSTALL --> VARS["cp bot/.dev.vars.example bot/.dev.vars"]
VARS --> FILL["fill BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL"]
FILL --> BUILD["npm run build"]
BUILD --> DEV["npm run dev — wrangler dev on :8787"]
DEV --> HOOK["point the webhook at the tunnel"]
HOOK --> TALK["message arah2bot in Telegram"]
style CLONE fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style INSTALL fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style VARS fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style FILL fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style BUILD fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style DEV fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
style HOOK fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
style TALK fill:#736B1E,stroke:#1A1A1A,stroke-width:2px,color:#F7F0E3
1. Create your local secrets file
bot/.dev.vars is gitignored (bot/.gitignore) and must never be committed. Copy the template:
cp bot/.dev.vars.example bot/.dev.vars
Then fill in all three variables:
# Copy to .dev.vars and fill for local wrangler dev (D-15). NEVER commit .dev.vars.
BOT_TOKEN=
WEBHOOK_SECRET=
# Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set.
WEBHOOK_URL=
BOT_TOKEN— from @BotFather. Required.WEBHOOK_SECRET— any high-entropy random string you choose. Telegram echoes it in theX-Telegram-Bot-Api-Secret-Tokenheader, and the Worker constant-time-compares it before touching any update.WEBHOOK_URL— the public origin Telegram will POST updates to. Locally this is a tunnel (see step 5);set-webhook.mjsappends/telegram/webhookautomatically if you give it a bare origin.
Every script in bot/scripts/ resolves each variable as process.env.<NAME> ?? devVars.<NAME>,
so your shell environment wins over the file and the file is the fallback.
2. Build
npm run build
This runs build -w theme then build -w web. The order matters: theme/dist/rams.css is an
input to the web build, so the theme must compile first. Neither theme/dist nor web/dist is
committed (root .gitignore excludes dist/), so this step is required on every fresh clone and
before every dev/deploy.
3. Run the Worker locally
npm run dev
Root dev is npm run build && npm run dev -w bot, which is wrangler dev --port 8787 --local.
Wrangler reads bot/.dev.vars, emulates the chat_sessions KV namespace under bot/.wrangler/,
and serves three things on one origin, port 8787:
| Path | Served by |
|---|---|
/telegram/webhook |
Worker fetch handler (run_worker_first) |
/api/* |
Worker fetch handler (run_worker_first) |
| everything else | web/dist static assets via the ASSETS binding |
Because it is the single origin, you can smoke-test the API without Telegram at all:
# Unknown paths are 404 — the Worker's fetch handler routes exactly six paths.
curl -i http://localhost:8787/api/location
# → 401 (no bearer token)
# An unauthenticated webhook POST fails closed when WEBHOOK_SECRET is unset,
# otherwise it 401s on the secret-token mismatch.
curl -i -X POST http://localhost:8787/telegram/webhook
The turn-card cron can be fired by hand against the local server — wrangler's scheduled-event endpoint, no tunnel needed:
curl "http://localhost:8787/cdn-cgi/local/scheduled"
4. Expose the webhook
wrangler dev binds to localhost only, so Telegram cannot reach it. Expose port 8787 with a
tunnel, then run the one-time registration script, which reads the same bot/.dev.vars:
# Terminal 1 — the dev server (from step 3)
npm run dev
# Terminal 2 — a tunnel that publishes port 8787 at a public HTTPS origin
cloudflared tunnel --url http://localhost:8787
Take the https://…trycloudflare.com origin it prints, put it in WEBHOOK_URL in
bot/.dev.vars, and register it:
npm run deploy --workspace bot # or, without the deploy half: see below
bot's own deploy script is wrangler deploy && node scripts/set-webhook.mjs, which is the
wrong tool for local work. For a local webhook registration, run the script directly — it only
calls Telegram's setWebhook; it does not deploy anything:
node bot/scripts/set-webhook.mjs
Expected output on success:
setWebhook ok:true — updates will be delivered to the configured URL with the secret-token header.
If WEBHOOK_URL has no path, the script prints a normalization line first:
WEBHOOK_URL had no path — normalized to .../telegram/webhook
Register the persistent command menu (English + Bahasa Melayu) once, so /help, /privacy,
and /cari appear in the Telegram UI:
npm run bot:commands
register-commands.mjs registers exactly ["help", "privacy", "cari"] in four private-chat
scopes — default (en), default+ms, all_private_chats (en), all_private_chats+ms — and clears the
group scopes, because Telegram resolves a chat's menu from the narrowest scope that has any
commands and a stale set shadows the default one. /start is a working handler that does not
appear in the menu (type it manually); /search is /cari's registered twin handler. The
descriptions are derived from the helpMenu lines in bot/src/i18n/strings.ts, the same text
/help replies with, so the menu and the reply cannot drift.
5. Talk to arah2bot
Open Telegram, find arah2bot, and send /start. The Worker receives the update at
/telegram/webhook, verifies the secret-token header, and replies. Supported commands:
| Command | Alias | Behaviour |
|---|---|---|
/start |
/mula |
One-sentence introduction to arah² and its privacy posture |
/help |
— | Lists the commands available and points at the known-limits record |
/privacy |
/privasi |
States exactly what is stored (~15 min) and what is never stored |
/cari |
/search |
Searches the KL gazetteer (/cari KLCC) and replies with one venue card carrying the OPEN IN ARAHARAH Mini App button plus 2–4 alternates |
The Mini App opens from the venue card's OPEN button, or from the bot's menu button. The script
that repoints the menu button at the deployed origin (type: web_app) has no npm alias —
run it directly:
node bot/scripts/set-menu-button.mjs
Working on the Mini App alone
For map and UI work that doesn't need the bot, run Vite instead of the Worker. It serves on port
5173 with strictPort: true, so a port clash fails loudly rather than drifting:
npm run web:dev
There is no Worker behind it, so web/src/main.ts falls back to the mock Telegram WebApp via
import.meta.env.DEV and the /api/* calls stay stubbed. This is the fastest map/UI loop; run
npm run dev when you need the real session handshake.
Verify the setup
Run the four test suites and the four typechecks — the runners are hand-rolled and
zero-dependency (node <workspace>/test/runner.mjs each), so no test framework needs installing:
npm run bot:test # 10 suites in bot/test/*.test.mjs
npm run web:test # 14 suites in web/test/*.test.mjs
npm run search:test # 8 suites in search/test/*.test.mjs
npm run routing:test # 9 suites in routing/test/*.test.mjs
npm run bot:typecheck # tsc --noEmit
npm run web:typecheck # tsc --noEmit
npm run search:typecheck # tsc --noEmit
npm run routing:typecheck # tsc --noEmit
Each runner prints PASS <name> / FAIL <name> per case and exits 0 only when every case
passed. If a suite fails to load, it is recorded as one failing case rather than crashing the run.
(The search and routing counts include their golden-corpus suites, which measure the committed
datasets offline — no network.)
The bot suites are cari, commands, guidance-cron, guidance-envelope, initdata,
location-pin, privacy, route-privacy, sessions, and webhook; the web suites include
build-artifact, copy-gate, destination-preload, failure-state-matrix, fullscreen,
guidance-eta, guidance-replay, guidance-surface, homescreen, location-chain,
no-storage, route-surface, session, and theme. Four are worth knowing about as you get
started:
no-storagepins that nothing inweb/srcuseslocalStorage,sessionStorage, cookies, IndexedDB, or the Cache API.privacygreps that everychat_sessionswrite carries an explicitexpirationTtl.route-privacypins the same category-only, claim-derived access control on/api/route.guidance-*(bot and web) pin the envelope lifecycle, the cron card projection, and the Mini App's replay/ETA surfaces.
Optionally, prove the repo-wide privacy and budget invariants — these are the maintainer-time audits a fork must re-run before its first driver:
npm run privacy-audit # storage, KV-TTL, category-only logs, host allowlist, bundle telemetry
npm run budget-audit # Worker CPU, bundle sizes, KV write classes, golden corpora
And prove the design tokens carry no off-token values:
npm run audit
theme/scripts/audit.mjs scans the rendered CSS and exits 1 on any colour, spacing,
typography, radius, shadow, or blur value not derived from theme/src/tokens.css; it prints
AUDIT CLEAN — … and exits 0 when the sheet is pure.
Common setup issues
npm run dev starts but every request returns the Mini App, or nothing.
web/dist may be stale or absent. run_worker_first covers only /api/* and /telegram/*
(a glob array, not a boolean), so everything else falls through to the assets binding. Run
npm run build first, or just use npm run dev — it already builds.
[webhook] rejected: misconfigured → 500 Server misconfigured.
WEBHOOK_SECRET is missing or empty in bot/.dev.vars. The fail-closed guard sits at the top of
the /telegram/webhook branch in bot/src/webhook.ts and it fails closed deliberately: grammY's
compareSecretToken silently accepts every request when the secret is undefined, so an
unarmed secret must never reach it. Generate a new random string and put it in WEBHOOK_SECRET.
setWebhook ok:true but arah2bot never responds.
Telegram accepted the URL, so it answered ok while delivering updates to a path that 404s.
This happens when WEBHOOK_URL is a bare workers.dev origin. set-webhook.mjs now appends
/telegram/webhook for you — if you registered the webhook by hand or via a different tool,
re-run node bot/scripts/set-webhook.mjs and confirm the registered URL includes the path.
/cari is in the code but missing from the Telegram command menu.
The bot-side bug that caused this was found and fixed on 2026-10-25 (register-commands.mjs sent
the scope under the wrong parameter name); re-running npm run bot:commands now arms all
private-chat scopes correctly. What can still bite: the Telegram client caches the menu, so
a device that opened the bot before a fix may keep showing the stale menu until the app restarts.
The workaround is always available — type /cari in the chat; the command itself has always
worked.
Missing BOT_TOKEN, WEBHOOK_URL, WEBHOOK_SECRET and exit code 1.
set-webhook.mjs checks all three before any network call and never echoes values — only names.
Confirm you ran it from a path where bot/.dev.vars resolves (bot/.dev.vars is read relative to
the script, so it works from the repo root too), and that you did not commit a truncated file.
Nothing happens when I send /start.
Almost always the tunnel. wrangler dev --local binds to localhost, so Telegram has no route in.
Re-run the tunnel, update WEBHOOK_URL, re-run node bot/scripts/set-webhook.mjs, and confirm the
tunnel origin in .dev.vars still matches the one currently printing in Terminal 2 — tunnels
rotate their hostname on every restart.
/api/route answers bare null for long trips.
That is the honest degradation, not a bug: route computation runs inside the Cloudflare Workers
free plan's small per-request CPU allowance. Short KL trips (2–8 km) measure 2–8 ms and resolve;
cross-metro trips (20–30 km) measured p95 38–93 ms against the same graph, beyond the free plan,
so the app shows the router-pending card instead of freezing or drawing a wrong line. The committed
upgrade lever (ALT landmark preprocessing) is a next-milestone item — see
KNOWN-LIMITATIONS.md. Workers Paid was deliberately rejected: it breaks
the project's $0 mandate.
chromium.launch() fails — npm run capture or npm run web:capture errors on a missing browser.
Both scripts use bare chromium.launch() with no executablePath and no channel, so they need
Playwright's own browser build:
npx playwright install chromium
npm run web:capture additionally requires the harness build to exist — it serves
web/dist-harness/, not web/dist, and waits for RAMS-BASEMAP-READY then ARAHARAH-LIVE
signals. Run npm run web:build:harness first. Its 90-second denial timeout is deliberate: the
chat-pin poll must elapse before the denied card appears.
Mini App boots but shows no map.
Check the network tab for tile requests to https://tiles.openfreemap.org/… — the generated
theme/basemap/liberty-rams.json points there, so a blocked or offline request leaves a blank
canvas. npm run gallery serves the basemap locally on :8123 if you want to inspect the style
in isolation.
theme/dist/rams.css is missing when web builds.
Run npm run build from the root rather than npm run build -w web. The root build script
chains theme then web; the web build imports the compiled stylesheet, not the token source.
Next steps
- ARCHITECTURE.md — how the Worker, KV namespace, R2 graph, Mini App, and design system fit together; the Worker routing table; the three-link location acquisition chain.
- CONFIGURATION.md — every environment variable,
bot/wrangler.jsoncandweb/vite.config.ts, and the full command reference per workspace. - TESTING.md — each suite's contract and how to write a new one.
- KNOWN-LIMITATIONS.md — stated plainly: no traffic awareness, no lane guidance, the cross-metro CPU degradation, the stale Telegram menu cache, and what is on the roadmap. Beta-quality caveats included (e.g. battery drain is a planned measurement before the next driver onboarding, not an estimate).
- ../README.md — package overview, HTTP surface, design-system usage.
- ../DESIGN.md — the Dieter Rams philosophy behind the token vocabulary.
Everyday commands
| Command | What it does |
|---|---|
npm run dev |
Build, then wrangler dev --port 8787 --local |
npm run build |
Build theme → Mini App (theme/dist/rams.css must exist first) |
npm run web:dev |
Vite dev server on 5173 with a mock WebApp and no Worker |
npm run bot:test / npm run web:test / npm run search:test / npm run routing:test |
Zero-dependency test runners |
npm run bot:typecheck / npm run web:typecheck / npm run search:typecheck / npm run routing:typecheck |
tsc --noEmit |
npm run privacy-audit |
Repo-wide privacy audit (add -- and --live for the live KV/cache probes) |
npm run budget-audit |
Free-tier budget audit, measured and printed |
npm run audit |
Off-token audit of the rendered CSS; non-zero on violations |
npm run bot:commands |
Register the persistent Telegram command menu (EN + MS) |
npm run deploy |
Build, then wrangler deploy + set-webhook.mjs |
The two build outputs must never share an outDir: web:build:harness emits to
web/dist-harness/ with the mock installer and /api/* fetch stubs that production must not
carry. vite.config.ts calls this out as the reason the split exists.
Deploying
npm run deploy publishes the Worker to Cloudflare and registers the webhook. It reads no local
file for secrets — production values live as Cloudflare Workers secrets. See
CONFIGURATION.md. A fork must also provision its own
R2 road-graph bucket (npx wrangler r2 bucket create <name> +
routing/scripts/upload-road-graph.mjs) — the fork contract is ROAD-GRAPH.md.