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 six tabs¶
| Tab | What it answers |
|---|---|
| Today | What Kwasi prepared for you (the feed), then what is on today — open tasks, screen time, profile, and the calendar (Google and Outlook, in his timezone; "Loading calendar…" until it arrives) |
| Inbox | The last three days of mail, already sorted into Reply / Act / FYI / Noise with a reason each (Spec 018) |
| Approve | What is waiting on you: a plain sentence first (the tool's own preview line, times made readable), then the payload in full |
| Memory | What Kwasi remembers about you — searchable, correctable, deletable |
| Jobs | Delegations running or finished: a short title and the outcome in one line (the answer, or why it failed in words); the full instructions and raw error behind a tap |
| System | Is it healthy — deploy state, loop health, exceptions, redeploy (view id status) |
The Approve tab carries a count badge, so something waiting is visible without Kwasi messaging about it.
The feed (Spec 017)¶
The top of Today: proactive content Kwasi prepared instead of messaging you.
The rule it serves — push only what is time-critical or needs a decision now;
everything else is a card — is in specs/017-proactive-feed/.
- For you lists only cards that still need you, most recently changed first. A handled card is never shown again: an earlier "Earlier today" section kept them for 24 hours, which across a reload showed overdue cards for an already-completed task with nothing left to act on. Within a visit, Done and × update the screen in place; the database keeps every transition for the read-out. Expired cards are never shown, even between the 5-minute expiry sweeps.
- Opening a card records
seen; × recordsdismissed. Both are idempotent. - Dismissing holds until the state changes (another task becomes overdue, or the days-overdue count moves the next morning) — not just until the next 5-minute check.
- Task cards are actionable: each task is a row with Done, which runs the
same executor as the chat tool (so it syncs to Microsoft To Do), writes an audit
row (
miniapp_complete_task), and marks the card acted. No approval card — it is your own tap, as with Memory edits. - Card text renders Telegram-style
*bold*(only that; applied after escaping, and only when the asterisks hug non-space text, so* bulletsand2 * 3stay literal). - The task line reads "12 open tasks · 1 overdue" and expands into a read-only
list of the open tasks (dated first by due date, so overdue leads in red; undated
high-priority marked ★). Tapping a task — or Open To Do — opens Microsoft To Do
at My Day (its link scheme has no documented way to open one specific task):
openLink→/miniapp/todo, a data-free hand-off page that runs in the phone's browser and uses Android'sintent://form (ms-to-do://myday, web To Do as fallback, plus a tappable link because some in-app browser tabs need a gesture). Not a plain https link: checked with adb, the To Do app claims only its sharing/import paths onto-do.microsoft.com, so ordinary links always land in the browser. Verified on the phone: the hand-off opens To Do on My Day. - An empty feed says so ("Nothing new from Kwasi.") instead of rendering nothing — in the first live test a blank space read as "the feature isn't there".
- Recurring state is one card updated in place (
dedup_key), not a card per check. The first producer: the two system task alerts (overdue, due today), which no longer push. - Every Mini App open and tab switch is recorded (
miniapp_opens), because until this feature nothing recorded whether the Mini App was used at all. - Card bodies render as plain text (escaped). Telegram-style
*bold*shows its asterisks — fine for task cards; briefing cards (PR 2) will need minimal bold.
Read the effect with scripts/prod_query.sh -f scripts/feed_report.sql.
Inbox (Spec 018)¶
The day's mail from the connected accounts, newest first, the two main accounts
(work Gmail, Outlook) ahead of the personal Gmail. Each email is placed by a fixed
rule (rule), by the mini model reading sender, subject and preview (read), or
by him (you), and says why in one line. Rules and their evidence:
app/inbox/rules.py and specs/018-inbox-triage/spike-2026-10.md. The model never
sees a body or headers, and its reason is capped at 120 characters.
- A disconnected account is shown as disconnected, in red, not as an inbox with fewer emails.
- Tap an account (Work Gmail / Outlook / Personal Gmail) to see only its mail; tap again for all. Group counts and "Archive all N" follow the filter, since bulk sends exactly the ids on screen. The filter, the open groups and the scroll position survive the reload that follows every tap.
- Moving an email (the "Move to…" menu) is his choice from then on: a re-sort
never changes it, and the sender's next email follows it (
sender_prefs). - Open opens the email in its own app through
openLink. - Archive and Delete are instant, with Undo. The row disappears on tap and a
strip says "Archived · Undo"; the request is sent 4 seconds later, so a
mis-tap costs one tap and nobody waits for a reload after every email. Leaving
the app sends whatever is pending (
fetchkeepalive) rather than dropping it. A refused action brings the row back with "Not archived — try again". - FYI and Noise rows are one line (sender · subject · time); tap one to see its reason and buttons. Delete sits last in a row, as red text, away from Archive.
- Times read
14:32today,Yesterday, then the weekday (calendar days, local). - A disconnected account's chip explains how to reconnect it (the script to run and the Railway variable to set).
- Reloads never run under an open "Archive these N?", a pending Undo or a request in flight; coming back from Open (Gmail/Outlook) refreshes the list.
- Archive (route
done) archives and marks read — in Gmail the whole conversation, since Gmail keeps a thread in the inbox while any message has INBOX; Delete moves to Trash (Gmail) / Deleted Items (Outlook), recoverable for 30 days; Archive all N, at the top of FYI and Noise, shows the count and asks before anything happens. (First live use: labelled "Done" and placed under 60 rows, neither was found.) All are his taps, so no approval card, and all are audited. A refused action leaves the email listed with "Not archived — try again"; nothing is shown as cleared that is still in the inbox. - Nothing can delete permanently.
OutlookClient._make_requestrefuses, at runtime, any DELETE on mail,/permanentDelete, or a move past Deleted Items (also inside$batch);tests/test_no_permanent_delete.pyscans every mail module and fails on Gmaildelete/batchDelete, a GraphDELETEon a message, the permanent-delete action, or a$batchDELETE. Gmail's scope could not do it anyway; Outlook's could, so the test is what holds there. - Reply (on Reply and Act emails): Kwasi drafts an answer from the email, read
as data by the mini model with no tools; the email is fenced and preceded by the
rule never to follow instructions inside it. Unknown facts become
[placeholders]. He edits it, then Send (asks "Send this reply to …?" with the recipient, subject and full text on screen; goes throughconfirm_actionas theinbox_send_replyaction, so it is audited and runs once) or Save draft (a threaded draft in Gmail/Outlook Drafts; sends nothing). Gmail replies carry the thread id and In-Reply-To/References; Outlook uses its ownreply/createReply. A reply being written survives any re-render, and reloads wait while one is open. - Everything shown (sender, subject, reason) was written by someone else and is rendered escaped.
- One feed card, "Email: N need you · M sorted", opens the tab in one tap. It is a card, never a push.
- One row per conversation, one main action (review, 2026-10-03). Messages of a thread are one row ("· 3 messages"); Archive, Delete, Snooze and Make task act on all of them, Undo included. Each row shows the action that fits its group (Reply on Reply, Make task on Act, Archive on FYI/Noise), Archive, and ⋯ for Open, Snooze, Move and Delete. The account chips count conversations.
- Make task (Act): an inline form, the title prefilled from the subject without its Re:/Fwd: chain plus the sender, and Today / Tomorrow / Friday / No date. It runs the chat's own create-task executor (so it syncs to To Do), then archives the conversation; if the archive is refused the task stands and the email stays.
- Snooze (Reply/Act): until tomorrow or Monday at 08:00 his time. A snoozed email leaves the list and the "needs you" counts, comes back when the time passes even if it is older than three days, and a re-sort cannot clear it.
- Clean up older mail (below the list; PR 4, 2026-10-03) — what worked when he cleared Outlook by hand with Claude in a browser, for all three inboxes, whenever he wants it. He picks a cut-off (7 / 30 / 90 days) and taps Scan: a read-only pass (about 15–30 s per account) that leaves out mail from people he has written to in any account, starred/flagged mail, senders he filed as Reply/Act, and his own. What is left is grouped by organisation (domain), except personal providers (gmail.com, outlook.com, …) where each address is its own group, biggest first; a group can be opened to tick single addresses (google.com mixes calendar invites and Docs comments). Nothing is pre-ticked. "Archive N emails" (pinned at the bottom while he scrolls) asks once, then archives exactly the scanned emails of what he ticked, in chunks recorded as they succeed, with a progress line and Stop (after the current chunk). A scan older than an hour must be redone first. It is its own section with its own state, so the list reloading above it never clears his ticks. Nothing runs unless he starts 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 |
The feed, open tasks, screen time, profile sections — all local DB reads. feed is null (not an error) if the feed fails to load |
POST |
/miniapp/api/feed/{id}/{seen\|acted\|dismissed} |
Forward-only, idempotent. acted is a record, never an approval |
POST |
/miniapp/api/opened |
{"tab": …} — one of the six view ids; 204 |
GET |
/miniapp/api/inbox |
Sorted mail, account states, refreshing. Re-sorts first when the last sort is over 2 minutes old, waiting at most 8 s before serving stored items |
POST |
/miniapp/api/inbox/{id}/done |
Archive + mark read in its own account, then mark it done. Audited (miniapp_inbox_done). 502 and still open if the mail service refused; a replay on a handled email returns its status |
POST |
/miniapp/api/inbox/{id}/delete |
Move to Trash / Deleted Items (recoverable 30 days), then deleted. Audited (miniapp_inbox_delete). 502 as above |
POST |
/miniapp/api/inbox/{id}/reply/draft |
{to, to_name, subject, body}: a model draft. Creates nothing in the mailbox. 409 on a handled email, 502 if drafting failed (the page then offers an empty box) |
POST |
/miniapp/api/inbox/{id}/reply |
{"body", "mode": "send"\|"save"}. save creates a threaded draft (audited miniapp_inbox_save_draft); send saves a PendingAction and confirms it in the same request through confirm_action (audited as inbox_send_reply), marks the email replied. 502 if the mail service refused; the text is kept |
POST |
/miniapp/api/inbox/bulk |
{"ids": [...], "action": "done"}: archives exactly the ids the page showed, only those still open; ≤ 500. Returns {"done", "failed"}; one audit row per email (miniapp_inbox_bulk_done). Only done is offered in bulk |
GET |
/miniapp/api/inbox/cleanup |
The latest Clean up run: status, progress, what was left out and why, per-account result, and when ready the sender groups. A run left active by a restart is settled here (failed/stopped) |
POST |
/miniapp/api/inbox/cleanup/scan |
{"cutoff_days": 7\|30\|90} starts a read-only scan in the background. 400 for another cut-off, 409 while a scan or archive runs. Audited miniapp_inbox_cleanup_scan |
POST |
/miniapp/api/inbox/cleanup/{run_id}/archive |
{"addresses": [...]} archives the scanned emails from those addresses only. 400 for an address the scan did not show, 409 unless the scan is ready and under an hour old, or while another run is going (a double tap starts one). Audited miniapp_inbox_cleanup_archive with the email count |
POST |
/miniapp/api/inbox/cleanup/{run_id}/stop |
Stops an archive after its current chunk |
POST |
/miniapp/api/inbox/task |
{"ids", "title", "due": "today"\|"tomorrow"\|"friday"\|"none"}: creates the task through _execute_create_task (To Do sync included) and archives those emails. Returns {"task", "archived"}; archived: false leaves them listed. Audited miniapp_inbox_task. 400 without a title or a known day, 409 if already handled |
POST |
/miniapp/api/inbox/snooze |
{"ids", "until": "tomorrow"\|"monday"}: hides them until 08:00 his time. Audited miniapp_inbox_snooze |
POST |
/miniapp/api/inbox/{id}/move |
{"grp": …}: his choice, kept against every later sort and remembered for the sender; refreshes the Inbox feed card. 400 unknown group, 404 unknown email |
GET |
/miniapp/todo |
Hand-off to Microsoft To Do (Android intent://, web fallback). No data, no auth — like /miniapp/ |
POST |
/miniapp/api/tasks/{id}/complete |
{"card_id": …} optional. Completes via the tool's executor (To Do sync included), audits, marks the card acted. 404 for an unknown task |
GET |
/miniapp/api/agenda |
Today's calendar from Google and Outlook (read in parallel; one failing returns the other's events with partial, both failing a note), times in his timezone, an event in both calendars once. Deliberately its own route: a live 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 Kwasi; it opens the whole app, not one tab) |
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.