Skip to content

Telegram Mini App

A web page that opens inside the Telegram chat, from the menu button next to the message box. No install, no login, no second deployment — it rides the same FastAPI service and authenticates on a blob Telegram signs for the page.

It exists because Kwasi's state was write-only. He holds ~650 facts and ~160 behavioural rules that shape every reply, and until this there was no screen anywhere that showed one. Every memory defect this project has found — a self-fact naming the wrong model, 160 one-off observations treated as policy, a decay sweep that nearly deleted a real phone number — was found by a human typing SQL.

It is also the pull-shaped half of the reduction plan. Everything else that work shipped removes push; this adds a reason to open the app. Nothing here ever sends a message.

Spec: specs/016-telegram-mini-app/spec.md.


The four tabs

Tab What it answers
Today What is on today — open tasks, screen time, profile, and the calendar once it loads
Memory What Kwasi remembers about you — searchable, correctable, deletable
Approve What is waiting on you, with the payload in full
Status Is it healthy — deploy state, loop health, exceptions, redeploy

The Approve tab carries a count badge, so something waiting is visible without Kwasi messaging about it.


Auth — initData, not DASHBOARD_SECRET

Telegram hands the page a signed query string naming the user. The page forwards it in X-Telegram-Init-Data; the server proves this bot signed it before believing anything in it.

secret       = HMAC_SHA256(key="WebAppData", msg=<bot token>)
check_string = "\n".join(f"{k}={v}" for k, v in sorted(fields))   # hash removed
expected     = HMAC_SHA256(key=secret, msg=check_string).hexdigest()

app/miniapp.py enforces three rules, each guarding a way this quietly stops being authentication:

  • Never read the user id before the signature verifies. The user field is attacker-controlled until the HMAC says otherwise, so parsing it early — even to log it — turns "authenticated" into "self-declared".
  • Expire it. initData is a bearer credential: anyone holding the string can replay it. auth_date bounds that to a day.
  • Sort the check string. A client that reorders its query string must still verify, or you get intermittent 401s that look like anything but a sorting bug.

The verified id is then checked against ALLOWED_TELEGRAM_USER_IDS — the same allowlist the bot uses. An empty allowlist blocks everyone, so a misconfigured deployment cannot become an open one. Failures return a flat 401/403 with no reason: which check failed is useful only to someone probing.

