---
title: "Cookbook: Shopping cart"
type: cookbook
tags: [cookbook, extraction, choice, loop, fan-out]
created: 2026-10-31
updated: 2026-11-03
confidence: high
sources:
  - raw/docs/cookbooks__cart_from_primitives.md
  - raw/docs/cookbooks.md
jev_version: "jev-1.14.0"
sdk_python: "0.7.5"   # the cookbook installs cooksafe>=0.2.0,<0.3.0, which requires typesafe-sdk>=0.7.0,<0.8.0
summary: "Turn a cafe order sentence into a cart: a loop of Choice questions over the not-yet-found menu items plus nothing_else finds each item, then one request per item fills its fields. jev-1.14.0."
---

# Cookbook: Shopping cart

> **TL;DR** A `Choice` returns one value, not a cart, so the cart is built in two stages. Stage 1 loops: each request sends the whole order with a `Choice` over the menu items not found yet plus a `nothing_else` sentinel; your code saves the pick, drops it from the next request's options, and stops on `nothing_else` (or after `MAX_ITEM_REQUESTS = 6`). Stage 2 sends one request per found item with one `Choice` per field (each with an `unspecified` option); items with no fields need no request. On "Can I get a large oat milk latte and a blueberry muffin please" (`jev-1.14.0`, 2026-10-29) it found `latte` (0.99) and `blueberry_muffin` (1.00) in three item requests, then `size` `large` and `milk` `oat` (1.00 each).

## Goal

Turn one natural-language order into a structured cart with separate items and fields, using only single-value answers. The demo behind it is TypeSafe's shopping-cart video (linked from the cookbook); the cookbook runs the TypeSafe part on a four-item cafe menu and one hard-coded order:

```text
latte
  size: large
  milk: oat
blueberry_muffin
```

Upstream lists it under Extraction, level Beginner ([[cookbooks/overview]]). It shows two things an agent building with Jev needs often: how to find an **unknown number** of things with a question that returns one answer (a shrinking-options loop), and how to fill several known fields at once ([[patterns/fan-out]]).

When to use it: a fixed catalog (menu, product list, form schema) whose items and field values you can list as options, and inputs that mention a handful of them. When not to: free-form values (names, addresses, arbitrary numbers) that you cannot enumerate as options, or quantities and prices you need to compute (see Gotchas; arithmetic is a documented weak spot, [[concepts/jaggedness-jev-1-13]]).

## Inputs / state shape

The state of every request is the order string itself, unchanged from request to request; only the question changes ([[concepts/state]]):

```text
Can I get a large oat milk latte and a blueberry muffin please
```

The menu lives in code. Each item's description carries the words a customer would use, and `fields` is the item's own schema: each field is a list of allowed values, which become that field's `Choice` options. Only the latte has fields.

```python
MENU = {
    "latte": {
        "description": "Espresso with steamed milk. Also ordered as a flat white or a milky coffee.",
        "fields": {
            "size": ["small", "medium", "large"],
            "milk": ["whole", "oat", "almond"],
        },
    },
    "filter_coffee": {
        "description": "Plain brewed drip coffee, sometimes ordered as a black coffee.",
        "fields": {},
    },
    "blueberry_muffin": {
        "description": "A blueberry muffin.",
        "fields": {},
    },
    "almond_croissant": {
        "description": "An almond croissant, sometimes ordered as a pastry.",
        "fields": {},
    },
}

ORDER = "Can I get a large oat milk latte and a blueberry muffin please"
```

