A serverless Telegram summary bot: Vercel Functions ingest Telegram webhook updates into Postgres, Vercel Cron (or an authenticated manual trigger) claims a summary run for a chat window, and a Postgres-backed queue drainer summarizes the window and sends it through the Telegram Bot API. Local and test runs can opt into a deterministic fake Telegram outbox. Postgres is the only state store; every function invocation is stateless.
The summary itself is produced by a real multi-agent pool, not string concatenation. Inside
the queue-drainer function, AgentSummarizerPort (src/agentSummarizer.ts) sends the
window's transcript to a smithers AnthropicAgent / OpenAIAgent with a typed zod output
schema (tl;dr, points, participants) and renders the structured result.
- Variety / "scale tasks across a variety of agents".
buildAgentPoolassembles a pool from the configured provider keys: with both keys it spans an Anthropic model and an OpenAI model; with a single provider it still spans two distinct models of that provider. Each run is routed to one agent deterministically (an FNV-1a hash ofchatId+ window), so the choice is reproducible and demonstrably spreads load. The chosen agent is recorded on the run row (run.summary_agent, e.g.anthropic:claude-opus-4-8/openai:gpt-4o) so the DB and/runsdashboard show the spread. - Serverless correctness — SDK agents only, never CLI agents. A Vercel Node function is a
JS runtime with no container, so it can only run in-process SDK agents (
AnthropicAgent/OpenAIAgentextend the AI SDKToolLoopAgentand make plain HTTPS calls). CLI agents (ClaudeCodeAgent,CodexAgent, …) spawn a vendor binary and cannot run here. See the smithers execution-model docs for the distinction. - Graceful degradation. When neither
ANTHROPIC_API_KEYnorOPENAI_API_KEYis present (local dev, CI), the container falls back to the deterministicFixtureSummarizerPortso the app still works offline and without a network. Unit tests inject a fake pooled agent via the same dependency-injection seam, so they stay green with no API keys.
Planning artifacts live in docs/planning/ (PRD, design, engineering spec, tickets) and
artifacts/smithering/ (research, decisions, probes). Smithers workflows that built this
repo live in .smithers/.
| Route | Method | Auth | Purpose |
|---|---|---|---|
/api/telegram/webhook |
POST | X-Telegram-Bot-Api-Secret-Token must equal TELEGRAM_WEBHOOK_SECRET |
Ingest Telegram updates (idempotent per (chat, message)) |
/api/cron/summary |
GET/POST | Authorization: Bearer <CRON_SECRET> |
Scheduled trigger for all enabled chats, then drains runnable queued runs (wired via vercel.json crons) |
/api/trigger |
POST | Authorization: Bearer <OPERATOR_SECRET> |
Manual operator trigger for one chat window; enqueues only and self-invokes the queue drainer |
/api/queue/drain |
POST | Authorization: Bearer <CRON_SECRET> |
Serverless queue drainer for pending/manual/scheduled runs |
/api/test/outbox |
GET | E2E_TEST_ROUTES=1 and operator bearer; always 404 in production |
Test-only view of the fake Telegram outbox |
Concurrent triggers for the same (chat, window) are deduplicated by an atomic
INSERT ... ON CONFLICT DO NOTHING claim on the run table. Triggers do not summarize
inline; the drainer claims runnable rows with SELECT ... FOR UPDATE SKIP LOCKED, assigns
a lease/visibility timeout, and retries failed runs with Postgres backoff until they are
posted or dead-lettered.
Copy .env.example to .env.local and fill in real values.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Postgres connection string (Neon in production, docker locally) |
TELEGRAM_WEBHOOK_SECRET |
Shared secret Telegram echoes in X-Telegram-Bot-Api-Secret-Token |
TELEGRAM_BOT_TOKEN |
Telegram bot token used for production sendMessage delivery |
TELEGRAM_API_ROOT |
Optional Telegram Bot API root override (defaults to https://api.telegram.org) |
TELEGRAM_DELIVERY_MODE |
Optional fake for local/e2e outbox mode or real to force Bot API delivery outside production |
OPERATOR_SECRET |
Bearer token for the manual trigger and test-only routes |
CRON_SECRET |
Bearer token Vercel Cron sends to /api/cron/summary |
ANTHROPIC_API_KEY |
Enables the Anthropic SDK summarizer agent(s) in the pool; without it (and OPENAI_API_KEY) summaries fall back to the offline fixture |
OPENAI_API_KEY |
Enables the OpenAI SDK summarizer agent in the pool (paired with Anthropic for cross-provider variety) |
SUMMARY_ANTHROPIC_MODELS |
Optional. Comma-separated Anthropic model ids for the pool (default claude-opus-4-8,claude-haiku-4-5); pin exact/current ids without a code change |
SUMMARY_OPENAI_MODELS |
Optional. Comma-separated OpenAI model ids for the pool (default gpt-4o,gpt-4o-mini); set to your account's current models |
E2E_TEST_ROUTES |
Set to 1 to enable test-only routes (never in production) |
TEST_DATABASE_URL |
Enables the live-Postgres integration suite (tests only) |
POSTGRES_PORT |
Host port for the local docker Postgres (default 5432) |
RATE_LIMIT_WINDOW_SECONDS |
Fixed-window rate-limit size in seconds (default 60) |
RATE_LIMIT_WEBHOOK_MAX |
Max authenticated Telegram webhook requests per window per requester (default 120) |
RATE_LIMIT_TRIGGER_MAX |
Max authenticated manual trigger requests per window per requester (default 20) |
RATE_LIMIT_CRON_MAX |
Max authenticated cron requests per window per requester (default 10) |
QUEUE_DRAIN_LIMIT |
Max runnable runs a drainer invocation claims (default 5) |
QUEUE_LEASE_SECONDS |
Queue visibility timeout for claimed runs (default 900) |
QUEUE_MAX_ATTEMPTS |
Max failed attempts before a run is dead-lettered (default 3) |
QUEUE_BACKOFF_BASE_SECONDS |
Initial retry backoff for failed runs (default 60) |
QUEUE_BACKOFF_MAX_SECONDS |
Maximum retry backoff for failed runs (default 3600) |
- Install dependencies with
bun install. - Start Postgres with
docker compose up -d. If port 5432 is busy, runPOSTGRES_PORT=5433 docker compose up -dand updateDATABASE_URLin.env.localto use port 5433. - Copy
.env.exampleto.env.localand replace the placeholderTELEGRAM_WEBHOOK_SECRET,OPERATOR_SECRET, andCRON_SECRETvalues. LeaveTELEGRAM_BOT_TOKENblank for deterministic local fake-outbox delivery, or set it withTELEGRAM_DELIVERY_MODE=realfor a live Telegram smoke test. - Apply the database schema with
bun run migrate. - Start the app with
bun run dev, then openhttp://127.0.0.1:3000.
Useful local route checks:
- Export the matching secrets first, for example
set -a; source .env.local; set +a. - Manual summary enqueue:
curl -X POST http://127.0.0.1:3000/api/trigger -H "authorization: Bearer $OPERATOR_SECRET" -H "content-type: application/json" -d '{"chatId":1,"windowStart":"2026-07-02T00:00:00.000Z","windowEnd":"2026-07-02T01:00:00.000Z"}' - Queue drain:
curl -X POST http://127.0.0.1:3000/api/queue/drain -H "authorization: Bearer $CRON_SECRET" - Fake outbox, when
E2E_TEST_ROUTES=1:curl http://127.0.0.1:3000/api/test/outbox -H "authorization: Bearer $OPERATOR_SECRET"
Real Telegram delivery goes through the first-class smithers-orchestrator/telegram
Bot API helper behind this app's narrow TelegramPort. If TELEGRAM_BOT_TOKEN is not
set outside production, delivery uses the fake Postgres outbox so local runs stay
deterministic. Test processes and deployments with E2E_TEST_ROUTES=1 also use the
fake outbox unless TELEGRAM_DELIVERY_MODE=real is set explicitly for a live smoke run.
The checked-in .env.example leaves TELEGRAM_BOT_TOKEN blank for that local fake
default; production must set a real token.
The Next.js run dashboard lives at /runs and uses
smithers-orchestrator/gateway-react to read live Smithers Gateway data.
Run the Smithers gateway and the app in separate terminals:
bun .smithers/gateway.tsSMITHERS_GATEWAY_URL=http://127.0.0.1:7331 bun run dev- Open
http://127.0.0.1:3000/runs?token=<OPERATOR_SECRET>once. The server validates the token, sets an HttpOnly Secure SameSite=Lax cookie, and redirects to/runsso the token is not left in the dashboard URL or client JavaScript.
next.config.mjs rewrites /v1/rpc/*, /v1/api/* (the dashboard's REST +
SSE data plane), /workflows/*, /health, and the WebSocket path
/smithers-ws to SMITHERS_GATEWAY_URL, defaulting to
http://127.0.0.1:7331. These proxy paths use the same operator cookie and fail
closed when OPERATOR_SECRET is missing. The dashboard waits until browser mount
to connect, so production builds do not fetch the gateway. Set
NEXT_PUBLIC_SMITHERS_GATEWAY_URL only when the browser should connect directly
to a separately reachable gateway; otherwise leave it blank and use the same-origin
rewrites above.
bun run typecheck— strict TypeScript over app, src, and tests.bun run build— Next.js production build.bun test/bun run test— unit, integration, and e2e suites.bun run test:coverage— enforces coverage thresholds (fails under them).TEST_DATABASE_URL=postgres://smithers:smithers@localhost:5432/vercel_example bun run test:integration— live-Postgres suite: migration idempotency, atomic run claim under concurrency, queue drainerSKIP LOCKEDclaims, lease reclaim, FK enforcement, exactly-one outbox send under concurrent trigger+drain, and rate-limit rejection. Skipped whenTEST_DATABASE_URLis unset.bunx tsc -p .smithers/tsconfig.json --noEmit— Smithers workflow typecheck.
CI (.github/workflows/ci.yml) runs typecheck, build, and the full coverage-gated test
suite against a Postgres 16 service, plus the Smithers workflow typecheck, on every PR
and push to main.
- Create or link the Vercel project with
vercel link. - Attach a Postgres database, such as Neon through the Vercel Marketplace, and set
DATABASE_URLfor Preview and Production. Use the pooled connection string (Neon's PgBouncer endpoint, the one with-poolerin the host) — every serverless function instance opens its own smallpgpool, so direct connections exhaust Postgres connection limits under concurrency. - Set these required secrets in Vercel for the target environment:
TELEGRAM_WEBHOOK_SECRET,TELEGRAM_BOT_TOKEN,OPERATOR_SECRET, andCRON_SECRET. Production startup fails fast whenTELEGRAM_BOT_TOKENis missing unlessTELEGRAM_DELIVERY_MODE=fakeis explicitly set for a non-delivery environment. - Set
E2E_TEST_ROUTES=1only for Preview deployments that need the fake outbox route; never set it to1in Production. The route also hard-404s wheneverNODE_ENVorVERCEL_ENVisproduction. - Run migrations against the target database before testing or promoting:
vercel env run -e preview -- bun run migratefor Preview, orvercel env run -e production -- bun run migratefor Production. To inspect synced values first, usevercel env pull .env.preview.local --environment=previeworvercel env pull .env.production.local --environment=production. - Deploy a Preview with
vercel deployorvercel deploy --target=preview. Deploy Production withvercel deploy --prod. - Register the Telegram webhook for the deployed URL:
curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" -d "url=https://<deployment-host>/api/telegram/webhook" -d "secret_token=$TELEGRAM_WEBHOOK_SECRET".
vercel.json schedules Vercel Cron to hit /api/cron/summary (daily at 00:00
UTC) and /api/queue/drain (daily at 00:30 UTC) in Production. Vercel's Hobby
plan caps cron jobs at once per day, so the schedules are daily; the cron route
windows summaries with a per-chat sched_cursor, so a wider window still covers
every message and nothing is dropped. On the Pro plan you can tighten the
schedules in vercel.json (e.g. hourly), and /api/trigger provides an
on-demand summary for any chat window regardless of plan. Vercel adds
Authorization: Bearer <CRON_SECRET> when CRON_SECRET is configured on the
project, matching the routes' bearer-token check.