NemlAI for AI agents

NemlAI predicts a household's next grocery basket from its order history and fills a shop's cart; the user checks out themselves. Connect over MCP at https://nemlai.milops.org/mcp; if that fails, use the nemlai CLI or the web app as described below.

Connect

1. The MCP server (first choice)

  • URL: https://nemlai.milops.org/mcp (Streamable HTTP, stateless, JSON responses).
  • Sign in with OAuth where the client supports it: add the URL with no client id/secret (the client registers itself). The client opens a browser: the user logs in to NemlAI if needed and clicks Giv adgang on the NemlAI consent page (/oauth/consent). The connection has full access to their NemlAI account; it can fill shop carts but never check out.
  • Or with an API token: the user runs nemlai login once in their own terminal (it asks for their NemlAI password there, not in chat). The CLI stores a token starting with nemlai_ in ~/.config/nemlai/token; it is sent as Authorization: Bearer <token>. The configs below read it from the NEMLAI_TOKEN environment variable (export NEMLAI_TOKEN=$(cat ~/.config/nemlai/token)) instead of storing it.
  • nemlai mcp install <client> (nemlai 0.11 or later) writes a client's config and installs this skill. It backs up the file first, changes nothing else in it, and --dry-run shows the change. nemlai mcp config <client> only prints the config; nemlai mcp doctor checks the URL, the token and the tool list.

Connect in one minute

  • claude.ai, Claude Desktop and mobile: Customize → Connectors → Add custom connector → URL → Add → Connect → Giv adgang. Desktop and mobile use the connector added on claude.ai. For the skill, upload nemlai-skill.zip in Claude's Skills settings.

  • Claude Code: nemlai mcp install claude-code. It runs claude mcp add --transport http --scope user nemlai https://nemlai.milops.org/mcp --header 'Authorization: Bearer ${NEMLAI_TOKEN}' and puts the skill in ~/.claude/skills/nemlai/. Claude Code needs NEMLAI_TOKEN set when it starts; with a fixed Authorization header it does not fall back to OAuth. For OAuth instead: claude mcp add --transport http nemlai https://nemlai.milops.org/mcp, then /mcp → nemlai → Authenticate.

  • ChatGPT: Settings → Apps & Connectors (developer mode) → add a custom MCP server → URL, authentication OAuth → Create → Connect.

  • Grok: grok.com/connectors → New Connector → Custom → URL → complete the sign-in.

  • Codex: nemlai mcp install codex adds this to ~/.codex/config.toml and puts the skill in ~/.agents/skills/nemlai/:

    [mcp_servers.nemlai]
    url = "https://nemlai.milops.org/mcp"
    bearer_token_env_var = "NEMLAI_TOKEN"
    
  • Hermes: nemlai mcp install hermes adds this under mcp_servers in ~/.hermes/config.yaml and puts the skill in ~/.hermes/skills/nemlai/. Set NEMLAI_TOKEN in the environment or in ~/.hermes/.env; in a running session, /reload-mcp.

    mcp_servers:
      nemlai:
        url: "https://nemlai.milops.org/mcp"
        headers:
          Authorization: "Bearer ${NEMLAI_TOKEN}"
    
  • OpenClaw (2026.5.12 or later): nemlai mcp install openclaw adds this to ~/.openclaw/openclaw.json and puts the skill in ~/.openclaw/skills/nemlai/. Set NEMLAI_TOKEN in the environment or in ~/.openclaw/.env. If the file has JSON5 comments, run openclaw mcp set nemlai '<the server object below>' instead.

    {"mcp": {"servers": {"nemlai": {"url": "https://nemlai.milops.org/mcp", "transport": "streamable-http",
      "headers": {"Authorization": "Bearer ${NEMLAI_TOKEN}"}}}}}
    
  • Other clients: nemlai mcp config generic prints the URL, the header and a JSON entry.

  • If the connector can't sign in (the client's OAuth fails), use the token where the client takes a header, else the CLI or the web app.

  • Check it works: whoami(). Not sure what to do: nemlai_guide().

2. The nemlai CLI (when MCP fails and you have a shell)

pipx install nemlai            # or: pip install nemlai
export NEMLAI_TOKEN=nemlai_…   # the user's token; or the user runs `nemlai login` themselves
nemlai --json whoami           # one command, JSON output, then exit
nemlai --json basket items
nemlai help                    # every command
  • Every MCP tool names its CLI command (see the fallback topic, or error.fallback.cli). Run that command as written: the global --json makes every command print one JSON object (status plus the data, or status + message for a text reply, or status + error + hint). With nemlai older than 0.11 (nemlai --version), add --format json to analytics commands; the report then comes back as a JSON string in message, so parse it a second time.
  • The CLI reads the token from ~/.config/nemlai/token, else from NEMLAI_TOKEN. Don't pass --token on the command line (it shows up in process lists).
  • Never type a password into the CLI for the user. Commands that need one (login, shops link, a Nemlig reauth) are for the user to run in their own terminal, or to do in the web app.
  • Python: from nemlai import NemlAI; client = NemlAI(token=…) gives the same features as methods (client.basket.items(), client.basket.send(), …).

3. The web app (no shell, or the CLI fails too)

An agent with a browser tool opens the page and follows the steps listed for the tool in the fallback topic; an agent without one gives the user the link and the steps. The user logs in themselves.

