The shop is a pure-Python rewrite of Princeton's WebShop. It is on by default and needs no setup step:

uv run launch.py                                  # shop at /onlineshop
uv run launch.py apps.onlineshop.enable=False     # turn it off entirely
uv run launch.py apps/onlineshop/content=default  # chrome only: no products, no shop

config/apps/onlineshop/content/webshop.yaml holds 999 products converted from the WebShop item dump, and is the shop's default content pack. The catalog is a property of the pack rather than of the app, so a pack with an empty products list yields no /onlineshop route and no start-page tile — the app is absent rather than empty.

The webshop pack sets product_images: hotlink, because it is the one catalog whose image URLs point at real photos. The default configuration therefore references Amazon's CDN. On a node without egress the images fail silently while the page still returns 200, so for eval runs there:

uv run launch.py apps.onlineshop.product_images=glyphs

tests/test_no_egress.py records this as the one content-driven entry in its external-host allowlist; every other entry is a styling dependency.

Varying the shop

It composes the same way as every other app:

uv run launch.py apps/onlineshop/layout=grid            # default | grid | compact_table
uv run launch.py apps/onlineshop/content=german         # or long_descriptions, adversarial_descriptions, misleading_descriptions
uv run launch.py apps.onlineshop.theme=solarized        # or apps/theme=solarized globally
uv run launch.py apps.onlineshop.products_per_page=5

The text variations (german, long_descriptions, adversarial_descriptions, misleading_descriptions) layer on top of webshop, so they change the chrome and keep the catalog. default.yaml is chrome only (title, promo text, category labels, currency) and deliberately carries products: []. Override apps.onlineshop.products from your own content file to swap the catalog wholesale.

content/fixture.yaml is a small mechanical catalog (18 generic products, no prose) that exists so the test suite has something fixed to run against.

The product seed

A content pack is plain Hydra YAML. set_environment clears and re-seeds all four tables from it on every launch and every reset, so the catalog is a variation axis like any other text in OpenApps — not a fixture you migrate.

# @package apps.onlineshop
defaults:
  - default          # inherit title, promo text, category labels, currency

products:
  - sku: 'furn-desk-205'          # stable id; used in URLs and in cart/order state
    title: 'Standing Desk'        # display name, and the top-weighted search field
    price: 649.00                 # float, in `currency_symbol` units
    category: furniture           # must be a key of `categories` in the pack
    breadcrumb: ['Furniture', 'Desks']   # crumbs on the item page
    rating: 4.2                   # 0-5, rendered as stars
    options:                      # option name -> selectable values; may be {}
      finish: ['walnut', 'natural']
      width: ['120 cm', '140 cm']
    bullets:                      # short feature list; may be []
      - 'Electric height adjustment'
    description: 'Sit-stand desk with a programmable controller.'
    images: []                    # optional; only drawn under product_images=hotlink

# Optional starting state. Both are seeded after `products`, and a line
# referencing an unknown sku is dropped with a warning rather than crashing
# the launch.
cart:
  - sku: 'furn-desk-205'
    options: {finish: 'walnut'}   # values not offered by the product are dropped
    quantity: 1

orders:
  - order_id: '0efcf51f'          # omit to get a random one
    name: 'Alex Morgan'
    address: '14 Bridge Street'
    date: '2026-08-24 19:54:34'
    status: 'Delivered'
    items:
      - sku: 'furn-desk-205'
        options: {finish: 'walnut'}
        quantity: 1
    # total: 649.00               # omit to compute from current prices

Option values are indexed for search alongside the title, bullets and description, so a query for walnut finds the desk above even though the word appears nowhere in its text. Cart lines are keyed on sku plus a canonical form of the chosen options, so the same product in two finishes is two lines, and the same options in a different order is one.

Rebuilding the catalog

scripts/fetch_webshop.py generated the committed pack and can regenerate it — a different dump, more products, synthesized ratings. Nothing requires it to run, and it overwrites webshop.yaml in place, discarding hand-edits. It pulls from a HuggingFace mirror of the item dump.

