The chef or kitchen manager manually compares a spreadsheet of past supplier orders against current inventory pars every week. This takes 2-3 hours and often leads to over-ordering (waste) or under-ordering (emergency runs). With labor tight, this back-office chore pulls the chef away from the line. A simple, automated par-based ordering system would save time and reduce food cost.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial builds a Supplier Order Optimizer for small restaurant groups — a Next.js 16 App Router API that reads XLSX spreadsheets of inventory pars and usage history, uses LLM reasoning (via the Vercel AI SDK) to generate optimized order quantities, caches results to save on API costs, and persists order history in agent memory. By the end, you’ll have a working API that accepts a spreadsheet upload and returns ready-to-order suggestions.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
An OpenAI API key with access to a GPT model (the LLM powers order suggestions)
A Redis instance (local or remote) for caching — redis://localhost:6379 is the default
A Langfuse account (free tier works) for observability tracing
Basic familiarity with TypeScript, Next.js App Router, and async/await
Step 1: Scaffold the project
Create a directory and set up the Next.js project with pnpm.
terminal
mkdir agnostic-supplier-order-par-agent && cd agnostic-supplier-order-par-agent
Expected output: pnpm creates node_modules/ and pnpm-lock.yaml with all 25+ packages resolved.
Step 2: Set up environment variables
All configuration comes from environment variables. Create .env.example as a template:
env
# Env vars used by agnostic-supplier-order-par-agent.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-key>LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_BASE_URL=https://cloud.langfuse.comREDIS_URL=redis://localhost:6379LLM_MODEL=gpt-5.2-miniMAX_RETRY_ATTEMPTS=3CACHE_TTL_SECONDS=3600
Create a config module that reads these at startup:
Expected output: All interfaces are exported and ready to import across the project.
Step 4: Create the observability layer
Every optimization run gets traced through Langfuse. Start with a lightweight wrapper:
ts
// src/lib/observability.tsimport { Langfuse } from "langfuse";import { config } from "./config.js";export let langfuse!: Langfuse;export function initObservability(): void { langfuse = new Langfuse({ publicKey: config.langfusePublicKey, secretKey: config.langfuseSecretKey, baseUrl: config.langfuseBaseUrl, });}export function createTrace(name: string) { return langfuse.trace({ name });}export function createSpan( trace: ReturnType<typeof langfuse.trace>, name: string, input?: unknown,) { return trace.span({ name, input });}
Expected output: Calling initObservability() once at startup creates a global Langfuse client. createTrace and createSpan return objects you can .end() when the operation finishes.
Step 5: Build the spreadsheet service
The spreadsheet service reads XLSX files and parses three sheets: par_levels, usage_history, and past_orders. Each row is validated with Zod.
Expected output: A buffer with three sheets (par_levels, usage_history, past_orders) parses into a complete OptimizationRequest. Rows that fail Zod validation are skipped with a warning — the function never throws on partial data.
Step 6: Build the memory service
Optimization results, par levels, and supplier data are stored in agent memory. This service wraps @reaatech/agent-memory-core for typed access.
ts
// src/services/memory-service.tsimport { Memory, MemoryType, MemoryImportance, MemorySource, MemoryLifecycle, withRetry,} from "@reaatech/agent-memory-core";import { InMemoryMemoryStorage, MemoryQuery } from "@reaatech/agent-memory-storage";import { ContextInjector, RetrievalStrategy } from "@reaatech/agent-memory-retrieval";import { config } from "../lib/config.js";import { InMemoryTaskStore } from "@reaatech/a2a-reference-persistence";import type { TaskStatus } from "@reaatech/a2a-reference-core";import type { OptimizationResult, ParLevel, Supplier } from "../lib/types.js";export class
Expected output:MemoryService stores and retrieves data from in-memory storage. Every method uses withRetry so transient failures (like a Redis connection blip) automatically retry up to the configured maxRetryAttempts.
Step 7: Build the cache service
LLM responses can be expensive. The cache service wraps @reaatech/llm-cache with Redis as primary storage and an in-memory fallback.
Expected output: The constructor tries to connect to Redis. If it fails, it degrades gracefully to in-memory-only caching and logs a warning. healthCheck() returns true only when both storage and vector storage are healthy.
Step 8: Build the LLM service
This is the reasoning core. It calls the OpenAI-compatible LLM with a structured output schema to generate order suggestions.
ts
// src/services/llm-service.tsimport { generateText, Output } from "ai";import { openai } from "@ai-sdk/openai";import { z } from "zod";import { withRetry } from "@reaatech/agent-memory-core";import type { OptimizationRequest, OptimizationResult, OrderSuggestion } from "../lib/types.js";import { config } from "../lib/config.js";export const OrderSuggestionSchema = z.object({ itemName: z.string(), suggestedOrderQuantity: z.number().int().min(0), reason: z.string(),
Expected output: The LLM receives the full request (par levels, usage history, past orders) as JSON in the user message. It returns a structured object validated against OptimizationResponseSchema. Each suggestion is then enriched with itemId, currentStock, parLevel, and unitCost from the request data. If the LLM call fails after all retries, the function throws LLMServiceError.
Step 9: Build the par optimizer
The optimizer orchestrates everything — spreadsheets, cache, LLM, memory, and observability — into a single entry point.
ts
// src/services/par-optimizer.tsimport { OptimizationRequest, OptimizationResult, OrderSuggestion } from "../lib/types.js";import { generateOrderSuggestions } from "./llm-service.js";import { MemoryService } from "./memory-service.js";import { CacheService } from "./cache-service.js";import { parseOptimizationRequest, generateOrderSpreadsheet } from "./spreadsheet-service.js";import { initObservability, createTrace, createSpan } from "../lib/observability.js";import { config } from "../lib/config.js";import { withRetry } from "@reaatech/agent-memory-core";const memoryService = new MemoryService();const cacheService = new
Expected output:runOptimization(buffer, supplierId) accepts a raw XLSX buffer and parses it. runOptimizationFromParLevels(request) works with structured JSON data directly. Both go through the same pipeline: check cache -> call LLM -> cache result -> persist to memory. If the LLM fails, a deterministic fallback computes max(0, parLevel - currentStock).
Step 10: Create the API routes
The API exposes three endpoints. Each lives in app/api/<name>/route.ts following the Next.js App Router convention.
Expected output:POST /api/optimize-order accepts either multipart/form-data (with a file field for XLSX uploads and an optional supplierId) or application/json (with the OptimizationRequest shape directly). Returns 200 with the optimization result, 400 for missing input, 422 for corrupt spreadsheets, or 500 for internal failures.
Step 11: Wire the entry point
The src/index.ts re-exports everything as a public API surface:
ts
// src/index.tsexport { runOptimization, runOptimizationFromParLevels, generateOrderSpreadsheet, initializeApp } from "./services/par-optimizer.js";export { MemoryService } from "./services/memory-service.js";export { CacheService } from "./services/cache-service.js";export type { OptimizationRequest, OptimizationResult, OrderSuggestion, ParLevel, Supplier, InventoryItem, UsageRecord, PastOrder } from "./lib/types.js";
Expected output: Consumers can import { runOptimization } from "agnostic-supplier-order-par-agent" and get the full pipeline, or import types separately.
Step 12: Run the quality gate
Now verify everything compiles, lints, and passes tests:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:typecheck exits 0 with no TypeScript errors, lint exits 0 with no ESLint violations, and test exits 0 reporting all tests passing with coverage above 90% across lines, branches, functions, and statements.
Add a UI dashboard — build a React frontend on the App Router pages that lets the chef upload spreadsheets and see order suggestions in a table rather than a JSON response.
Connect real Redis and PostgreSQL — swap the in-memory memory storage and Redis adapter for production instances. The CacheService already accepts a RedisAdapter by default.
Add multi-tenant support — the tenantId parameter is already threaded through every memory method. Build an auth layer that scopes data per restaurant group.
Schedule recurring optimizations — add a cron job that pulls the latest pars from inventory, runs the optimizer, and emails the order suggestions to the supplier.
Add supplier-specific pricing — extend the Supplier type with item-level pricing and let the optimizer pick the cheapest supplier per ingredient.
"You are a restaurant inventory optimiser. Given par levels, usage history, and past orders, compute the optimal order quantity for each item. Formula: suggestedOrder = max(0, parLevel - currentStock + forecastDemand). Explain each suggestion.";
let object: z.infer<typeof OptimizationResponseSchema>;