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
userfield is attacker-controlled until the HMAC says otherwise, so parsing it early — even to log it — turns "authenticated" into "self-declared". - Expire it.
initDatais a bearer credential: anyone holding the string can replay it.auth_datebounds 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:
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 raiseTypeErrorat approve time, after you had committed. - Types are preserved. A form posts strings;
timeoutis an int. - The tool's own guards re-run.
db_executeblocks DDL, refusesaudit_log/pending_actions/vault_credentials, demands a reason and dry-runs — all before the approval gate.app.approval.validate_payloadre-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.showConfirmexists outside Telegram.telegram-web-app.jsdefineswindow.Telegram.WebAppwherever the page loads, so a truthiness check always passes — and below Bot API 6.2 the call throwsWebAppMethodUnsupported. That throw escaped before thetry, so tapping delete did nothing at all. Confirmation now goes through a guardedask()helper.- Loads are sequence-guarded, not boolean-guarded. A
loadingflag 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.