uv run scripts/fetch_webshop.py --inspect          # print the real schema; writes nothing
uv run scripts/fetch_webshop.py                    # rebuild the committed pack as it stands
uv run scripts/fetch_webshop.py --file items_shuffle.json --limit 5000   # bigger (~1.5 GB download)
uv run scripts/fetch_webshop.py --synth-ratings    # stable, invented ratings when the dump has none
uv run scripts/fetch_webshop.py --out /tmp/webshop.yaml                  # e.g. to diff two conversions
Flag Default Effect
--inspect off Print the real schema and exit without writing
--file first of items_shuffle_1000.json, items_human_ins.json, items_shuffle.json Which dump in the HF repo to convert
--limit 1000 Products to keep (the committed pack's size; also all of items_shuffle_1000.json)
--synth-ratings off Derive stable ratings from the sku when the data has none
--out config/apps/onlineshop/content/webshop.yaml Destination

It downloads through huggingface_hub when that is importable, so re-runs hit the shared HF cache.

On prices. About one record in eleven has an empty pricing field, and a few ranges open at a $0.01 placeholder. A product nobody can be charged for is useless to a shopping task, so a missing price is filled with the median price of the products in the same category, and sub-dollar values are ignored when reading a range.

On field names. The dump has been re-exported several times and the casing is not stable — the current one uses name, full_description, small_description and pricing. Each output field is read from a list of candidate keys (_FIELDS in the script), so if a conversion comes out empty, run --inspect and add the real key to that list.

On options. WebShop's customization_options is {"Color": [{"value": "Bath Ball", "url": ..., "image": ..., "price": ...}]} — the sibling keys hold amazon.com links. Only value is carried across, and the script aborts if any URL survives into a text field.

Product images

apps.onlineshop.product_images chooses what gets drawn:

Mode What renders
glyphs (app default) Inline SVG line art chosen by title keyword — no files, no outbound requests
hotlink (set by the webshop pack) The catalog's own image URLs, as a CSS-only carousel when there are several

Under hotlink, each <img> carries an onerror that swaps the whole carousel for the glyph, so a failed fetch degrades to line art rather than a broken-image icon. The carousel is one hidden radio per slide and a clickable dot per image — real elements an agent can click, no JavaScript. layout=compact_table stays imageless either way. An unrecognised value falls back to glyphs, so a typo in a sweep override cannot silently start hotlinking.

Glyphs. _GLYPHS in src/open_apps/apps/onlineshop_app/main.py holds ~35 hand-written shapes on a 100×100 box; _GLYPH_KEYWORDS maps title keywords onto them, first match wins, on word boundaries with an optional plural (pen hits "Pens" but not "Open"); _CATEGORY_GLYPHS catches the rest. The hue is derived from the sku, so a product looks identical on every page. To review a catalog's art at once:

uv run scripts/render_glyph_sheet.py            # the webshop pack; or pass `fixture`
open /tmp/glyphs.html

Inspecting the shop's data

Each launch writes a fresh SQLite database under the run's log_outputs directory, so the newest one is the run you were just clicking through:

DB=$(ls -t log_outputs/*/databases/onlineshop.db | head -1)
sqlite3 -header -column "$DB" "SELECT * FROM cart_items;"

Four plain tables — products, cart_items, orders, order_items — plus the products_fts* search index. The same state is served as JSON at /onlineshop_all, which is what rewards are computed from.

How it works

Concern Implementation
Catalog Seeded from the Hydra content pack into the products table on launch
Search SQLite FTS5 with its built-in bm25(), weighted title > options > bullets > description
Persistence One SQLite file, four relational tables, via fastlite
Rendering FastHTML against the shared design tokens (apps/theme=) with a layout group
Reward surface GET /onlineshop_all → {"cart": [...], "orders": [...]}

FTS5 is compiled into Python's sqlite3 module, so BM25 ranking costs no dependency. User input is accent-folded and tokenised, and each token is quoted before being OR-ed into a MATCH expression, so FTS5 operators typed into the search box (*, NEAR, a stray quote) are matched literally, and creme and crème find the same products. Tokens are OR-ed rather than AND-ed so a query returns its best partial matches.