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 + SGSTis exactly the grand total. - Multi-turn bills. A draft builds across messages and only
finalize_billtouches stock, so edits are free until the moment of commit. - Idempotency. Telegram redelivers updates, so every
update_idis recorded and repeats ignored;finalize_billandkhata_paycarry 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.