Using AI to set up your menu

Typing a whole menu into a new system is slow and easy to get wrong. If you already have a printed menu, an AI assistant can read photos of it and build the mozo version for you. This page is for you and for the assistant: how to give it access, and how the menu import works.

What you need

  • Clear photos of every page of your menu, taken straight on, in good light. One photo per page is better than one photo of everything.
  • A manager account on mozo. Only managers can import a menu.
  • An AI assistant that can make web requests, for example Claude with a terminal or code tool.

Access: a token, not your password

Give the assistant an API token, never your email and password. You get one by signing in through the API:

POST https://www.mozo.bar/employees/sign_in.json
{"employee": {"email": "you@example.com", "password": "..."}}

The response contains a token. Every request the assistant makes sends it as a header:

Token: <the token>

The token works for one restaurant (if you manage several, it’s the first one, so ask the assistant to check the restaurant name before importing). To cut off access, sign out: DELETE /employees/sign_out with the same header revokes the token immediately. See Using AI to manage your floor plan for more on how tokens work.

How it works

  1. You share the photos with the assistant.
  2. The assistant transcribes them into one JSON document: your categories, products, prices and options.
  3. It sends that document as a dry run. mozo checks everything and answers with what it would create, without saving anything.
  4. The assistant shows you the result. You check names and prices against your menu and correct whatever was misread.
  5. It sends the same document for real.
  6. Optionally, it uploads product photos one by one.
  7. You open the Menu screen and use the time preview to check what guests will see.

Steps 3 and 4 are the important ones. Photos are easy to misread, a 7 can look like a 1, a price can belong to the line above. The dry run costs nothing, so use it until the result is right.

The import request

POST https://www.mozo.bar/supplier/api/v1/menu_import?dry_run=true
POST https://www.mozo.bar/supplier/api/v1/menu_import
Token: <the token>
Content-Type: application/json

The body is the whole menu as one tree. A small but complete example, for a restaurant in Colombia:

{
  "tax_rates": [{"name": "INC 8%", "percentage": 8}],
  "destinations": {"kitchen": "Cocina", "bar": "Bar"},
  "categories": [
    {
      "name": "Almuerzo",
      "full_day": false, "start_from": 720, "end_on": 900,
      "products": [
        {
          "name": "Almuerzo ejecutivo", "code": "A1", "price": 22000,
          "description": "Sopa, plato fuerte y jugo",
          "tax_rate": "INC 8%", "destinations": ["kitchen"],
          "option_groups": [
            {"name": "Proteína", "min_select": 1, "max_select": 1, "choices": [
              {"name": "Pollo"},
              {"name": "Mojarra", "price_delta": 4000},
              {"name": "Chicharrón", "price_delta": 2000}
            ]},
            {"name": "Jugo", "min_select": 1, "max_select": 1, "choices": [
              {"name": "Mora"}, {"name": "Lulo"}
            ]}
          ]
        }
      ]
    },
    {
      "name": "Bebidas",
      "products": [
        {"name": "Limonada de coco", "price": 9000, "destinations": ["bar"],
         "variants": [{"name": "Grande", "price_delta": 3000}]}
      ]
    }
  ]
}

Only categories is needed. tax_rates and destinations are optional, and everything in them can be left out if the restaurant doesn’t use them.

Fields

Categories:

FieldMeaning
nameRequired. Shown to guests as the section heading.
full_daytrue (default) for all day. false to use the hours below.
start_from, end_onVisible from/until, in minutes after midnight: 720 is 12:00, 900 is 15:00. end_on may be past midnight (up to 2879) for late-night categories.
active_on_monday … active_on_sundayfalse to hide the category on that day. All days are on by default.
productsThe products in this category.

Products:

FieldMeaning
nameRequired. Keep it short, it’s read on a phone.
priceRequired. In your restaurant’s currency, as a number: 22000 for 22.000 pesos, 7.5 for 7,50 euro.
codeOptional internal code. When given, it’s how the import recognizes the product next time.
descriptionOptional. Ingredients, portion, anything printed under the name.
activefalse to import a product without offering it yet.
tax_rateThe name of a tax rate, from tax_rates or one that already exists.
destinationsStation keys, from destinations or ones that already exist.
variantsSingle choices that change the price: name, price_delta, description.
option_groupsCombo choices, see below.

