A Telegram-only conversational agent that runs an Indian kirana / supermarket store end-to-end. Receives stock, cuts GST-correct bills, runs khata (customer credit), checks stock, closes the day, and generates PDF invoices and PPTX analysis decks.
Live bot: @kiranaBuddyBot (message it on Telegram)
| Layer | Choice |
|---|---|
| Agent harness | Vercel AI SDK (ai v7) |
| Model | Google Gemini via @ai-sdk/google |
| Hosting | Vercel Function one webhook POST /api/telegram |
| Database | Neon Postgres |
Because Vercel functions are stateless, all state lives in Postgres such as stock, bills, khata, preferences, and the conversation history + processed update-ids. This provides durable persistence and idempotency across restarts.
api/telegram.ts (webhook) → dedupe update_id → runAgent
(src/lib/agent/agent.ts) → generateText({ model, system, messages, tools, stopWhen: stepCountIs(10) }) → persist the turn → reply via Telegram. History + preferences are reloaded
from Postgres each call; the system prompt (prompt.ts) injects the owner's remembered settings.
Tools are thin, Zod-validated, and each enforces its own rule inside a DB transaction. The model orchestrates; business rules live in the tools, not the prompt.
- inventory (
tools/inventory.ts):add_product,receive_stock,get_stock,list_products,low_stock - billing (
tools/billing.ts):add_bill_items(preferred, multi-line),add_bill_item,edit_bill_item,remove_bill_item,view_bill,finalize_bill, includes strict validation:- Upfront stock checks: Rejects adding items if requested quantity exceeds stock (returns
insufficient_stock). - Packaged item checks: Validates non-loose items (
isLoose: false) to ensure quantities are whole numbers (integers), rejecting fractional quantities (returnsinvalid_quantity).
- Upfront stock checks: Rejects adding items if requested quantity exceeds stock (returns
- khata (
tools/khata.ts):khata_charge,khata_payment,khata_balance - analytics (
tools/analytics.ts):sales_summary,close_day - memory (
tools/memory.ts):set_preference,get_preferences; allows open-ended preferences with descriptive keys and coherent, self-contained values. - documents (
tools/documents.ts):generate_invoice_pdf,generate_analysis_pptx
A free-text product resolver (domain/products.ts) scores catalog matches; ties come back as
ambiguous so the model asks "which atta ; Aashirvaad 5kg or loose?" rather than guessing.
- Grounding ; every price/slab/stock comes from a tool DB read; the prompt forbids inventing
data; unknown items return
not_foundand the model asks. - Oversell guard ; Checked at draft time (in
add_bill_item/edit_bill_itemusingapplyItem) and transactionally enforced at finalize withUPDATE products SET qty = qty - $n WHERE id = $id AND qty >= $n RETURNING. A 0-row result rolls back the whole bill. This is fully business-logic enforced in the tool layer, not the prompt. - GST correctness ;
domain/gst.ts(pure, unit-tested): per-item HSN + slab, CGST = SGST = slab/2, round-half-up per line, tax grouped by slab on the bill. Seed data uses real HSN codes and slabs (0/5/12/18%). - Multi-turn bills ; the bill is a
draftrow; add/edit/remove mutate it; stock only moves atfinalize_bill. - Idempotency ; Telegram
update_idis claimed viaprocessed_updates(insert-on-conflict-do- nothing); redeliveries are skipped.finalize_billis a one-waydraft → finaltransition, so a retried finalize returns the same bill unchanged. - Concurrency ;
finalize_billruns in a Neon WebSocket-pool transaction withSELECT … FOR UPDATEon the bill and products, plus the atomic conditional decrement. Two bills, or a sale + a stock-in, can't corrupt or negative stock. - Guardrails ; below-cost sale →
needs_confirmation(model must reconfirm withallow_below_cost); khata payment with no account →no_account; no destructive deletes. - Real artifacts ;
pdfkitGST invoice (shop/GSTIN header, HSN, CGST/SGST breakup, amount in words) and apptxgenjsdeck (KPIs, top-items bar chart, payment-mix pie), sent via TelegramsendDocument. - Memory across sessions ; Custom preferences are fully open-ended. The model saves self-contained, coherent statements (e.g.,
"Okay with either Ghee or Butter, whichever is in stock") under descriptive keys. The prompt builder (prompt.ts) dynamically iterates through and injects all saved preferences into system instructions./newclears chat messages but leaves preferences intact. - Duplicate checks on creation ;
add_productverifies duplicates by performing an exact case-insensitive match on name, brand, and pack size, avoiding fuzzy-token search overlap issues (allowing variants likeAashirvaad Atta 10kgandAashirvaad Atta 5kgto coexist).
GST base assumption: a product's sell_price is the GST-exclusive taxable value; tax is
added on top so the bill shows a clean breakup. (An MRP-inclusive back-calc is a documented
alternative.)
- Telegram bot ; talk to @BotFather,
/newbot, copy the token. - Gemini key ; Google AI Studio.
- Neon Postgres ; neon.tech → copy the pooled
DATABASE_URL.
npm install
cp .env.example .env.local # fill in the 4 secrets + a random TELEGRAM_WEBHOOK_SECRET
npm run db:push # create tables in Neon
npm run db:seed # load realistic SKUs
npm run test # GST engine unit tests
npm run typecheck # full type check
npm run smoke # OPTIONAL: drive the agent end-to-end without Telegram# 1. Push to GitHub, import into Vercel (Hobby), set the same env vars in the dashboard.
# 2. After the first deploy, point Telegram at it:
PUBLIC_URL=https://your-app.vercel.app npm run set-webhookThen message the bot.
50 packets of Maggi came in, cost ₹12, MRP ₹14make a bill: 2kg loose atta, 1 Aashirvaad atta 5kg, 4 Maggi, 1 Amul butter, UPI→drop the butter, make it 6 Maggiadd atta(alone, no brand) → agent asks "Aashirvaad 5kg or loose?" instead of guessing- bill more than is in stock → refused
put ₹500 on Ramesh's credit·Ramesh paid ₹300·Ramesh's balance?today's sales/close the daysend me that bill as a PDF·make this week's analysis deckalways assume UPI unless I say cash→/new→ bill without a stated mode → still UPI
api/telegram.ts the ONLY web surface ; Vercel Function webhook
src/lib/
agent/ agent.ts, prompt.ts, tools/* the control loop + tool surface
domain/ gst.ts, products.ts, billing.ts, preferences.ts, history.ts, idempotency.ts
db/ schema.ts, client.ts, seed.ts Drizzle + Neon
telegram.ts, env.ts
scripts/ set-webhook.ts, smoke.ts
vercel.json declares the function (maxDuration 60s)