Constants: `TYPESAFE_MODEL = "jev-1.14.0"`, `NOTHING_ELSE = "nothing_else"`, `MAX_ITEM_REQUESTS = 6`, `UNSPECIFIED = "unspecified"`; the client is built with `model=TYPESAFE_MODEL` and `timeout=30.0`. Numbers below came from `jev-1.14.0` on 2026-10-29 (the cookbook's first capture, 2026-10-31; no earlier run).

## Questions asked

**Stage 1, one per request** (key `item`):

| key | type | `instructions` | `criteria` |
|---|---|---|---|
| `item` | `Choice` | `This is a cafe order. Items already accounted for: {already}. Which remaining menu item does the order mention? If the order mentions more than one remaining item, choose the one mentioned first.` (`{already}` is the found names joined with ", ", or `nothing yet`) | one option per remaining menu item, its `description` as the criteria; plus `nothing_else`: "The order mentions no menu item other than the ones already accounted for." |

A `Choice` must select an option, so the sentinel is what lets the loop stop.

**Stage 2, one request per item with fields** (one key per field, here `size` and `milk`):

| key | type | `instructions` | `criteria` |
|---|---|---|---|
| `{field}` | `Choice` | `Which {field} did the order ask for, for the {item_name}?` | each allowed value with `None` as its description; plus `unspecified`: "The order does not say which {field} for the {item_name}." |

`unspecified` means a field the customer did not mention gets that option instead of a forced concrete value.

## Combining logic in code

Upstream code, verbatim except the setup lines folded in:

```python
import os
from pathlib import Path

from cooksafe import JsonCache
from typesafe_sdk import Choice, TypeSafeClient

TYPESAFE_MODEL = "jev-1.14.0"
NOTHING_ELSE = "nothing_else"  # the sentinel option that ends the item loop
MAX_ITEM_REQUESTS = 6  # a hard stop, so a bad answer can never loop forever

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),  # cached re-renders need no key
    model=TYPESAFE_MODEL,
    timeout=30.0,
)
json_cache = JsonCache(Path("json_cache.json"))


def fmt_confidence(confidence: float | None) -> str:
    """A confidence for printing. The API can return None, so never format it blind."""
    return f"{confidence:.2f}" if confidence is not None else "n/a"


# MENU and ORDER as in "Inputs / state shape" above


def item_question(remaining: dict[str, dict], found: list[str]) -> Choice:
    """Build one request's question from the items not found yet."""
    already = ", ".join(found) if found else "nothing yet"
    criteria = {name: item["description"] for name, item in remaining.items()}
    criteria[NOTHING_ELSE] = (
        "The order mentions no menu item other than the ones already accounted for."
    )
    return Choice(
        instructions=(
            f"This is a cafe order. Items already accounted for: {already}. "
            "Which remaining menu item does the order mention? If the order mentions more "
            "than one remaining item, choose the one mentioned first."
        ),
        criteria=criteria,
    )


@json_cache
def ask_item(order: str, question: dict) -> dict:
    """One item request. The question arrives as its API JSON, which also keys the cache."""
    answer = client.system_one(state=order, questions={"item": question}).choices["item"]
    return {"choice": answer.choice, "confidence": answer.confidence}


def find_items(order: str) -> list[dict]:
    remaining = list(MENU)
    found: list[dict] = []
    item_requests = 0
    for _ in range(MAX_ITEM_REQUESTS):
        if not remaining:
            break
        question = item_question(
            {name: MENU[name] for name in remaining},
            [item["name"] for item in found],
        )
        result = ask_item(order, question.model_dump(mode="json", exclude_none=True))
        item_requests += 1
        print(
            f"item request {item_requests}: {len(remaining)} items + sentinel "
            f"-> {result['choice']} (confidence {fmt_confidence(result['confidence'])})"
        )
        if result["choice"] == NOTHING_ELSE:
            break
        remaining.remove(result["choice"])
        found.append({"name": result["choice"], "confidence": result["confidence"]})
    return found


UNSPECIFIED = "unspecified"


def field_questions(item_name: str) -> dict[str, Choice]:
    questions = {}
    for field, values in MENU[item_name]["fields"].items():
        criteria = {value: None for value in values}
        criteria[UNSPECIFIED] = (
            f"The order does not say which {field} for the {item_name}."
        )
        questions[field] = Choice(
            instructions=f"Which {field} did the order ask for, for the {item_name}?",
            criteria=criteria,
        )
    return questions


@json_cache
def ask_fields(order: str, questions: dict[str, dict]) -> dict:
    """One field request. The questions arrive as their API JSON, which also keys the cache."""
    response = client.system_one(state=order, questions=questions)
    return {
        field: {"choice": answer.choice, "confidence": answer.confidence}
        for field, answer in response.choices.items()
    }


def fill_fields(order: str, item_name: str) -> dict:
    if not MENU[item_name]["fields"]:
        return {}
    questions = {
        field: question.model_dump(mode="json", exclude_none=True)
        for field, question in field_questions(item_name).items()
    }
    return ask_fields(order, questions)


items = find_items(ORDER)
cart = []
for item in items:
    fields = fill_fields(ORDER, item["name"])
    cart.append(
        {"name": item["name"], "confidence": item["confidence"], "fields": fields}
    )
```

The loop ends on any of three conditions: the sentinel is selected, the menu runs out, or the cap is reached. Each found item leaves the option list, so it can be found at most once. Assembling and printing the cart makes no TypeSafe calls; it only reads the stored single-value answers.

## Results / what the cookbook reports

All numbers from `jev-1.14.0` (2026-10-29), one order:

```
item request 1: 4 items + sentinel -> latte (confidence 0.99)
item request 2: 3 items + sentinel -> blueberry_muffin (confidence 1.00)
item request 3: 2 items + sentinel -> nothing_else (confidence 1.00)

found 2 items: ['latte', 'blueberry_muffin']
```

```
order: "Can I get a large oat milk latte and a blueberry muffin please"

latte  (item confidence 0.99)
    size   large        confidence 1.00
    milk   oat          confidence 1.00
blueberry_muffin  (item confidence 1.00)
```

That is four requests in total for this order: three item requests and one field request for the latte; the muffin has no fields (count inferred from the code and output). The cookbook reports no accuracy over a set of orders, no latency and no cost; it is a single worked example. It also builds a playground link (`make_playground_link(ORDER, {"item": item_question(MENU, [])})`; it also passed `models=[TYPESAFE_MODEL]` until the 2026-11-02 docs) holding the first item request, so you can edit the order and watch the answer change.

## Adapting it to a new domain

- Replace `MENU` with your catalog. Put the words customers actually use in each `description` (the latte's lists "flat white" and "milky coffee"); Jev matches the input against these ([[guides/writing-instructions-and-criteria]]).
- Add fields per item as lists of allowed values. For quantities, the cookbook's own advice: add a `quantity` field to each item's schema, with values such as `one`, `two` and `three`.
- Keep `MAX_ITEM_REQUESTS` above your largest realistic order plus one (the sentinel request).
- Read the confidences you already get: the example prints them but never acts on them. For a real order, route low-confidence items or fields to a confirmation step ([[patterns/confidence-routing]], [[concepts/confidence]]).
- The same two-step shape turns a sentence into a function call: [[cookbooks/function-calling]] picks the function with one `Choice` and fills each argument with one, but handles an argument the user did not mention with a separate yes/no question instead of an `unspecified` option.

## Gotchas

- **No quantities, no duplicates.** The cart has no quantity field, so "two flat whites" still produces one `latte`, and the loop finds each menu item at most once (two lattes with different milks cannot be expressed as written).
- **Stage 1 is sequential.** Each item request depends on the previous answer, so an order with n items costs n + 1 round trips before any field request; only the field questions of one item go out together. [[cookbooks/parallel-questions]] measures what batching saves.
- **A request is needed even to stop.** The sentinel request always runs unless the menu runs out, so even a one-item order takes two item requests.
- **Confidence can be `None`.** The cookbook's `fmt_confidence` guards against it ("The API can return None, so never format it blind"); do the same in any threshold check.
- **Cache key.** `ask_item` and `ask_fields` are cached on the order plus the question JSON; the model is not an argument, so a cached answer from one model replays under another (inferred from the decorated functions' arguments). Move `json_cache.json` aside when you change `TYPESAFE_MODEL`.
- **Install line.** `pip install ipython 'cooksafe>=0.2.0,<0.3.0'`, then set `TYPESAFE_API_KEY`. Calls are memoized to a local `json_cache.json`; since the 2026-11-03 docs the cookbook no longer offers a published cache in `typesafe-public-examples` to replay its numbers (the key still defaults to `"cache-only"` in the sample, which only helps once your own cache is filled; inferred).

## Related

- [[concepts/choice]] — the only primitive used; one answer per question
- [[patterns/fan-out]] — the field questions of one item in one request
- [[cookbooks/function-calling]] — the same pick-then-fill shape for function calls
- [[cookbooks/parallel-questions]] — what one batched request saves over one at a time
- [[cookbooks/hierarchical-classification]] — another flow where each answer sets the next request's options
- [[concepts/primitives]] — when a second request is justified
- [[cookbooks/overview]] — the full cookbook catalog

## Sources

- raw/docs/cookbooks__cart_from_primitives.md (https://docs.typesafe.ai/cookbooks/cart_from_primitives.md), first captured 2026-10-31
- raw/docs/cookbooks.md (https://docs.typesafe.ai/cookbooks.md): section and level

Re-fetched 2026-11-03: only the `json_cache.json` download note was removed. Code, questions and every reported number unchanged.
