Technical Specification · v1.10.0 · live

AI-in-TA Telegram Bot A private, self-running daily briefing on AI in recruitment — researched by Claude, delivered every morning, operated by two runtimes split along a latency boundary.

briefing opus-4-8 /ask sonnet-5 schedule 09:05 UTC daily window last 48h access allowlist-gated cap 50 users runtimes Worker + Actions
01 · System context

Two runtimes, one latency boundary

Generation runs for tens of seconds — a recency-filtered news search, then one Messages API call to compose; live commands must answer Telegram in milliseconds. Splitting them means a slow generation run can never make /briefing feel slow, and a webhook hiccup can never affect the scheduled send.

Telegram — webhook (secret-token validated) —▶ Cloudflare Worker

Cloudflare Worker

Webhook + cron · always on · milliseconds

  • Validates X-Telegram-Bot-Api-Secret-Token on every request
  • Serves /briefing from KV — no generation
  • Mutates access & subscriptions in the Durable Object
  • Cron Trigger 5 9 * * * → dispatches the daily run

GitHub Actions

Generation + fan-out · on demand · minutes

  • fetch-news.mjs searches, then generate-briefing.mjs composes the briefing
  • send-briefing.mjs delivers to every subscriber
  • broadcast.mjs fans out on the runner, paced + retried
  • sync-kv.mjs bridges the result back into KV
source of truth
BotState · DO
Strongly-consistent access list, subscribers, usage stats.
read mirror
BOT_STATE · KV
Cheap Worker reads — cached briefing, live subscriber list.
generation record
state/ · git
What a given day produced; holds the idempotency marker.
delivery ◀— send-briefing / broadcast — Subscribers
02 · Command contract

What each command does, and who may call it

Access is enforced by chat_id checks in the Worker — never by message content. Owner-gated commands pass isOwnerOrAdmin(); managing admins stays keyed to the owner alone.

Command Access Effect
/start anyone Request access — adds sender to pending; enforces the 50-user cap; the reply points at /privacy and /forgetme
/briefing allowlisted Serves the cached copy from KV, sub-second, no generation
/newbriefing allowlisted Dispatches a fresh generation run; honors the cooldown & daily cap
/ask allowlisted Answers a question from past briefings, cited and dated; read-only over the wiki, no web access
/subscribe · /unsubscribe allowlisted Opt in / out of the daily send
/privacy · /mydata · /forgetme allowlisted Data-subject rights over everything stored about you
/admin · /pending · /listusers owner / admin Read stats, review requests, list the population
/adduser · /removeuser owner / admin Add or remove someone from the allowlist directly
/broadcast <msg> owner / admin Validated by the Worker, fanned out on the Actions runner
/pause · /resume owner / admin Freeze or lift the bot; while paused every paid path is withheld and the daily send is skipped
/addadmin · /removeadmin owner only Delegate / revoke admin — target must already be allowlisted
03 · Constraints

Hard limits, enforced in code

Three limits guard generation, and the strictest one wins: a shared cooldown (shorter for the owner), a per-user daily cap that bounds hogging, and a global daily cap that bounds cost. A refused request still gets the cached edition — never a bare error.

30
Maximum users
MAX_USERS — checked atomically in the DO so two concurrent approvals can't both slip past.
50
Pending queue cap
MAX_PENDING — bounds the access-request queue, capped independently of the user allowlist.
60min
Generation cooldown
Global and role-dependent — 5 min for the owner. During it, users still get the cached briefing rather than an error.
3/day
Per-user dispatch cap
DAILY_DISPATCH_CAP — bounds one user hogging /newbriefing; scales with the allowlist, so it doesn't cap cost alone.
5/day
Global dispatch cap
GLOBAL_DAILY_DISPATCH_CAP — the real cost ceiling, since every dispatch is a paid Actions + Claude run.
10/day
Per-user /ask cap
ASK_DAILY_CAP, on its own counter with a 30 s cooldown and a 40/day shared ceiling — a grep over the wiki is not a paid search loop, so questions never spend the briefing allowance.
$0.09
Cost per edition
The search runs outside the model, so an edition composes from a short candidate list instead of ~250k tokens of raw results.
7d
News window
The search filters to the past week and the prompt drops anything older — freshness over recall.
<1s
Live command latency
KV- and DO-backed replies answer in milliseconds, independent of generation.
04 · Reliability

Layered triggers, plus a heartbeat outside GitHub

Three triggers race each morning; an idempotency marker (last_briefing_at) makes it safe for more than one to fire — only the first to actually run does the work. But two of the three ride GitHub's scheduler, so a Cloudflare-side heartbeat is the only detection that survives a GitHub-wide bad morning.

09:05UTC
primary

Cloudflare Worker Cron Trigger

Fires scheduled() → repository_dispatch to daily-briefing.yml (PR #43); the minute offset dodges GitHub's top-of-hour congestion. Best-effort too — it and the backup missed together on 2026-07-16 (issue #61).

09:00UTC
backup

GitHub Actions schedule

Fires the same workflow independently. Best-effort by GitHub's own docs — observed 1–4h late or skipped entirely (issue #17), so it's a backstop, not the plan.

10:30UTC
watchdog

Stale-check fallback

If last_briefing_at still isn't today, daily-briefing-watchdog.yml re-dispatches the run and alerts the owner — but it too runs on GitHub's scheduler.

12:00UTC
heartbeat

Cloudflare-side heartbeat

briefingHeartbeat alerts the owner if KV's last_delivered_date isn't today. Detection only, no dispatch — but the only layer outside GitHub's scheduler, so it catches an account-wide Actions failure the others would miss.

05 · Security & privacy

Least privilege, per credential

Every secret carries the narrowest scope that still works; the allowlist holds real third-party personal data, so data-subject rights are first-class, not an afterthought.

Webhook secretWorker secretValidates every inbound webhook is really Telegram
CI dispatch tokenWorker secretFine-grained PAT — single repo, Contents: write
LLM API keyrepo secretSpend-capped workspace key
KV storage tokenrepo secretWorkers KV Storage: Edit — nothing more
Bot API tokenrepo + WorkerFull bot control — rotated on any leak
Owner identifierrepo variableNot sensitive — the owner's own chat id
Environments are fully isolated. Production and staging run distinct Workers, distinct bots, distinct KV namespaces, and distinct webhook secrets — a staging change is exercised end-to-end with zero chance of touching prod state or prod subscribers. wrangler deploy targets prod; --env staging is required to touch staging.