plan_shopping
Compare prices across Swiss grocery chains and generate an optimal shopping plan for your list, choosing the cheapest single or multi-store route near your location.
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)Core handler function that geocodes the location input and delegates to the planner service.
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-78 (schema)Zod schema defining the input structure: items array, near location (lat/lng, zip, or address), chains filter, strategy, 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(' ')); export type PlanShoppingInput = z.infer<typeof planShoppingSchema>; - src/index.ts:111-121 (registration)Registration of the plan_shopping tool in the TOOLS array, linking its name, description, Zod schema, and handler.
{ 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 planner service that fans out store searches and product searches across chains, applies canonicality filtering, solves the strategy, and returns primary + alternative plans.
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, }; } - src/tools/errors.ts:1-10 (helper)Custom error class used by the handler to throw structured errors with a code, message, and hint.
export class ToolError extends Error { constructor( public readonly code: string, message: string, public readonly hint?: string, ) { super(message); this.name = 'ToolError'; } }