File No. 002/ PROJECT KHATA

Supermarket Ops Agent

Status
IN PROGRESS
Period
2026 — PRESENT
Role
Solo builder — agent, tools & infra

A conversational agent that runs a small Indian kirana store end-to-end through chat — inventory, GST-correct billing, credit ledger and reporting — built on the Claude Agent SDK with a real tool-calling control loop instead of a hand-rolled intent router.

A shopkeeper should not have to learn software to run a shop. This is an agent you talk to the way you would talk to an assistant behind the counter — receive stock, build a bill across several messages, put it on someone’s khata, ask what sold last week — and it does the work against a real database with real guardrails.

The problem

The obvious way to build this is an intent router: match the message against a list of patterns, dispatch to a handler. That approach breaks the moment a sentence carries two intents, or an item is named loosely, or the owner changes their mind halfway through a bill. It was also an explicit fail condition for this build.

The approach

The Claude Agent SDK supplies the control loop — observe, reason, act, feed the result back — so the model chooses tools from their descriptions rather than a regex choosing them from the text. Store functions are exposed as in-process MCP tools, so there is no separate tool server to run, and a PreToolUse hook gives one choke point to sandbox the agent to store-only tools and tag the destructive ones for confirmation.

Eighteen tools across six categories: inventory, billing, khata, reporting, preferences, documents. Two conventions do most of the work:

  • Every tool returns {"ok": bool, ...}. Expected failures — no match, oversell, overpayment, below-cost, ambiguity — come back as data rather than exceptions, so the agent can relay or reason about them instead of crashing.
  • Business logic lives in the tools, never in the prompt. The oversell guard, GST maths, CGST/SGST split, idempotency and below-cost refusal are enforced at the database layer, so a misbehaving prompt cannot bypass them.

Fuzzy matching is asymmetric on purpose. Reads resolve loosely (exact → substring → difflib) and return the candidate list when a name is ambiguous, so the agent asks instead of guessing. khata_charge is deliberately conservative — exact and substring only — because it can create an account, and loose similarity there risks crediting the wrong person or opening a duplicate.

The war story

Two concurrent stock-ins, +5 and +7 onto 10, landed at 15 instead of 22 in about a quarter of runs. Genuine, silent corruption.

add_stock was doing a read-modify-write in Python — product.qty_on_hand += qty. Python’s sqlite3 driver runs the preceding SELECT outside the write transaction, so both threads read 10 and one increment was simply lost. The fix was to express the change as an atomic SQL UPDATE as the first statement of the transaction, mirroring what finalize_bill already did:

SET qty_on_hand = qty_on_hand - :q WHERE qty_on_hand >= :q

That single pattern turns out to be the whole oversell guard as well: the losing writer’s UPDATE matches zero rows, raises, and rolls the entire finalize back — so stock can never go negative or partially commit. khata_charge and khata_pay had the identical Python-side flaw and were hardened the same way, with khata_pay taking a WHERE balance >= :amount guard against overpayment. A unique(name) constraint backstops the first-time-creation race, and the losing insert retries as a charge on the existing account.

The more interesting half of the conclusion is where the fix should not be applied. set_preference needs none of it: it is a plain upsert that sets a value rather than doing arithmetic on the existing one, so two concurrent writes resolving last-writer-wins is the correct semantics. There is no sum to lose. Applying the pattern everywhere would have been cargo cult.

The rule that came out of it: never read-then-write a contended numeric field in Python — express the change as an atomic SQL UPDATE. SQLite in WAL mode with a busy timeout is genuinely sufficient for a single-instance shop, but only under that discipline. Multiple app instances would mean moving to Postgres and row-level locking.

The rest of the hard parts
  • Grounding. Prices, GST slabs and stock always come from the database via tools; the system prompt forbids inventing a product or a price.
  • GST correctness. Each line’s slab is snapshotted at add-time, tax is halved into CGST and SGST so the two always sum to the line tax, and everything is integer paise, so subtotal + CGST + SGST is exactly the grand total.
  • Multi-turn bills. A draft builds across messages and only finalize_bill touches stock, so edits are free until the moment of commit.
  • Idempotency. Telegram redelivers updates, so every update_id is recorded and repeats ignored; finalize_bill and khata_pay carry idempotency keys, so a replayed money-move is a no-op.
  • Documents. A GST-correct tax invoice with per-line HSN codes and a six-slide analysis deck with matplotlib charts. Noto Sans is vendored and registered explicitly so the ₹ glyph renders identically on the deployed host rather than depending on whatever fonts happen to be installed there.
The outcome

Deployed on Railway as a polling Telegram bot. The concurrency guarantees are held by threaded tests — two bills finalising at once, two stock-ins, two khata charges on a new customer, two payments that would together overpay — run under stress rather than as sequential calls.

Outstanding: the demo recording.

Apparatus
  • Claude Agent SDK
  • Python
  • SQLite (WAL)
  • SQLAlchemy
  • Telegram Bot API
  • Matplotlib
  • ReportLab / PDF
  • python-pptx
  • Railway
Back to the room