plan_shopping
Plan a multi-store shopping trip near your location by comparing prices across Swiss grocery chains. Get the cheapest plan with alternatives.
Instructions
Plan a multi-store shopping trip near a location, picking the best products across configured Swiss grocery chains. Items can be generic ("milch", "pasta") or pinned to a specific SKU. Returns a primary plan plus alternatives. Use when the user gives a list of items and asks "where should I shop?" or "what's cheapest?". Strategies: single_store (one chain), split_cart (multi-chain with stop penalty), absolute_cheapest (no penalty).
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| items | Yes | The list of items to shop for. At least one item required. | |
| near | Yes | Shopper's location — used to find nearby stores. Pass coordinates, ZIP, or address. | |
| chains | No | Restrict the plan to these chains. Omit to consider all configured chains. | |
| strategy | Yes | single_store: buy everything at one chain (minimises trips). split_cart: allow multiple chains but add splitPenaltyChf per extra stop. absolute_cheapest: pick the cheapest source per item regardless of stops. | |
| splitPenaltyChf | No | Cost in CHF added per extra store stop in split_cart strategy. Default 2.00. | |
| radiusKm | No | Only consider stores within this radius of the provided location (1–50 km). Default 5 km. |
Implementation Reference
- src/tools/plan_shopping.ts:80-123 (handler)The main handler function 'planShoppingHandler' that executes the plan_shopping tool logic. It geocodes the input location, then delegates to the 'plan' service function to compute the multi-store shopping plan.
export async function planShoppingHandler( registry: AdapterRegistry, input: PlanShoppingInput, ): Promise<PlanResult> { const geo = await geocode(input.near as any); if (!geo.ok) { const err = geo.error; if (err.code === 'unknown_zip') { throw new ToolError( 'unknown_zip', `ZIP "${(err as any).zip}" is not in the lookup table`, 'Pass { lat, lng } directly or check that the ZIP is a valid Swiss PLZ (e.g. "8001").', ); } if (err.code === 'address_not_found') { throw new ToolError( 'address_not_found', `Address "${(err as any).query}" could not be geocoded`, 'Try a more specific address or pass a Swiss ZIP code or { lat, lng } coordinates.', ); } if (err.code === 'unavailable') { throw new ToolError( 'unavailable', (err as any).reason, 'The Nominatim geocoding service is temporarily unavailable. Try passing a ZIP or { lat, lng } instead.', ); } throw new ToolError( err.code, 'address_unsupported' in err ? (err as any).reason : err.code, 'Pass a Swiss ZIP code or { lat, lng } coordinates instead of a free-text address.', ); } return plan(registry, { items: input.items, near: { lat: geo.data.lat, lng: geo.data.lng, city: geo.data.city }, chains: input.chains, strategy: input.strategy, splitPenaltyChf: input.splitPenaltyChf, radiusKm: input.radiusKm, }); } - src/tools/plan_shopping.ts:41-76 (schema)The input schema 'planShoppingSchema' (Zod) defining the tool's input: items (array with query, quantity, preferredChain, preferredProductId, filters), near (lat/lng, zip, or address), chains, strategy (single_store/split_cart/absolute_cheapest), splitPenaltyChf, and radiusKm.
export const planShoppingSchema = z.object({ items: z.array(itemSchema).min(1) .describe('The list of items to shop for. At least one item required.'), near: z.union([ z.object({ lat: z.number().describe('Latitude in decimal degrees (WGS 84).'), lng: z.number().describe('Longitude in decimal degrees (WGS 84).'), }).describe('GPS coordinates of the shopper\'s location'), z.object({ zip: z.string().describe('Swiss postal code (PLZ / NPA), e.g. "8001".'), }).describe('Swiss postal code (PLZ), e.g. "8001"'), z.object({ address: z.string().describe('Free-text address string — geocoded via OpenStreetMap Nominatim; prefer zip or lat/lng for speed.'), }).describe('Free-text address — geocoded via Nominatim; prefer zip or lat/lng for speed'), ]).describe('Shopper\'s location — used to find nearby stores. Pass coordinates, ZIP, or address.'), chains: z.array(z.enum(['migros', 'coop', 'aldi', 'denner', 'lidl', 'farmy', 'volgshop', 'ottos'])) .optional() .describe('Restrict the plan to these chains. Omit to consider all configured chains.'), strategy: z.enum(['single_store', 'split_cart', 'absolute_cheapest']) .describe([ 'single_store: buy everything at one chain (minimises trips).', 'split_cart: allow multiple chains but add splitPenaltyChf per extra stop.', 'absolute_cheapest: pick the cheapest source per item regardless of stops.', ].join(' ')), splitPenaltyChf: z.number().nonnegative() .optional() .describe('Cost in CHF added per extra store stop in split_cart strategy. Default 2.00.'), radiusKm: z.number().positive().max(50) .optional() .describe('Only consider stores within this radius of the provided location (1–50 km). Default 5 km.'), }).describe([ 'Plan a multi-store shopping trip near a location, picking the best products across configured Swiss grocery chains.', 'Items can be generic ("milch", "pasta") or pinned to a specific SKU. Returns a primary plan plus alternatives.', 'Use when the user gives a list of items and asks "where should I shop?" or "what\'s cheapest?".', 'Strategies: single_store (one chain), split_cart (multi-chain with stop penalty), absolute_cheapest (no penalty).', ].join(' ')); - src/tools/plan_shopping.ts:9-39 (schema)The item sub-schema defining each shopping list item with query, quantity, preferredChain, preferredProductId (chain+id pin), and filters (tags, maxPrice, sizeRange).
const itemSchema = z.object({ query: z.string().min(1) .describe('Product search term, e.g. "Milch", "pasta integrale". The planner searches this across chains.'), quantity: z.number().int().positive() .optional() .describe('Number of units needed (default 1). Used for total cost calculation.'), preferredChain: z.enum(['migros', 'coop', 'aldi', 'denner', 'lidl', 'farmy', 'volgshop', 'ottos']) .optional() .describe('Prefer this chain for this item when scores are tied. Useful for loyalty card preferences.'), preferredProductId: z.object({ chain: z.enum(['migros', 'coop', 'aldi', 'denner', 'lidl', 'farmy', 'volgshop', 'ottos']) .describe('Chain the pinned product belongs to.'), id: z.string() .describe('Exact product ID to use for this line item; bypasses search.'), }).optional() .describe('Pin a specific product by chain + ID. If set, query is ignored for product selection.'), filters: z.object({ tags: z.array(z.enum(TAG_VALUES)) .optional() .describe('Require all listed tags on matching products, e.g. ["organic", "vegan"].'), maxPrice: z.number().positive() .optional() .describe('Reject products above this CHF price per unit.'), sizeRange: z.object({ minMl: z.number().nonnegative().optional().describe('Minimum size in ml.'), maxMl: z.number().nonnegative().optional().describe('Maximum size in ml.'), }).optional() .describe('Size range filter in millilitres; useful for beverages.'), }).optional() .describe('Optional product constraints applied when searching for this item.'), }).describe('A single item in the shopping list.'); - src/index.ts:111-121 (registration)Tool registration in the TOOLS array: name 'plan_shopping' with description, schema (planShoppingSchema), and handler (planShoppingHandler).
{ name: 'plan_shopping', description: [ 'Plan a multi-store shopping trip near a location, picking the best products across configured Swiss grocery chains.', 'Items can be generic ("milch", "pasta") or pinned to a specific SKU. Returns a primary plan plus alternatives.', 'Use when the user gives a list of items and asks "where should I shop?" or "what\'s cheapest?".', 'Strategies: single_store (one chain), split_cart (multi-chain with stop penalty), absolute_cheapest (no penalty).', ].join(' '), schema: planShoppingSchema, handler: planShoppingHandler, }, - src/services/planner.ts:39-157 (helper)The 'plan' function that orchestrates the shopping plan: fans out store searches per chain, fans out product searches per item×chain, applies canonicality filtering, then calls 'solve' for the primary strategy and alternatives.
export async function plan(registry: AdapterRegistry, input: PlanInput): Promise<PlanResult> { const adapters = registry.list(input.chains).filter((a) => a.capabilities.productSearch); const radius = input.radiusKm ?? 5; const splitPenalty = input.splitPenaltyChf ?? 2.0; const errors: ChainError[] = []; // 1. Fan out store search per chain const storeResults = await Promise.all( adapters.map(async (a) => ({ adapter: a, result: await a.searchStores({ near: input.near, radiusKm: radius, cityHint: input.near.city }), })), ); const storeByChain: Partial<Record<Chain, NormalizedStore>> = {}; for (const { adapter, result } of storeResults) { if (!result.ok) { errors.push({ chain: adapter.chain, code: result.error.code, reason: 'reason' in result.error ? result.error.reason : undefined }); continue; } if (result.data.length === 0) continue; const sorted = [...result.data].sort((a, b) => haversineKm(input.near, a.location) - haversineKm(input.near, b.location)); storeByChain[adapter.chain] = sorted[0]; } // 2. Fan out product search per (item × chain) const matrix: Matrix = {}; for (const item of input.items) matrix[keyOf(item)] = {}; await Promise.all( adapters.flatMap((adapter) => input.items.map(async (item) => { if (storeByChain[adapter.chain] === undefined && adapter.capabilities.storeSearch) { // No store found for this chain. Still attempt product search so that // a failing adapter registers in errors; on success, skip (no local store). const probe = await adapter.searchProducts({ query: item.query, limit: 1 }); if (!probe.ok) { if (!errors.some((e) => e.chain === adapter.chain)) { errors.push({ chain: adapter.chain, code: probe.error.code, reason: 'reason' in probe.error ? probe.error.reason : undefined }); } } matrix[keyOf(item)][adapter.chain] = null; return; } const r = await adapter.searchProducts({ query: item.query, tags: item.filters?.tags, maxPrice: item.filters?.maxPrice, sizeRange: item.filters?.sizeRange, limit: 20, }); if (!r.ok) { if (!errors.some((e) => e.chain === adapter.chain)) { errors.push({ chain: adapter.chain, code: r.error.code, reason: 'reason' in r.error ? r.error.reason : undefined }); } matrix[keyOf(item)][adapter.chain] = null; return; } matrix[keyOf(item)][adapter.chain] = matchProduct(item, r.data); }), ), ); // Canonicality filter: for each item, if at least one chain has a product // whose category contains a query token/synonym, drop all non-canonical chains // to null for that item. This prevents tangential products (e.g. Apfelschorle // for "apfel") from winning cross-chain comparisons against real apples. for (const item of input.items) { const key = keyOf(item); const offers = matrix[key]; if (!offers) continue; const canonical: typeof offers = {}; let hasCanonical = false; for (const [chain, product] of Object.entries(offers) as [Chain, NormalizedProduct | null | undefined][]) { if (product && isCanonical(product, item)) { canonical[chain] = product; hasCanonical = true; } } if (hasCanonical) { // Replace the matrix row with only canonical matches. // Tangential chains become null for this item. for (const chain of Object.keys(offers) as Chain[]) { if (!canonical[chain]) offers[chain] = null; } } // If no chain has a canonical match, leave the row alone (best-effort fallback). } const completedStoreByChain = Object.fromEntries( Object.entries(storeByChain).filter(([_, v]) => v !== undefined), ) as Record<Chain, NormalizedStore>; const primaryPlan = solve(input.strategy, input.items, matrix, { splitPenaltyChf: splitPenalty, storeByChain: completedStoreByChain, }); // Build alternatives const altStrategies: Strategy[] = []; if (input.strategy !== 'single_store') altStrategies.push('single_store'); if (input.strategy !== 'split_cart') altStrategies.push('split_cart'); if (input.strategy !== 'absolute_cheapest') altStrategies.push('absolute_cheapest'); const alternatives: PlanWithMeta[] = altStrategies .map((s) => solve(s, input.items, matrix, { splitPenaltyChf: splitPenalty, storeByChain: completedStoreByChain, })) .filter((p) => p.stops.length > 0) .slice(0, 2); return { primary: { ...primaryPlan, unavailableChains: errors.length ? errors : undefined }, alternatives, }; }