GET /miniapp/ itself is unauthenticated on purpose — it is a static shell containing no data. Everything it displays arrives through /miniapp/api/*, which is not.

This does not touch DASHBOARD_SECRET

The browser dashboard at /dashboard/ keeps its own scheme. A secret in a URL is fine for a bookmark and wrong for a page that can be handed to anyone.


Endpoints

Method Path Notes
GET /miniapp/ The page. Static shell, no auth, no data
GET /miniapp/api/today Open tasks, screen time, profile sections — all local DB reads
GET /miniapp/api/agenda Today's calendar. Deliberately its own route: a live Google round trip across every calendar, so folding it into /today would make the whole first screen wait on the slowest thing on it. The page renders Today, then slots the agenda in
GET /miniapp/api/facts Paginated (50/page), ?q= searches
PATCH /miniapp/api/facts/{key} Correct a value — marks it source="user"
DELETE /miniapp/api/facts/{key} Idempotent; a replay is a successful no-op
GET /miniapp/api/approvals Pending actions with payloads in full
PATCH /miniapp/api/approvals/{id} Revise a payload. Does not execute
POST /miniapp/api/approvals/{id}/confirm Run it
POST /miniapp/api/approvals/{id}/cancel Decline it
GET /miniapp/api/status Deploy state, loop health, exceptions, consoles
POST /miniapp/api/redeploy Restart the service (confirmed, audited)

Every /api/* route authenticates. Approval routes are additionally scoped to the caller's own chat, taken from the verified blob and never the request, so a verified user cannot resolve an approval raised in someone else's conversation.


Memory — correcting a fact actually sticks

Editing a fact marks it source="user", and a user-asserted fact outranks an extracted one. save_user_fact carries the rule on its upsert:

ON CONFLICT (key) DO UPDATE SET ...
WHERE user_facts.source <> 'user' OR EXCLUDED.source = 'user'

Without it the edit button would be a lie. The upsert was unconditional, so a value corrected in the evening was reverted by the 2 AM reflection re-extracting the same key — and because source was overwritten too, the correction erased the evidence it had ever happened. Production showed the shape of that: 541 reflection facts updated within the week against 2 user facts, both untouched since March.

To hand a fact back to extraction, delete it rather than editing it. A deleted fact is simply re-derived; an asserted one is not overruled.

Deletes and edits do not go through the approval gate. The gate interposes a human between a model's decision and a consequential action; a human editing a row they are looking at has already supplied that check. Both write an AuditEntry with channel="miniapp", so the gate's other job survives.


Approvals — reading what you are approving

The Telegram card shows a preview, and previews truncate: execute_python cuts code at 400 characters, delegate_to_coding_agent cuts the task at 300. So an approval given there is for something you cannot finish reading — on the most consequential tools in the system. The full payload was always stored; nothing displayed it.

This tab shows it: code and SQL as monospace blocks, everything else as fields, plus the request that triggered it and the time remaining.

One approval path, not two. Confirm and cancel go through app.approval.confirm_action / cancel_action — the same functions the inline keyboard calls — so execution, the audit entry and the Langfuse user_approval score cannot differ between surfaces.

Editing revises; approving runs. PATCH updates the stored action and leaves it pending, so approving is a separate deliberate tap and the edit endpoint cannot cause a side effect on its own. Three rules make that safe:

  • Only keys already present may change. Payload keys are the executor's keyword arguments (executor(deps=deps, **payload)), so an invented one would not be caught at edit time — it would raise TypeError at approve time, after you had committed.
  • Types are preserved. A form posts strings; timeout is an int.
  • The tool's own guards re-run. db_execute blocks DDL, refuses audit_log / pending_actions / vault_credentials, demands a reason and dry-runs — all before the approval gate. app.approval.validate_payload re-applies them, because an edit path that skipped them would be worse than offering no edit at all.

Editing also rewrites the Telegram card. Leaving the old preview above a live Confirm button is a way to approve something you never read.


Status — the Command Centre on a phone

Reads the same sources as diagnose_self and the browser Command Centre: HEARTBEAT_KEYS from app.background, railway.latest_deployment, logfire_exceptions, the pending-redeploy marker. Deliberately — those two had already drifted apart once, each keeping its own heartbeat list until the two views disagreed about which loops existed.

Every panel degrades on its own. The moment you most want this page is the moment something is already broken, so an unreachable Railway or Logfire returns its own error string rather than failing the screen.

Two shaping decisions live in app/miniapp_panels.py as pure functions, because each encodes a judgement that deserves a test:

  • Loop health classifies each loop as off / never / error / stale / ok, checked in that order. The distinction that matters is off vs broken: a silenced loop is following instructions, not failing, however old its last run — while a loop that is enabled and quiet is a fault. Rendering raw heartbeat values conflated the two.
  • Logfire exceptions are parsed into type / message / time. The browser Command Centre prints the query API's raw columnar JSON, which on a phone is a screen-filling wall of escaped stack traces with nothing readable in it.

Redeploy is here because "it's broken, restart it" is the most useful thing to do from a phone. It authenticates more strongly than the browser dashboard that already offers the same button, confirms before firing, and is audited.


Configuration

Two settings, both in Deployment:

Variable Effect
MINIAPP_BASE_URL Public HTTPS origin. Installs the chat menu button at startup. Unset ⇒ no button; Telegram rejects http:// and localhost
MINIAPP_BUTTON_TEXT Button label (default Memory)

The button is installed by _install_miniapp_button during bot startup rather than by hand in BotFather, so the entry point lives with the routes it opens and a URL change is a redeploy rather than a forgotten manual step. It is best-effort: a Telegram hiccup there must never stop the bot from starting.


Implementation notes

app/static/miniapp.html is one self-contained document — no build step, no bundler, no framework — because it is one screen and the alternative is a toolchain to maintain for it. telegram-web-app.js is the only external script, and it is required: it supplies initData and the theme variables that make the page match the user's Telegram colours.

It is read once at import. A missing file degrades to a stub rather than failing the boot, because a packaging slip in a secondary surface must not take Telegram, the briefings and every loop down with it; a test asserts the file ships.

Two failure modes are worth knowing, since both were found by driving the page in a browser and neither was visible to any HTTP test:

  • tg.showConfirm exists outside Telegram. telegram-web-app.js defines window.Telegram.WebApp wherever the page loads, so a truthiness check always passes — and below Bot API 6.2 the call throws WebAppMethodUnsupported. That throw escaped before the try, so tapping delete did nothing at all. Confirmation now goes through a guarded ask() helper.
  • Loads are sequence-guarded, not boolean-guarded. A loading flag both dropped searches typed during a fetch and rendered the stale response against the newer query, producing "62 facts matching" above 62 unfiltered rows. Every load takes a ticket; superseded responses are discarded.