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 loginonce in their own terminal (it asks for their NemlAI password there, not in chat). The CLI stores a token starting withnemlai_in~/.config/nemlai/token; it is sent asAuthorization: Bearer <token>. The configs below read it from theNEMLAI_TOKENenvironment 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-runshows the change.nemlai mcp config <client>only prints the config;nemlai mcp doctorchecks 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.zipin Claude's Skills settings. -
Claude Code:
nemlai mcp install claude-code. It runsclaude 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 needsNEMLAI_TOKENset when it starts; with a fixedAuthorizationheader 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 codexadds this to~/.codex/config.tomland 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 hermesadds this undermcp_serversin~/.hermes/config.yamland puts the skill in~/.hermes/skills/nemlai/. SetNEMLAI_TOKENin 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 openclawadds this to~/.openclaw/openclaw.jsonand puts the skill in~/.openclaw/skills/nemlai/. SetNEMLAI_TOKENin the environment or in~/.openclaw/.env. If the file has JSON5 comments, runopenclaw 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 genericprints 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
fallbacktopic, orerror.fallback.cli). Run that command as written: the global--jsonmakes every command print one JSON object (statusplus the data, orstatus+messagefor a text reply, orstatus+error+hint). With nemlai older than 0.11 (nemlai --version), add--format jsonto analytics commands; the report then comes back as a JSON string inmessage, so parse it a second time. - The CLI reads the token from
~/.config/nemlai/token, else fromNEMLAI_TOKEN. Don't pass--tokenon 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
- Check the connection:
whoami(). No household → the user joins or creates one in the web app first. - 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 ajob_idforjob_status(). - Review with the user: show the items, quantities and the total in kr against the budget.
explain_item()says why an item is there. - Adjust:
add_to_basket()(by SKU fromsearch_products()),set_quantity(),remove_from_basket(),add_dish_to_basket()for a meal. - Optionally compare shops:
compare_shop_prices(). - 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": ["…"]}.nextlists what makes sense now; follow it. - Failure:
{"ok": false, "error": {"code", "message", "hint", "fix": {"tool", "args"}, "fallback": {"cli", "web"}}}. Dofixif present; otherwise see thefallbacktopic.
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
- 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. - The user decides what happens to their shop cart. Call
send_basket()withoutcart_modefirst. An empty cart is filled straight away. If the cart at the shop already holds items, nothing is sent and the answer iscart_not_emptywith 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 callsend_basket()again withcart_mode="keep"(a product in both gets the higher quantity) orcart_mode="replace"(empty the cart first). Never pick the mode yourself, and never sendreplacewithout that answer: it removes things the user put there. - Send once.
send_basket()is a job. If it times out, fails or the result is unclear, checkjob_status()with itsjob_id, orget_basket(), before anything else. A second send can double the shop's cart. - 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()andlist_delivery_slots()only read them. - The cart link comes after the send. A completed send returns
cart_url: for Nemlig the plainhttps://www.nemlig.com/basketlink, 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
- Prices are in Danish kroner (kr). Show them as the tools return them (e.g.
79,00 kror79.00 kr); don't convert currencies. - 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 regularregular_unit_priceapplies. When a user is 1 item short of a campaign, you may point it out; don't raise quantities on your own. - 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
- 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").
- Ø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
- 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. - 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. - 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
- Never invent results. If a tool fails and the fallbacks fail, say so plainly. The user can still shop directly on nemlig.com.
- 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").
- Follow
nextandfix. Tool results say what state things are in and what to call next. Callnemlai_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".
whoami(): check the connection and that there is a household.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).- If the orders look stale (the basket or
order_fetch_status()says the last fetch is old), offerrefresh_orders()first so the prediction knows about the latest order. It is a job: wait for it or polljob_status(). If it saysreauth_required, seeorder-refresh-reauth. generate_basket()with the user's budget preset (mediumif they don't say). It waits up towait_seconds; if it returns ajob_id, polljob_status()until it is done.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.- 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, thenadd_to_basket()with the chosen SKU. - "why is X there?":
explain_item().
- "more milk":
- Show the new total. Ask "Shall I send it to Nemlig?" and wait for a clear yes.
send_basket()withshop="nemlig". If it returns ajob_id, polljob_status(); never send again without checking.- 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_urlas "Å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".
find_dishes()with what the user wants ("fisk", "vegetar", "hurtig", "lasagne"). Search in Danish; keep æ, ø, å.- Show a short list (name, a line about it). Let the user pick; don't pick for them unless asked.
- 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. get_basket()to see what is already there, so you don't add a second pack of something.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").get_basket()and show the new total in kr. Repeat for the next dish.- 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".
get_basket(): the total in kr, the budget, and each item's price, quantity and campaign info.- Work out how much to cut (total minus the target).
- Propose cuts, cheapest pain first:
- items the prediction is least sure about:
explain_item()reasons with categoryfills_budget(added only to reach the budget) orless_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).
- items the prediction is least sure about:
- Mind campaigns: lowering a quantity below
campaign_min_quantityraises 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. - Show the proposal as a list with what each change saves and the new total. Change nothing until the user agrees.
- Apply:
set_quantity(),remove_from_basket(), oradd_to_basket()for a swap. get_basket()and show the final total against the budget.- 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".
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.list_shops(): the shops, whether each is linked, its status and whether it can receive a basket (send_mode). Use only these shop ids.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; checkprices_as_ofand say when a shop's prices are old.- Show the user a short table: shop, total in kr, matched / missing. A shop with
total_is_partiallacks some items, so its total is not the basket's price there: compare it withsame_items_nemlig_kr(the same items at Nemlig) and say how many items are missing.cheapest_shoponly counts complete totals. - 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. - 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. - Show the items and the total at that shop and wait for a clear yes; then
send_basket()with thatshop. Polljob_status()if it returns ajob_id. - 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?".
- Start broad:
analytics_overview()for the period the user names (this month, last 3 months, 12 months). - 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().
- "how much on X / in category Y":
- Report numbers as the tool returns them, in kr, with the period. Say how many orders the period covers; few orders make trends unreliable.
- 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".
get_exclusions(): what is excluded or hidden today.- Find what to exclude:
- a product: from
get_basket()(it is in the basket now) orsearch_products(); use its SKU; - an order:
analytics_drill()oranalytics_receipts()show orders;list_order_items()shows one order's lines.
- a product: from
- Say what will change ("the order from 20 December will be left out of predictions and spending reports") and get the user's yes.
update_exclusions(): exclude a whole order, include it again, or hide single products in an order.- If the product is in the current basket, also
remove_from_basket()(exclusions shape future baskets; they don't edit this one). - For a lasting "never suggest this", also check
get_preferences()/update_preferences()(e.g. excluded products, prefer øko). - 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.
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.- Only when the user wants new orders:
refresh_orders()for the caller's own Nemlig account (ormember=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 polljob_status(). - Done → tell the user how many new orders arrived; offer
generate_basket()for a fresh basket. 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 typeorders 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.
job_running: a refresh is already running; poll thejob_idinfixwithjob_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.
list_shops(): which shops are linked, their status, and which can receive a basket.- 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,foetexthemselves; it asks for the password without echoing it.
- When they say it's done:
list_shops()again. A newly linked shop may needrefresh_shop()(a job; polljob_status()) before its receipts or prices show up. - 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().
get_shop_cart()(orshop="nemlig"/"bilkatogo"): the lines, fees, total and whether the minimum order is met. Lines withbasket_skuare also in the NemlAI basket.- Sending: call
send_basket(shop=…)withoutcart_mode. If the shop's cart already holds items, nothing is sent and the answer iscart_not_empty: ask the userdetails.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 withcart_mode="keep"or"replace"as they answer. Never choose for them. - 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). get_delivery_info(shop=…)for the chosen time and the deadline to change the order.- 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 withreauth_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", afterrefresh_orders(), or whenget_basket()is empty or old.get_basket: before talking about the basket, beforesend_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, oradd_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 theanalytics_*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 choosingkeeporreplaceaftersend_basket()answeredcart_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 theorder_numberforanalytics_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?", beforeupdate_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, orget_preferencesshowstotal_orders0 / stale numbers.notifications_status: "do I get push notifications?", beforesend_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) returnedstatus: "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.
- The tool returned an error with a
fix: do thefixcall. Retry a timeout,upstream_unavailableor a 5xx once after a few seconds. For a job, polljob_status()with itsjob_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, exceptsend_basket()(check the basket and the shop's cart first). - 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'sfallback.cli) as written, withNEMLAI_TOKENset. They arenemlai --json <command>. - 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. - 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(), checkjob_status()or the basket (get_basket(),nemlai --json basket itemsor/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 withconflict: 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