Page What is there
https://nemlai.milops.org/kurv/ The basket: generate, review, adjust, search, dishes, shop tabs with prices, send. The same saved basket as MCP and the CLI
https://nemlai.milops.org/settings Profile, household and invite code, settings, prediction settings, preferences, notifications, password, delete account
https://nemlai.milops.org/settings/shops Link shops (Salling: Bilka, Føtex, BilkaToGo) and their status
https://nemlai.milops.org/refresh-orders Fetch new orders; log in to Nemlig again when the session expired
https://nemlai.milops.org/ekskluder Exclude orders or products from predictions
https://nemlai.milops.org/analytics Spending: overview, trends, top products, receipts
https://nemlai.milops.org/cli A CLI in the browser (same commands as nemlai)
https://nemlai.milops.org/cli/docs CLI, API and SDK documentation
https://nemlai.milops.org/agents This guide for agents, with the full fallback table
https://nemlai.milops.org/healthz Is the server up?

4. Everything down

Say so plainly and don't invent results. Check https://nemlai.milops.org/healthz. The user can still shop directly on https://www.nemlig.com.

Data model and core loop

What NemlAI is

  • A household shops online, mostly at Nemlig.com. NemlAI fetches the household's past orders, learns what it buys and how often, and predicts the next basket: which products and how many.
  • The user (or you, the agent) reviews the basket, adjusts it, and sends it to a shop. Sending fills the shop's own cart. The user then opens the shop's site and checks out. NemlAI never checks out or pays.
  • The same basket can be priced at other shops (BilkaToGo, Netto, Føtex, REMA 1000) and sent to the ones that accept a basket.

Data model

Thing What it is Tools
User The NemlAI account this connection acts for. whoami()
Household The unit orders, settings, preferences and linked shops belong to. One user is in one household. household_info()
Member A person in the household, each with their own Nemlig account. Orders from all members count together. household_info()
Basket (kurv) The user's current basket (each member has their own, predicted from the whole household's orders): items with quantity, unit price, campaign info, a total in kr and a budget. It is saved on the server (it survives restarts) and shared by MCP, the CLI, the SDK and the web page /kurv/. get_basket(), generate_basket()
Item One product line. Identified by its SKU (product_number, a Nemlig product id) from an earlier tool result. explain_item(), set_quantity()
Shop Where the basket can be priced or sent: nemlig, bilkatogo, and price-only shops. Some need linking first (web app, takes a password). list_shops(), compare_shop_prices()
Order A past purchase fetched from a shop. The model learns from them; stale orders mean a stale basket. refresh_orders(), order_fetch_status()
Dish A meal (e.g. "Fisk en hverdag") with ingredients you can add to the basket. find_dishes(), add_dish_to_basket()
Job A long action (generate, send, order or shop refresh) running on the server. job_status()

Scope: household scope is the default for orders, analytics and settings. Use one member's scope only when the user names that person ("refresh Anna's orders"). The basket is the exception: it belongs to the user this connection acts for, so another member doesn't see it; the user sees it on the web page /kurv/.

The core loop

  1. Check the connection: whoami(). No household → the user joins or creates one in the web app first.
  2. Look at the basket: get_basket(). If it is empty or old, generate_basket() with a budget preset (small, medium, large). It returns when done or gives a job_id for job_status().
  3. Review with the user: show the items, quantities and the total in kr against the budget. explain_item() says why an item is there.
  4. Adjust: add_to_basket() (by SKU from search_products()), set_quantity(), remove_from_basket(), add_dish_to_basket() for a meal.
  5. Optionally compare shops: compare_shop_prices().
  6. Send with send_basket() only after the user says so. Tell them to check out on the shop's site.

How results look

Every tool returns an envelope:

  • Success: {"ok": true, "data": {…}, "next": ["…"]}. next lists what makes sense now; follow it.
  • Failure: {"ok": false, "error": {"code", "message", "hint", "fix": {"tool", "args"}, "fallback": {"cli", "web"}}}. Do fix if present; otherwise see the fallback topic.