Option groups and their choices, for combo items like a lunch special:

FieldMeaning
nameRequired. The question the guest answers: “Proteína”, “Choose your base”.
min_select, max_selectHow many choices to pick. Both default to 1.
requiredWhether the guest must choose. Defaults to true.
free_choice_countHow many picks are included in the price before extras cost more.
choicesEach with name (required), price_delta and description.

The order in the document is the order guests see. To place something elsewhere, give it an explicit position.

See Variants and Combo options for when to use which. In short: one choice that changes the price (a size) is a variant. Several choices on one item (protein, side, drink) are option groups. A product uses one or the other, not both.

Running it again is safe

The import only adds and updates, it never deletes. Everything is matched to what’s already there:

  • tax rates and categories by name,
  • products by code when given, otherwise by name within their category,
  • variants and option groups by name within their product, choices by name within their group.

A match is updated, anything else is created. Sending the same document twice changes nothing the second time. Changing a price in the document and sending it again updates just that price. A product with a code can move to another category this way.

Because nothing is deleted, removing a product from the document doesn’t remove it from your menu. Do that on the Menu screen, or switch it off with "active": false.

The answer

A successful import answers with 200 and a summary:

{
  "ok": true,
  "dry_run": false,
  "counts": {"product_category": {"created": 2}, "product": {"created": 2}, "...": {}},
  "items": [
    {"path": "categories[0]", "type": "product_category", "id": "...", "name": "Almuerzo", "action": "created"},
    {"path": "categories[0].products[0]", "type": "product", "id": "...", "name": "Almuerzo ejecutivo", "action": "created"}
  ],
  "errors": []
}

action is created, updated or unchanged. On a dry run the ids aren’t kept, so use the ids from the real import.

If anything is wrong, nothing is saved at all. The answer is 422 with every problem and where it is:

{
  "ok": false,
  "errors": [
    {"path": "categories[1].products[0].price", "message": "is not a number"},
    {"path": "categories[0].products[0].tax_rate", "message": "unknown tax rate \"IVA 19%\": add it to tax_rates"}
  ]
}

Fix those entries and send the document again.

Product photos

Photos aren’t part of the import. After the import, upload them per product with the product’s id from the answer:

PATCH https://www.mozo.bar/supplier/api/v1/products/<id>
{"product": {"image": "data:image/jpeg;base64,<the image>"}}

JPEG and PNG work. Option choices take an image the same way, at /supplier/api/v1/product_option_choices/<id>. A photo of the dish itself works much better for guests than a crop of the printed menu.

Tips for the assistant

  • Transcribe what’s printed. Don’t invent descriptions, translate names, or round prices.
  • Prices: use the plain number in the restaurant’s currency. Thousands separators in Colombian menus are dots (22.000 is twenty-two thousand), and pesos have no cents.
  • If a price or name is unreadable, leave the product out and list it for the restaurant to fill in, rather than guessing.
  • “Small / large” or “glass / bottle” with different prices are variants: the product price is the cheapest, each variant adds the difference.
  • A “menú del día” or set lunch with “choose one of …” lists is one product with option groups, not a product per combination.
  • A section of the menu that’s only served at some hours (“Desayunos hasta las 11”, “Happy hour”) becomes a category with full_day: false and its hours.
  • Codes printed next to items (A1, 12, …) go in code. Without them, product names within a category must be unique to be matched later.
  • Before the real import, show the restaurant the dry-run items as a readable list, category by category with prices, and wait for their approval.

Small changes later

For a single change, the regular endpoints are simpler than a new import. All take the same token header and a body wrapped in the record type, for example {"product": {"price": 23000}}:

  • PATCH /supplier/api/v1/products/<id>
  • PATCH /supplier/api/v1/product_categories/<id>
  • POST /supplier/api/v1/product_variants with product_id
  • POST /supplier/api/v1/product_option_groups with product_id
  • POST /supplier/api/v1/product_option_choices with product_option_group_id
  • GET /supplier/api/v1/product_categories for the whole current menu

Or simply edit it on the Menu screen.