The Cart Was Not the Product: Building a HoReCa Procurement Agent for Silpo AI Factory

The obvious idea was also the least interesting one.
The Silpo AI Factory hackathon gave participants an unusually tempting setup: AI, an official MCP server from a large grocery retailer, product search, carts, delivery, purchase history, and the requirement to build an agent that could actually use tools.
The easy version almost writes itself. A user asks what to buy. AI suggests products. The app puts them into a cart.
Useful? Maybe.
Interesting? Not enough.
That shape quickly becomes another AI shopping chat. I wanted to avoid that, partly because the hackathon itself was clearly nudging people toward something more agentic: not text generation around commerce, but a system where the agent receives context, uses tools, and helps complete a concrete task.
The project that came out of that became a HoReCa Procurement Agent: an AI-assisted procurement cockpit for a small restaurant that also handles catering and events.
Links for the project:
- Hackathon: Silpo AI Factory
- Official Silpo MCP: ai-factory.silpo.ua/docs/mcp
- Live demo: horeca-nine-alpha.vercel.app
- Source code: github.com/MaximusFT/horeca
- Demo video: YouTube
The shopping assistant was too small
The first direction was some version of Smart Basket, AI cart, shopping assistant. It is a reasonable idea. It is also the first idea most people would reach for.
That was the problem.
I kept asking one question: if I remove the AI chat, what remains as a product?
For a generic shopping assistant, the answer was not strong enough. The AI was doing too much of the conceptual work. The product was mostly a conversation with a cart mutation at the end.
So we changed the buyer.
What if the customer is not one person deciding what to eat, but a small restaurant or catering business trying to plan procurement?
That changed everything. A grocery cart stopped being a list of preferences. It became the result of an operational problem: regular restaurant demand, bookings, events, guest counts, menus, recipes, inventory, incoming supply, safety stock, shelf life, and supplier availability.
Now the official commerce MCP had a more precise role. It did not define the business need. It helped execute a need the system had already calculated.
That boundary became the project.
Catering was close, but not enough
The first stronger version was catering procurement.
Wedding. 180 guests. Menu. Recipes. Ingredients. Shopping list. Supplier cart.
That already felt much better than a shopping assistant. A single change, like 180 guests becoming 200, can flow through portions, recipe quantities, ingredient demand, packages, and cart preparation.
Good demo.
Still not quite the product.
The flaw was simple: a restaurant continues to exist between events. It still needs vegetables, meat, dairy, eggs, coffee, bread, and all the ordinary supplies of daily operations. Event demand is only one source of demand.
After that correction, the model became much stronger:
Regular restaurant demand + event and catering demand + safety stock - current inventory - incoming supply = procurement need.
Then procurement need becomes: when to buy, which supplier products can satisfy it, what package sizes are available, what is in stock, and what should be prepared for approval.
That is the point where the idea stopped being an event shopping calculator and became a procurement layer for HoReCa.
The cart is not the product. The procurement plan is the product.
The cart is the final execution step. The product is the plan that explains what the business needs, when it needs it, and why.
AI should not do the arithmetic
Once the product model became clearer, another decision followed naturally: most of the important work was not AI work.
Take the hero scenario. A wedding changes from 180 to 200 guests. The menu includes salmon croissants, cheese croissants, ham croissants, skewers, salads, and dessert cups. If each guest gets 0.35 salmon croissant and each croissant uses a known amount of salmon, that is not a prompt. That is arithmetic.
The same is true for BOM expansion, unit conversion, inventory subtraction, FEFO, safety stock, package rounding, procurement dates, and plan diffing.
If an LLM calculates those numbers, the system becomes less reliable, not more intelligent.
So the procurement engine became ordinary TypeScript code. It owns the critical math: restaurant demand, event demand, recipes, ingredients, inventory lots, incoming supply, shortages, purchase dates, packages, and the procurement plan.
The AI got a different job: understand natural-language intent, choose application tools, explain where a number came from, help match internal ingredients to supplier products, evaluate replacements, and orchestrate a sequence of safe tool calls.
AI does not calculate procurement. AI helps operate the procurement system.
That was probably the most important architecture decision in the project.
Dashboard first, agent second
I also did not want the user to open a procurement system in the morning and ask an AI, "so, what is happening today?"
The application should already know.
It should know upcoming events, restaurant load, procurement batches, shortages, expiry risks, incoming supply, and the next actions that need attention. The agent can help explain and act, but the primary interface should be an operations dashboard, not a blank chat box.
So the UI settled into four main sections: Overview, Procurement, Events, and Inventory.
The agent did not become a fifth top-level screen. It became a contextual drawer: ask why a quantity is needed, explain the impact of the wedding change, find a replacement, prepare the next supplier order.
That distinction matters. A chat-first system makes the user discover the product by asking questions. A cockpit should surface the current operating state before the user asks.
Misto Kitchen made the model concrete
To avoid building against abstractions, we created a synthetic restaurant: Misto Kitchen.
One kitchen. Shared inventory. Around 55 seats. Regular restaurant operations. Catering and events. A 14-day planning horizon in the Europe/Kyiv business timezone.
The demo events gave the system real pressure: Birthday Breakfast, Office Lunch, Private Anniversary, Tech Conference, and Wedding. The wedding became the hero flow: 180 guests to 200 guests, total event guests moving from 445 to 465.
That tiny number change was useful because anyone can understand it. A manager gets a call: the wedding will have 200 people, not 180. In a manual process, that means recalculating menu portions, ingredients, inventory coverage, packaging, and supplier orders.
In this system, the change creates a preview first. The user inspects the impact, then approves it. Only after approval does it become Plan v2.
This is where the product became more than a planning spreadsheet. The system could explain what changed and why.
Explainability had to be part of the model
One number is not enough.
If the system says "buy 31.4 kg of chicken," a real procurement manager has an immediate question: why 31.4?
So provenance became part of the domain model. Each procurement line needed to explain its sources: restaurant operations, specific events, current inventory, incoming supply, safety stock, shelf-life rationale, and supplier enrichment state.
The AI can summarize that explanation in human language. But it should not invent it. The source of truth is the deterministic calculation and structured provenance.
This is one of those product decisions that looks like UX until you build it. Then it becomes architecture. If explanation is generated after the fact by the model, you get storytelling. If explanation is part of the calculation, you get auditability.
The first UI was too engine-shaped
After the deterministic core started working, the first noticeable product flaw was not in the math. It was in the Overview.
The screen was too close to the engine. It wanted to show procurement batches, dated ingredient lines, restaurant operating days, and engine status. Those facts were correct, but they did not explain the product to a restaurant manager.
So the Overview had to be rebuilt around the business story:
Restaurant Operations + Events & Catering -> Combined Business Demand -> Procurement Plan -> Supplier Execution.
The first viewport needed to show regular operations, the five events, upcoming deliveries, and attention items. Not because those cards are prettier. Because the user has to understand in 10-15 seconds that the system combines daily restaurant demand and event demand into one procurement plan.
That was a useful reminder: a correct engine does not automatically create a clear product.
There was also a very normal frontend bug. The Ask Misto drawer first looked trapped inside a narrow header. The issue was not the drawer size. A sticky header used backdrop-filter, which changed the containing block for position: fixed descendants. The fix was to render the fullscreen overlay through a React portal into document.body.
Small bug. Good lesson. A feature can be logically correct and still be mounted in the wrong DOM and CSS context.
Mock supplier first, live MCP later
Before live Silpo access was reliable, we built the full supplier flow against a mock supplier.
That was not a fake replacement for integration. It was how we protected the product model from waiting on external access.
The mock supplier covered product mappings for all 38 ingredients, package sizes, prices, availability, deterministic package rounding, surplus and cost, substitution approval, cart preview, cart write, reread, reconciliation, and preservation of unrelated cart lines. The preferred salmon SKU was intentionally unavailable, with a compatible 400 g alternative, so the product could exercise replacement handling.
The architecture was simple on purpose:
Procurement engine -> SupplierGateway -> Mock supplier or Silpo supplier.
The mock stayed as an offline fallback. It should never be described as live Silpo.
When the agent arrived, it was also intentionally small. One Procurement Agent. Not Inventory Agent, Forecast Agent, Supplier Agent, Cart Agent, and Supervisor Agent. For this MVP, that would have been architecture theatre.
The agent worked through application-level tools like get_event, get_procurement_plan, explain_requirement, preview_event_change, apply_event_change, and prepare_supplier_order. It did not see the whole raw MCP catalog. It did not own procurement quantities. Every tool called an application use case with its own schema and approval rules.
Live AI was not the proof
The default agent mode became AGENT_MODE=local.
That sounds less exciting than a live model, but it was the right default for this project. It was deterministic, free, offline, used the same protected application tools, and made the demo stable enough to record.
The OpenAI Responses API path was implemented as an explicit opt-in with a limited number of consecutive tool steps. But the final demo should not be described as a fully autonomous live OpenAI session. The API key was not configured in this workspace, and the protocol was covered with mocked HTTP tests.
What the demo proves is still valuable: the agentic application flow, the tool orchestration, the approval boundaries, and the supplier execution path. But it is important to say what was actually tested.
The official MCP changed the shape of the work
The early generic JSON-RPC client and public docs were enough to understand the direction. They were not enough for a reliable live integration.
After access, the real path required OAuth 2.1: Dynamic Client Registration, authorization code, PKCE S256, refresh token, a session-scoped provider, the official @modelcontextprotocol/sdk, Streamable HTTP transport, and server-side OAuth state.
The rule at that stage was simple: do not invent Silpo arguments, fields, or output shapes.
First, we captured tools/list through an authorized session on September 1, 2026. It returned 40 unique tools, not the 39 suggested by the earlier public description. One additional branch was silpo_create_shopping_cart.
Input draft-07 schemas were saved and validated with Ajv before MCP calls. Output schemas were not treated as known in advance. The mapper only read documented paths and returned safe diagnostics when a shape did not match, without leaking values.
That is where integration work starts to become real. Not at the first successful tool call. At OAuth, schemas, expiry, idempotency, reread, and observability.
Read first, then write
The first live workflow was deliberately read-only:
silpo_get_my_shopping_cart -> silpo_get_shopping_cart_by_id -> silpo_get_time_slots -> silpo_find_products_batch.
Search ran for eggs, tomatoes, and salmon. The workflow stopped before writes if there was no cart, not enough cart context, an invalid delivery slot, or a response shape that did not match documented paths. Express delivery normalized to DeliveryHome, because that is what the product search tool description required.
Only after that came a constrained write spike:
preview -> explicit human approval -> one additive write -> immediate cart reread -> validation.
Existing cart lines could not be cleared or replaced automatically. On September 2, 2026, production confirmed one limited additive write for a test candidate with quail eggs. The sanitized trace showed silpo_add_or_update_cart_products followed by silpo_get_shopping_cart_by_id.
The UI reported a verified result only after reread.
That is not automatic checkout. It is not payment. It is a controlled cart mutation with evidence.
Serverless made state honest
The wedding change flow revealed another boundary.
Locally, an in-memory repository was convenient. In production, a memory singleton is not a storage strategy. Different Vercel workers can handle different requests, so planning state and event previews had to move behind a real repository boundary.
The final approach allowed local development to use in-memory state, while deployed planning state and event previews used Turso. Apply and reread went through the repository boundary, and tests covered preview, apply, and refresh through different repository instances.
That bug did not invalidate the product model. It made the deployment model more honest.
The corporate-machine constraints also shaped the workflow. Local coding, tests, and static checks happened on the work machine. Silpo OAuth and OTP happened in a personal browser. Deployed backend calls handled Silpo MCP. GitHub Actions covered sanitized trace and Turso smoke checks. Secrets and raw cart/product values were not routed through the model or stored in traces.
Not glamorous. Necessary.
Live integration made the mock better
The supplier-neutral gateway eventually connected the same application contract to the official MCP:
UI -> SupplierOrderService -> SupplierGateway -> SilpoSupplierGateway -> official MCP.
The first live rollout was bounded. It did not try to send the whole batch. It selected up to three light, fully fulfillable lines within a ten-line window, with total mass or volume capped at 20 kg/l, far below the observed 50 kg Silpo delivery validation. The cap was checked before preview and before write.
This was a pilot, not a claim that the entire procurement batch could safely go to Silpo.
The gateway accounted for display ratio, step, price, stock, package rounding, product IDs already in the cart, error-level cart validations, and reread after write. After live verification, matching became stock-aware and started skipping SKUs that could not fully satisfy the need.
The salmon replacement case was especially useful. In the mock catalog, the preferred salmon SKU was unavailable, so the offline flow showed a substitution. In live Silpo, silpo_get_replacements for sampled salmon returned success with an empty list.
That empty list was not an error. It meant there was no known picking or assembler risk for that product. The system had to accept reality instead of inventing a replacement for a prettier demo. Unresolved lines blocked cart preview. Unknown nested shapes did not become synthetic replacements. Exact nested replacement mapping would only be added after a separate capture.
The integration became more reliable because it allowed the real response to be boring.
The failures after live verification were the useful ones
Several issues appeared only after the live path existed.
A delivery slot could expire between preparation and apply, so the system needed recovery: reread cart, fetch available slots, preview alternatives, require approval, update the slot, reread the exact slot, and retry supplier preparation.
A repeated demo run could accidentally double the real cart quantity. Reset Demo must not delete real Silpo cart lines, so the gateway now reads existing product IDs before supplier initialization and stops before preview/write if a selected SKU is already present.
Insufficient stock also changed matching. A known product is not enough if there is not enough stock to fulfill the required quantity.
Later, i18n exposed a runtime boundary: a Dictionary with interpolation functions cannot be passed from a Server Component to a Client Component as a plain prop. Typecheck and build passed, but the browser failed. The fix was to pass a plain locale to client components and let them read a client-safe dictionary import. Backend locale also flows into agent explanations and supplier flow.
These are not glamorous failures. They are the difference between a demo that works once and a workflow that can be trusted enough to show.
Observability had to be safe
A normal browser cannot directly show a server-to-server call from Vercel backend to mcp.silpo.ua. It only sees requests to the Next.js routes.
So the demo needed a sanitized Agent request execution timeline.
It combines application decisions, application tool calls, official Silpo MCP calls, sequence number, status, and duration. In mock mode, the timeline shows only application steps. In deployed Silpo mode, it adds blue Silpo MCP entries from the server-side trace.
That mattered because a reviewer needs to see where the application made a decision and where the official supplier tool was actually called. But the trace stays safe: operation names, argument key names, status, duration, and structural summary. Not tokens. Not raw arguments. Not addresses. Not cart contents. Not product values.
What the final demo actually proves
The final demo flow was intentionally short.
Overview shows Restaurant Operations, Events & Catering, Combined Procurement, a 14-day timeline, and attention items. Wedding starts at 180 guests. The user changes it to 200, reviews the preview, sees Plan v1 become Plan v2, and approves. The system updates total guests to 465 and records the change.
Then the user opens Why this quantity? and sees deterministic provenance: demand sources, inventory coverage, incoming supply, and timing. Finally, Ask Misto prepares the next supplier order, shows a bounded supplier card, handles availability/substitution state, creates a cart preview, requires approval, performs an additive write, rereads the cart, and reports a verified result.
The right ending is not "AI ordered everything."
The right ending is: the agent guided the user from a business event change, through an explainable procurement plan, to a verified supplier cart, while keeping business-critical mutations under human control.
Testing followed the same philosophy. Unit and application tests covered demand calculation, BOM expansion, FEFO, incoming supply timing, package rounding, provenance, wedding preview/apply, stale preview protection, agent tool schemas, mock supplier flow, reread reconciliation, live schema validation, timeslot updates, sanitized MCP trace, and Turso repositories.
The handoff baseline had 122 tests in 36 files. Static checks included npm test, npm run typecheck, npm run lint, npm run build, and npm audit, with audit reporting 0 vulnerabilities after adding the MCP SDK.
Browser QA covered the wedding flow, explanation drawer, mock salmon substitution, cart preview -> approval -> reread, agent supplier preparation, expired slot recovery, mobile/tablet navigation at 850 px and 390 px, no page-level horizontal overflow, procurement search, expiry-risk filtering, and localized units.
Production Silpo verification confirmed live OAuth, 40 tools from tools/list, cart context, approved timeslot update, batch product search, one explicitly approved additive product write, immediate reread and validation, a bounded supplier flow through the normal Procurement Agent UI, one silpo_add_or_update_cart_products call followed by silpo_get_shopping_cart_by_id, and preservation of existing cart lines.
It did not prove everything. It did not test all 40 tools, full checkout, payment, full wholesale ordering, every replacement response shape, live OpenAI autonomy, or real restaurant usage.
That distinction is the whole point.
What I would keep
The hardest part of this project was not connecting an LLM to a store.
The hardest part was deciding what work the AI should not do.
The procurement engine calculates. The plan explains. The UI surfaces the operating state. The agent interprets intent and orchestrates tools. The supplier gateway executes bounded supplier operations. MCP is the final execution layer, not the product itself.
That boundary made the project more interesting than another shopping assistant.
Silpo is a connected commerce supplier in this story. I am not presenting it as a full HoReCa wholesale ERP, and the MVP is not a multi-supplier procurement platform. The future architecture could absolutely grow that way: Silpo, Metro, meat suppliers, vegetable suppliers, local suppliers, with AI helping compare availability, delivery, price, quality constraints, and preferences.
But that was not the MVP.
The MVP was narrower and, honestly, stronger because of it: one restaurant, one kitchen, one procurement plan, one agent, one supplier integration, explicit approvals, idempotent writes, reread validation, and a trace that shows what happened without leaking sensitive values.
If there is one thing I would take from the hackathon, it is this:
Real agentic software is not proven by a model calling a tool. It is proven by the system around that tool: deterministic logic, explainable state, preview, approval, idempotency, reread, durable storage, and a clear boundary between application and supplier.
That is where the work became interesting. Not when the cart changed. When the cart became only the last step of a procurement workflow the system could explain.
More than a blog post
I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.