Error codes: invalid_argument (fix the call), not_found (use an id from a tool result; the hint lists candidates), forbidden, no_household, not_linked (web app link), reauth_required (the user logs in again in the web app), job_running (poll the job_id in fix), upstream_unavailable (retry once, then fall back), rate_limited (wait as the hint says), conflict (the basket changed elsewhere, e.g. on /kurv/: get_basket() again, then redo the change), cart_not_empty (the shop's cart already holds items and nothing was sent: ask the user details.ask_user, then send_basket() again with their cart_mode), internal (fall back).

Limits: each user gets about 240 tool calls a minute with at most 8 at once, and each household gets find_dishes 10 per 10 minutes, generate_basket 6 per 10 minutes, live shop reads (get_shop_cart, get_delivery_info, list_delivery_slots) 30 a minute, and analytics plus compare_shop_prices 60 a minute. Over a limit the call answers rate_limited with details.retry_after in seconds: wait that long and retry once; never loop.

Where to go next

  • The rules: nemlai_guide(topic="rules").
  • A whole task: the workflow topics (weekly-basket, dinners-via-dishes, trim-to-budget, compare-shops, spending-review, exclude-products, order-refresh-reauth, shop-not-linked).
  • One tool: nemlai_guide(topic="<tool name>").
  • When something fails: nemlai_guide(topic="fallback").

Rules

Sending and paying

  1. Show before you send. Before send_basket(), show the user the items (name, quantity, price) and the total in kr, and the budget if there is one. Send only when the user clearly says so ("send it", "ja, send") about the basket you showed. A request to "make a basket" is not a request to send it, and "send kurven" about a basket the user hasn't seen in this conversation means: show it and ask "Skal jeg sende den til Nemlig?" first.
  2. The user decides what happens to their shop cart. Call send_basket() without cart_mode first. An empty cart is filled straight away. If the cart at the shop already holds items, nothing is sent and the answer is cart_not_empty with the cart's count, total and a few names. Ask the user, e.g. "Du har allerede 6 varer for 214,50 kr. i din Nemlig-kurv (bl.a. mælk, rugbrød og bananer). Skal jeg beholde dem og lægge de nye varer oveni, eller tømme kurven og kun sende de nye?" (details.ask_user), then call send_basket() again with cart_mode="keep" (a product in both gets the higher quantity) or cart_mode="replace" (empty the cart first). Never pick the mode yourself, and never send replace without that answer: it removes things the user put there.
  3. Send once. send_basket() is a job. If it times out, fails or the result is unclear, check job_status() with its job_id, or get_basket(), before anything else. A second send can double the shop's cart.
  4. No checkout, ever. send_basket() fills the shop's cart, item by item through the shop's API. The user opens the shop's site, checks the cart and pays. Say that when you send. Never claim an order was placed. NemlAI also never books or reserves a delivery time, chooses a pickup store or the delivery mode, or changes the address: get_delivery_info() and list_delivery_slots() only read them.
  5. The cart link comes after the send. A completed send returns cart_url: for Nemlig the plain https://www.nemlig.com/basket link, which opens the Nemlig app if it is installed. Offer it as "Åbn kurven i Nemlig (åbner appen, hvis den er installeret)". Before a send there is no link, and no link adds items to the cart; a failed send has no link either: say what went wrong and what to do (e.g. log in to Nemlig again in the web app).

Prices and campaigns

  1. Prices are in Danish kroner (kr). Show them as the tools return them (e.g. 79,00 kr or 79.00 kr); don't convert currencies.
  2. Campaign minimums. A campaign price applies only when the quantity reaches campaign_min_quantity. "Mix 3 stk 79,00" means 3 items for 79 kr together (26,33 kr each); with 2 items the regular regular_unit_price applies. When a user is 1 item short of a campaign, you may point it out; don't raise quantities on your own.
  3. Pant (bottle and can deposit) is a separate charge on drinks, refunded when the empties are returned. Don't fold it into the product price or count it as spending on the product.

Danish text

  1. Keep Danish text as it is. Product names, categories and dishes are Danish. Keep "øko" (organic) and the letters æ, ø and å intact in names, searches and messages. Never transliterate ("oeko", "aeble"): search with the real spelling ("æble", "økologisk").
  2. Øko is a real difference. An øko product and its non-øko version are different products. Don't swap one for the other without saying so.

Scope and identity

  1. Household scope is the default. Orders, analytics and settings belong to the household. Use one member's scope only when the user names a person ("Annas ordrer"). The basket is per user: one saved basket that MCP, the CLI, the SDK and the web page /kurv/ share; it is not the household's.
  2. Never take ids from the user's head. SKUs, dish ids, job ids and order ids come from earlier tool results. If an id is unknown, the error lists candidates: pick from those or search again. A vague request ("tilføj mælk") that matches different products (øko or not, fat %, size) is not a choice: show the options, or what the household usually buys (analytics_top()), and ask.
  3. Never ask for a password. Not for NemlAI, Nemlig, Salling/Bilka or any shop, at any step. Logins, linking a shop, Nemlig reauth, password changes and deleting the account happen in the web app; give the user the link (account_links()) and let them do it.

Honesty

  1. Never invent results. If a tool fails and the fallbacks fail, say so plainly. The user can still shop directly on nemlig.com.
  2. Say which way you used. If you fell back to the CLI or the web app, tell the user ("MCP is down, so I used the nemlai CLI").
  3. Follow next and fix. Tool results say what state things are in and what to call next. Call nemlai_guide() when unsure instead of guessing.

Workflows

Weekly basket · Build the household's usual weekly basket, review it with the user and send it to Nemlig on their go-ahead.

User says: "lav ugens kurv", "do my weekly shop", "order the usual groceries".

  1. whoami(): check the connection and that there is a household.
  2. get_basket(): is there already a basket? If it has items from today or yesterday, offer to review it instead of generating a new one (generating replaces it).
  3. If the orders look stale (the basket or order_fetch_status() says the last fetch is old), offer refresh_orders() first so the prediction knows about the latest order. It is a job: wait for it or poll job_status(). If it says reauth_required, see order-refresh-reauth.
  4. generate_basket() with the user's budget preset (medium if they don't say). It waits up to wait_seconds; if it returns a job_id, poll job_status() until it is done.
  5. get_basket() and show the user: the items (name, quantity, unit price), items on campaign, the total in kr and the budget. Group by category if the list is long.
  6. Adjust what the user asks for:
    • "more milk": set_quantity() with the item's SKU.
    • "no crisps this week": remove_from_basket().
    • "add rugbrød": search_products(), let the user pick if there are several, then add_to_basket() with the chosen SKU.
    • "why is X there?": explain_item().
  7. Show the new total. Ask "Shall I send it to Nemlig?" and wait for a clear yes.
  8. send_basket() with shop="nemlig". If it returns a job_id, poll job_status(); never send again without checking.
  9. Tell the user it is in their Nemlig cart and that they choose the delivery time and pay on nemlig.com themselves. Give them the completed send's cart_url as "Åbn kurven i Nemlig (åbner appen, hvis den er installeret)". If the send failed there is no link: say what to do instead.

Pitfalls: don't send on "make a basket"; don't raise quantities to reach a campaign without asking; a new generate_basket() throws away manual changes, so warn first.

Dinners via dishes · Plan the week's dinners from NemlAI's dishes and add their ingredients to the basket.

User says: "find 4 aftensmåltider", "plan dinners for the week", "add what I need for lasagne".

  1. find_dishes() with what the user wants ("fisk", "vegetar", "hurtig", "lasagne"). Search in Danish; keep æ, ø, å.
  2. Show a short list (name, a line about it). Let the user pick; don't pick for them unless asked.
  3. For a picked dish: dish_info() shows its ingredients and which are already in the basket; dish_recipe() gives the recipe if the user wants it.
  4. get_basket() to see what is already there, so you don't add a second pack of something.
  5. add_dish_to_basket() with the dish id from step 1 and either all ingredients or the ones the user wants (skip what they have at home: "vi har løg og hvidløg").
  6. get_basket() and show the new total in kr. Repeat for the next dish.
  7. Send only when the user asks (see weekly-basket, steps 7 to 9).

Pitfalls: dish ids come from find_dishes(), never from the dish name; ingredients may map to øko or non-øko products, so say which if the user cares; quantities are for the dish's servings.

Trim to budget · Bring a basket down to a budget in kr by removing or reducing the least needed items, with the user's approval.

User says: "max 1000 kr", "it's too expensive", "trim it to 800".

  1. get_basket(): the total in kr, the budget, and each item's price, quantity and campaign info.
  2. Work out how much to cut (total minus the target).
  3. Propose cuts, cheapest pain first:
    • items the prediction is least sure about: explain_item() reasons with category fills_budget (added only to reach the budget) or less_likely (often skipped);
    • big quantities that can go down by one;
    • expensive items with a cheaper similar product (search_products(); keep øko if the user buys øko);
    • treats and extras before staples (milk, bread, eggs).
  4. Mind campaigns: lowering a quantity below campaign_min_quantity raises the unit price ("Mix 3 stk 79,00": 3 cost 79 kr, 2 cost the regular price each). Sometimes removing the whole campaign line saves more.
  5. Show the proposal as a list with what each change saves and the new total. Change nothing until the user agrees.
  6. Apply: set_quantity(), remove_from_basket(), or add_to_basket() for a swap.
  7. get_basket() and show the final total against the budget.
  8. Next time, generate_basket() with a smaller budget preset may fit better.

Pitfalls: never cut silently; recompute the total from get_basket() instead of doing the sum yourself; pant on drinks is not part of the product price.

Compare shops · Price the basket at other shops, pick alternatives where a shop lacks a product, and send it to a shop that accepts a basket.

User says: "er det billigere i Bilka?", "compare shops", "send it to BilkaToGo instead".

  1. get_basket(): is there a basket? Empty is fine for a price question: compare_shop_prices() then prices the basket NemlAI prepared for the household (source: "pregenerated"); tell the user that. Make a basket (generate_basket()) only when the user asks for one.
  2. list_shops(): the shops, whether each is linked, its status and whether it can receive a basket (send_mode). Use only these shop ids.
  3. compare_shop_prices(): the basket's total at each shop, how many items matched, which are suggestions (a similar product, with how it differs) and which are missing. Prices are a snapshot; check prices_as_of and say when a shop's prices are old.
  4. Show the user a short table: shop, total in kr, matched / missing. A shop with total_is_partial lacks some items, so its total is not the basket's price there: compare it with same_items_nemlig_kr (the same items at Nemlig) and say how many items are missing. cheapest_shop only counts complete totals.
  5. For a suggested or missing item at the shop the user likes: list_alternatives() for that shop and item, let the user pick, and use the pick for that shop.
  6. To send to another shop: it must be linked and able to receive a basket. Not linked → see shop-not-linked. Shops that can't receive a basket: give the user the list to shop by hand.
  7. Show the items and the total at that shop and wait for a clear yes; then send_basket() with that shop. Poll job_status() if it returns a job_id.
  8. Tell the user to check out on that shop's site.

Pitfalls: "Bilka" in the basket tabs is BilkaToGo (bilkatogo). Bilka's stores use the same price list, but a store can have local offers and Bilka Plus member prices, and some store items aren't sold online; say so when the user shops in the store. If counts.stale is above 0, some prices are old: say so; a suggestion is a different product, so say how it differs (size, øko, brand); never send twice without checking job_status().

Spending review · Answer questions about what the household spends on groceries: totals, trends, top products, single orders and receipts.

User says: "hvad bruger vi på mad?", "how much did we spend on milk this year?", "what went up?".

  1. Start broad: analytics_overview() for the period the user names (this month, last 3 months, 12 months).
  2. Pick the report that answers the question:
    • "how much on X / in category Y": analytics_spend() (a product or category, optionally compared with the previous period);
    • "what do we buy most / spend most on": analytics_top();
    • "is it going up": analytics_trend() by week or month;
    • "what was in order 42", "why was that order so big": analytics_drill();
    • "show the receipts for jordbær": analytics_receipts();
    • "which categories are there": analytics_categories_tree().
  3. Report numbers as the tool returns them, in kr, with the period. Say how many orders the period covers; few orders make trends unreliable.
  4. Offer a follow-up that acts on it: exclude an unusual order (exclude-products), trim the next basket (trim-to-budget).

Pitfalls: product names are Danish ("mælk", "jordbær", "øko"); search in Danish; excluded orders are left out of analytics; spending includes linked shops' receipts (Bilka, Føtex) when they are linked; don't do your own sums across reports.

Exclude products · Stop products or whole orders from shaping the predictions (a one-off party order, a product the household no longer buys).

User says: "vi køber ikke længere sodavand", "ignore the Christmas order", "stop suggesting Y".

  1. get_exclusions(): what is excluded or hidden today.
  2. Find what to exclude:
    • a product: from get_basket() (it is in the basket now) or search_products(); use its SKU;
    • an order: analytics_drill() or analytics_receipts() show orders; list_order_items() shows one order's lines.
  3. Say what will change ("the order from 20 December will be left out of predictions and spending reports") and get the user's yes.
  4. update_exclusions(): exclude a whole order, include it again, or hide single products in an order.
  5. If the product is in the current basket, also remove_from_basket() (exclusions shape future baskets; they don't edit this one).
  6. For a lasting "never suggest this", also check get_preferences() / update_preferences() (e.g. excluded products, prefer øko).
  7. The next generate_basket() uses the new exclusions.

Pitfalls: excluding an order also removes it from analytics; hiding a product in one order is not the same as never suggesting it; use ids from tool results only.

Order refresh needs reauth · Fetch new orders from Nemlig; when the stored Nemlig session has expired, hand the login to the user in the web app.

User says: "hent mine nye ordrer", "the basket doesn't know about last week's order", or a tool returned reauth_required.

  1. order_fetch_status(): when each member's orders were last fetched, and whether each login works (session_valid, needs_reauth). "Is my login expired?" is answered here; don't start a refresh just to test it.
  2. Only when the user wants new orders: refresh_orders() for the caller's own Nemlig account (or member= one person, only if the user names them). It uses the stored Nemlig session only and never takes a password. It is a job: wait, or poll job_status().
  3. Done → tell the user how many new orders arrived; offer generate_basket() for a fresh basket.
  4. reauth_required (the Nemlig session expired):
    • Don't ask for the Nemlig password, and don't accept it if the user pastes it.
    • Give the user the link from the error (or account_links()): https://nemlai.milops.org/refresh-orders. There they enter their Nemlig.com password ("Log ind på Nemlig.com"; "Andre i husstanden" for another member) and the refresh runs.
    • A user with a terminal can start the interactive CLI (nemlai) themselves and type orders refresh; it asks for the Nemlig password without echoing it. (nemlai --json orders refresh, the agent's CLI fallback, uses the stored session only and never asks.)
    • When they say it's done, check with order_fetch_status() and carry on.
  5. job_running: a refresh is already running; poll the job_id in fix with job_status(). Never start a second one.

Pitfalls: never retry refresh_orders() in a loop on reauth_required; it won't fix itself.

Shop not linked · A shop the user wants to send to or count receipts from isn't linked yet; linking takes the shop password, so the user does it in the web app.

User says: "send it to Bilka", "include my Føtex receipts", or a tool returned not_linked.

  1. list_shops(): which shops are linked, their status, and which can receive a basket.
  2. Not linked:
    • Don't ask for the shop's email or password, and don't accept them in chat.
    • Give the user the link (from the error, or account_links()): https://nemlai.milops.org/settings/shops. There they log in with their Salling profile ("Salling Profil") to link Bilka, Føtex and BilkaToGo.
    • A user with a terminal can run nemlai shops link --email <their email> --shops bilka,foetex themselves; it asks for the password without echoing it.
  3. When they say it's done: list_shops() again. A newly linked shop may need refresh_shop() (a job; poll job_status()) before its receipts or prices show up.
  4. Carry on with what the user wanted (compare-shops, spending-review).

Pitfalls: a shop can be linked but in an error state (expired login): list_shops() shows it, and the fix is the same web page; price-only shops (Netto, REMA 1000) can't be linked or sent to.

Shop cart and delivery times · See what is already in the user's cart at Nemlig or BilkaToGo, and which delivery times are free and cheapest, without changing anything at the shop.

User says: "hvad ligger der i min Nemlig-kurv?", "hvilke tider er billigst i morgen?", "hvad koster leveringen?", or you are about to send_basket().

  1. get_shop_cart() (or shop="nemlig"/"bilkatogo"): the lines, fees, total and whether the minimum order is met. Lines with basket_sku are also in the NemlAI basket.
  2. Sending: call send_basket(shop=…) without cart_mode. If the shop's cart already holds items, nothing is sent and the answer is cart_not_empty: ask the user details.ask_user ("Skal jeg beholde dem og lægge de nye varer oveni, eller tømme kurven og kun sende de nye?") and send again with cart_mode="keep" or "replace" as they answer. Never choose for them.
  3. Times: list_delivery_slots(shop=…, days=2) for today and tomorrow, cheapest first. Say the price and the kind (Fleksibel is unattended and often cheaper at Nemlig).
  4. get_delivery_info(shop=…) for the chosen time and the deadline to change the order.
  5. The user books the time and pays on the shop's site (cart_url). Never offer to do it.

Pickup (BilkaToGo): a cart set to pickup answers delivery_mode: "pickup" and pickup_store ("Bilka Vejle"); the times are pickup times and the fee is the cart's (often 0). With delivery_mode: null BilkaToGo doesn't say: ask the user to choose home delivery or pickup on bilkatogo.dk.

Pitfalls: a missing or expired shop login answers not_linked/reauth_required: give the link from account_links(), never ask for a password.

Tools

Tools marked (coming) are not built yet. Ask the server for details with nemlai_guide(topic="<tool or area>").

Account, household and feedback (account)

  • update_profile: "change my name". A changed Nemlig email is not set here: the user changes it in the web app.
  • export_my_data: "give me my data" (GDPR).
  • account_links: the user wants something an agent must not do: signup, login, change_password, reset_nemlig_password, link_shop (Bilka/Føtex), reauth_nemlig (expired Nemlig session), delete_account.
  • achievements: "what have I unlocked?".
  • send_feedback: only when the user asks to send feedback to the NemlAI team.
  • household_invite_code: "how do I invite my partner?".
  • whoami: Documentation coming.

Household (household)

  • household_info: Documentation coming.

Orders (orders)

  • order_fetch_status: the user asks whether their orders are up to date, a basket looks stale, or a refresh failed with reauth_required.
  • refresh_orders: the user says they ordered recently ("jeg har lige handlet") and wants the history or the next basket to include it.

Basket (basket)

  • generate_basket: "make my weekly basket", "start a new basket", after refresh_orders(), or when get_basket() is empty or old.
  • get_basket: before talking about the basket, before send_basket(), after changes, or when the user asks what's in it.
  • explain_item: "why is X in my basket?", "why 3 of them?", or when trimming to a budget and you need to know which items the prediction is least sure about.
  • add_to_basket: the user wants a product added ("add rugbrød", "2 liter øko mælk too").
  • set_quantity: "more milk", "only one rugbrød", or to reach a campaign minimum.
  • remove_from_basket: the user doesn't want an item, or to trim the basket to the budget.
  • clear_basket: only when the user explicitly asks to empty the basket or start over by hand.

Search (search)

  • search_products: the user wants a product that isn't in the basket, wants a cheaper or øko alternative, or add_to_basket() returned candidates and you need more.
  • product_info: the user asks about one product: brand, weight, origin, whether it's øko or in stock, or the campaign details.
  • list_categories: the user asks what kinds of products there are, or you need a category name (for browsing ideas or for the analytics_* category filters).

Dishes (dishes)

  • find_dishes: "what do I need for butter chicken?", "add a fish dinner", "ideas for lasagne til 4".
  • dish_info: going back to a dish found earlier in the conversation, or to see which of its ingredients are already in the basket.
  • dish_recipe: the user wants to know how to cook the dish.
  • add_dish_to_basket: the user wants the dish's ingredients in the basket (all or some).

Shops (shops)

  • list_shops: before comparing prices or sending, or when the user asks which shops they can use.
  • refresh_shop: the user shopped at Bilka or Føtex and wants those receipts in the history or analytics.
  • compare_shop_prices: the user asks "hvad koster kurven i Netto/Bilka?", which shop is cheapest, or wants a shopping list for a shop without a cart.
  • list_alternatives: a shop lacks a basket item, matched it loosely, or the user wants another brand/size there.
  • list_offers: "hvad er på tilbud?", "er noget af det vi plejer at købe på tilbud?", "ugens tilbud hos Bilka", Dagstilbud or free gifts at nemlig.
  • send_basket: only after the user has seen the basket and its total and explicitly asks to send it.
  • get_shop_cart: the user asks "hvad ligger der i min kurv hos Nemlig/BilkaToGo?", or wants to see the whole cart before choosing keep or replace after send_basket() answered cart_not_empty.
  • get_delivery_info: "hvornår kommer varerne?", "hvad koster leveringen?", "kan jeg nå at ændre ordren?".
  • list_delivery_slots: "hvilke leveringstider er billigst i morgen?", "hvornår kan jeg få leveret?".

Analytics tools (analytics)

  • analytics_overview: "How much did we spend?", a summary of a period. The one call to start with.
  • analytics_spend: the total for a product or category, optionally compared with the previous period.
  • analytics_top: "what do we spend most on", by product or by category.
  • analytics_trend: spend over time, optionally for a product or category.
  • analytics_drill: the individual lines behind a number: when something was bought, at what price, in which order.
  • analytics_receipts: expense documentation, or finding the order_number for analytics_drill.
  • analytics_categories_tree: you need the exact category names before filtering, or a full breakdown by category.

Settings, preferences and notifications (settings)

  • get_settings: the user asks how predictions are configured, or before changing a setting.
  • update_settings: "ignore orders before March", "turn off auto-learn".
  • get_preferences: "what does NemlAI think I like?", before update_preferences.
  • update_preferences: "I'm vegetarian now", "never suggest liver paste", "I prefer Arla".
  • preference_definitions: the user asks what can be tuned, or you are unsure of a preference key.
  • recalculate_preferences: after new orders were imported, or get_preferences shows total_orders 0 / stale numbers.
  • notifications_status: "do I get push notifications?", before send_test_notification.
  • send_test_notification: only when the user asks to test notifications.

Exclusions (exclusions)

  • get_exclusions: "what have I excluded?", or to find an order id before excluding it.
  • list_order_items: the user wants to hide single products on an order.
  • update_exclusions: exclude or include a whole order, or hide or unhide lines.

Jobs (jobs)

  • job_status: a start tool (generate_basket, send_basket, refresh_orders, refresh_shop, find_dishes) returned status: "running", or the user asks whether a basket send or refresh has finished.

Guide tools (guide)

  • nemlai_guide: you are unsure which tool to use, what a Danish word means, how a whole task goes, or what to do when a tool or MCP fails. Call it first in a new conversation if the instructions are not enough.

Fallback

The ladder

Go one step down only when the step above fails.

  1. The tool returned an error with a fix: do the fix call. Retry a timeout, upstream_unavailable or a 5xx once after a few seconds. For a job, poll job_status() with its job_id; don't start the job again. An HTTP 502/503 from the proxy (not a NemlAI error envelope) usually means a deploy is restarting the server: wait about 10 seconds and retry once. A job the server no longer knows after that (not_found) was lost in the restart: start it once more, except send_basket() (check the basket and the shop's cart first).
  2. MCP unreachable, auth failing, or a tool missing → the CLI (agents with a shell): pipx install nemlai, then run the tool's command from the table below (or the error's fallback.cli) as written, with NEMLAI_TOKEN set. They are nemlai --json <command>.
  3. No shell, or the CLI fails too → the web app: with a browser tool, open the page from the table (or fallback.web.url) and follow its steps; without one, give the user the link and the steps. The user logs in themselves.
  4. Everything down: say so plainly, check https://nemlai.milops.org/healthz, never invent results. The user can still shop directly on nemlig.com.

Rules on every step

  • Never send a basket twice blindly. After a failed or unclear send_basket(), check job_status() or the basket (get_basket(), nemlai --json basket items or /kurv/) first. A second send can double the shop's cart.
  • The web page shows the same basket. MCP, the CLI and the web page /kurv/ share one saved basket per user, so falling back to the web for a basket action means opening /kurv/: the items you added are there. A change made on the page while you work makes your next write fail with conflict: read the basket again (get_basket()) before you change it.
  • Never ask the user to paste a password into chat, whichever step you are on. Logins, shop linking and Nemlig reauth happen in the web app (or in the user's own terminal).
  • Tell the user which step you used ("MCP is down, so I used the nemlai CLI").
  • The same rules as with MCP apply: show the basket and total before sending, send only on the user's go-ahead, no checkout.

Per tool

The table is generated from nemligbot/mcp/coverage.py (TOOLS): CLI is the command to run with NEMLAI_TOKEN set; Web is the page and the steps there. Placeholders in <…> come from earlier results (SKUs, ids) or from the user.

Tool CLI (NEMLAI_TOKEN set) Web page and steps
update_profile nemlai --json profile update --name <name> https://nemlai.milops.org/settings: Open the page. Edit the 'Profil' section. Click 'Gem ændringer'.
export_my_data nemlai --json account export [--section <profile|orders|order_items|preferences|suggestions>] https://nemlai.milops.org/auth/export: Open the URL while logged in; the browser downloads a JSON file.
account_links none https://nemlai.milops.org/settings: Open the page: password, account deletion and shop linking live here.
achievements nemlai --json achievements https://nemlai.milops.org/achievements
send_feedback nemlai --json feedback --message "<text>" https://nemlai.milops.org/settings: Open the page. Click 'Giv feedback' in the top navigation. Write your message and send it.
household_invite_code nemlai --json household invite-code https://nemlai.milops.org/settings: Open the page. Copy 'Invitationskode' in the 'Husstand' section.
whoami nemlai --json whoami https://nemlai.milops.org/settings: Open the page (log in if asked). Read the 'Profil' section.
household_info nemlai --json household info https://nemlai.milops.org/settings: Open the page (log in if asked). Read the 'Husstand' section: Husstands-ID and Invitationskode. Members are listed on /refresh-orders under 'Andre i husstanden'.
order_fetch_status nemlai --json orders status https://nemlai.milops.org/refresh-orders: Open the page; each member's last fetch and any login problem are shown.
refresh_orders nemlai --json orders refresh [--member <user_id>] https://nemlai.milops.org/refresh-orders: Open the page (log in if asked). Click 'Opdater' and wait until it says the orders are uploaded. If it asks you to log in to Nemlig again, the user does that on the page (never in chat).
generate_basket nemlai --json basket generate --size <small|medium|large> [--budget <kr>] https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. If a basket is shown, click 'Ny kurv'. Pick the size and a delivery date and generate the basket; it replaces the saved one.
get_basket nemlai --json basket items https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Read the items and the total ('Anslået beløb'); an empty basket opens on the generate form.
explain_item nemlai --json basket why <sku> https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Tap the (i) next to the item.
add_to_basket nemlai --json basket add --product-number <sku> --quantity <n> https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Type the product in 'Tilføj en vare eller søgning…' and click 'Tilføj'.
set_quantity nemlai --json basket adjust --product-number <sku> --quantity <n> https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Change the item's quantity with − and +; it is saved at once.
remove_from_basket nemlai --json basket remove --product-number <sku> https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Press − at quantity 1, then the bin next to the item.
clear_basket nemlai --json basket clear https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Remove the items (− at quantity 1, then the bin), or click 'Ny kurv' and generate a new basket, which replaces it.
search_products nemlai --json search <query> [--limit <n>] https://nemlai.milops.org/kurv/: Open the page. Use the search field.
product_info nemlai --json search info <sku> https://nemlai.milops.org/kurv/: Open the page. Search for the product and open it.
list_categories nemlai --json categories none
find_dishes nemlai --json dish <query> https://nemlai.milops.org/kurv/: Open the page. Pick a dish suggestion (e.g. '+ Fisk en hverdag').
dish_info nemlai --json dish info <#> none
dish_recipe nemlai --json dish recipe none
add_dish_to_basket nemlai --json dish add all https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Pick a dish and add its ingredients.
list_shops nemlai --json shops status https://nemlai.milops.org/settings/shops: Open the page; linked shops and each member's status are shown. Price shops are the tabs above the basket on /kurv/.
refresh_shop nemlai --json shops refresh <bilka|foetex> https://nemlai.milops.org/settings/shops: Open the page. Click 'Hent kvitteringer' for the shop.
compare_shop_prices nemlai --json basket prices [--shop <shop_id>] https://nemlai.milops.org/kurv/: Open the page. Click the shop tabs above the basket (Bilka, Netto, …) to see each shop's total.
list_alternatives nemlai --json basket alternatives <item> --shop <shop_id> [--search <text>] https://nemlai.milops.org/kurv/: Open the page. Open the shop's tab. Click 'Vælg alternativ' on the item.
list_offers nemlai --json offers [--all] [--shop <nemlig|bilkatogo>] [--category <text>] none
send_basket nemlai --json basket send --keep|--replace https://nemlai.milops.org/kurv/: Open the page (log in if asked): it shows the same basket as MCP and the CLI. Check the basket and the total with the user. Nemlig: click 'Send til nemlig'. BilkaToGo: open the 'Bilka' tab and send from there. If the shop's cart already holds items, the page asks: 'Behold dem og læg de nye oveni' or 'Tøm kurven og send kun de nye'. Let the user choose. Open the cart link and let the user check out themselves.
get_shop_cart nemlai --json shop cart [nemlig|bilkatogo] none
get_delivery_info nemlai --json delivery <nemlig|bilkatogo> none
list_delivery_slots nemlai --json delivery slots [nemlig|bilkatogo] [--days <n>] none
analytics_overview nemlai --json analytics overview --last 3m https://nemlai.milops.org/analytics: Open the page. The Forbrug tab shows the total for the chosen period. Use the Top and Trend tabs for the ranking and the trend.
analytics_spend nemlai --json analytics spend --last 30d https://nemlai.milops.org/analytics: Open the page. Stay on the Forbrug tab and set the period and the Produkt / Kategori filters.
analytics_top nemlai --json analytics top products --last 90d https://nemlai.milops.org/analytics: Open the page. Open the Top tab, pick Produkter or Kategorier and set the period.
analytics_trend nemlai --json analytics trend --bucket auto --last 12m https://nemlai.milops.org/analytics: Open the page. Open the Trend tab and set the period and filters.
analytics_drill nemlai --json analytics drill --product <name> --last 30d https://nemlai.milops.org/analytics: Open the page. Open the Detaljer tab and set the filters to see the order lines.
analytics_receipts nemlai --json analytics receipts --last 30d https://nemlai.milops.org/analytics: Open the page. Open the Kvitteringer tab and set the period and filters.
analytics_categories_tree nemlai --json analytics categories-tree --last 12m https://nemlai.milops.org/analytics: Open the page. Open the Kategori filter dropdown; it lists the category tree.
get_settings nemlai --json settings show https://nemlai.milops.org/settings: Open the page. Read 'Forudsigelsesindstillinger'.
update_settings nemlai --json settings update --cutoff <YYYY-MM-DD|none> --auto-learn <true|false> https://nemlai.milops.org/settings: Open the page. Change the setting. Click 'Gem indstillinger'.
get_preferences nemlai --json preferences show https://nemlai.milops.org/settings
update_preferences nemlai --json preferences update --dietary <d> --prefer-eco <true|false> --notes <n> https://nemlai.milops.org/settings: Open the page. Edit the preferences. Save.
preference_definitions nemlai --json preferences definitions none
recalculate_preferences nemlai --json preferences recalculate none
notifications_status nemlai --json notifications status https://nemlai.milops.org/settings: Open the page. Read 'Notifikationer'.
send_test_notification nemlai --json notifications test https://nemlai.milops.org/settings: Open the page. Click 'Send test' under 'Notifikationer'.
get_exclusions nemlai --json exclusions list https://nemlai.milops.org/ekskluder: Open the page; excluded products and orders are listed.
list_order_items nemlai --json exclusions items <order_id> https://nemlai.milops.org/ekskluder: Open the page. Open the order to see its lines.
update_exclusions nemlai --json exclusions exclude|include <order_id> / nemlai --json exclusions hide|unhide <order_id> <product_number> https://nemlai.milops.org/ekskluder: Open the page. Exclude or include the product or order.
job_status nemlai --json jobs status <job_id> none
nemlai_guide nemlai --json help https://nemlai.milops.org/agents: Open the page: the agent guide, the workflows and every tool's CLI command and web page.

Glossary

Words

Danish Meaning Notes for agents
kurv / indkøbskurv basket, shopping cart "min kurv" = the user's saved NemlAI basket (get_basket(), the web page /kurv/; per user, not per household); the shop's own cart is filled by send_basket().
tilbud offer, special price A campaign price; see the formats below. "på tilbud" = on sale.
kampagne campaign Same as tilbud in product data (campaign_* fields).
øko / økologisk organic Part of the product, not a detail: øko and non-øko are different products. Keep the "ø".
pant deposit on bottles and cans Charged on top of drinks, refunded on return. Not part of the product price.
stk (stykker) pieces, items "3 stk" = 3 items. Quantities in the basket are counted in stk unless a product is sold by weight.
kg, g, l, cl, ml units Part of the product name and size ("Letmælk 1,5% 1 l"). A size, not a quantity.
kr (kroner, DKK) Danish currency Decimal comma: 79,00 kr = 79.00 kr.
pr. kg / kr/kg, pr. l price per kilo / litre The comparison price; use it to compare sizes.
vare, varer product(s), item(s)
ret, retter, aftensmad dish(es), dinner find_dishes().
opskrift recipe dish_recipe().
husstand household Settings page section "Husstand".
medlem / "andre i husstanden" member / "others in the household"
ordre, ordrer order(s) Past purchases; refresh_orders() fetches new ones.
levering, leveringsdato delivery, delivery date Picked in the shop's checkout, not in NemlAI.
budget budget Basket presets small / medium / large (kr limits shown by the tools).
ekskluder / skjul exclude / hide Leave an order or a product out of predictions (update_exclusions()).
indstillinger settings /settings in the web app.
butik, butikker shop(s) list_shops().
tilknyt butik link a shop Web app only (takes the shop password).
adgangskode password Never ask for one in chat.
send til nemlig send to Nemlig The web app's send button: fills the Nemlig cart.
mejeri, frugt & grønt, kød, brød, frost, drikkevarer dairy, fruit & veg, meat, bread, frozen, drinks Category names; search in Danish.

Campaign text formats

Shops describe a campaign in text. NemlAI parses it into campaign_min_quantity, campaign_unit_price and regular_unit_price; when the quantity is at least the minimum the basket uses the campaign price and marks the item on_sale.

Text Means Unit price Minimum
Mix 3 stk 79,00 any 3 of the campaign's products for 79 kr together 26,33 kr 3
Ta' 2 for 50 2 for 50 kr 25,00 kr 2
3 for 57 3 for 57 kr 19,00 kr 3
a single lower price, no count a plain discount the shown price 1

"Mix" means the products can be mixed: 2 of one flavour and 1 of another still reach 3. Below the minimum, each item costs regular_unit_price. If the text can't be parsed, NemlAI falls back to the regular price; say "the campaign may not apply" rather than guessing.

Shop names

Id Shop Notes
nemlig Nemlig.com The main shop: orders come from here, the basket is sent here.
bilkatogo BilkaToGo (shown as "Bilka") Salling Group. Can receive the basket after linking.
bilka, foetex, netto Bilka, Føtex, Netto (Salling Group) Receipts can be linked for analytics; prices for comparison.
rema1000 REMA 1000 Prices for comparison only.

Use the ids list_shops() returns; they are the only valid values.

NemlAI never checks out: the user pays on the shop's own site. · nemlai.milops.org