Sous
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Sous Get me a quote for 20 liters of skimmed milk for Monday delivery."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Sous
Sous is Valio Aimo's front door for AI purchasing agents. A hotel's, hospital's or school's agent searches the catalog over MCP or REST, gets a quote signed by the gateway with live Valio price and availability, and orders under a mandate its buyer set. The customer stays in control, and autonomy is earned: plain code puts every order in one of four tiers (auto, auto + FYI, needs approval, refused), no language model ever approves an order or relaxes a rule, and the agent gets more freedom only when the buyer accepts it.
Built at Junction 2026 for Valio Aimo's challenge "Trusted Trade Agents".
The flow, in Valio's four steps:
Request. The agent states what the buyer needs (amount, unit, date, what it is for) under the buyer's signed mandate (valtakirja).
Lookup. Search over 17,546 products (fi/sv/en) on a local index, no API calls; live price and availability for a shortlist only, at most 2 batched Valio calls per quote.
Order. A signed quote (pack sizes, VAT, cut-offs, stock as ample/limited/short) is ordered once, at that price; the mandate engine decides the tier and names the rule.
Approval. A held order reaches the buyer's phone with an approve-by time (earliest cut-off minus 15 min); routine orders go through and are logged.
What keeps the buyer in control:
Only an authenticated person of the buyer approves. Held orders are decided on the buyer side with the approver code or a passkey; agents get 403 (audited), the claude.ai connector refuses an approver token, and Valio staff see held orders read-only.
No agent can become its own approver. Passkeys are enrolled only with the buyer's own credential, never by whoever opens a link first; a passkey approval signs this order at this total, including any "remember this" choice.
Autonomy is earned and can be taken back. After 5+ unchanged approvals over 2+ weeks for one rule and product type, Sous proposes ask → notify with the evidence; the buyer accepts it as a re-signed mandate, and a later rejection proposes the revert. Never allergens or spend limits (PROTOCOL 7.2).
The right product for its use. Skimmed milk for a dessert or coffee cream for whipping is held with a fitting alternative; a substitute is judged by use (another brand of whipping cream for a dessert asks, coffee cream for coffee doesn't).
Needs change mid-contract. A ward goes nut-free: standing, held and upcoming orders and remembered approvals are re-checked and conflicts held.
Buyer controls. Four-eyes (the requester can't approve), a cap on a substitute's price premium, and doubts the agent declares always reach a human (PROTOCOL 7.3).
A record both sides can check. Every rule at
/rules(en/fi), signed quotes and receipts (Ed25519 JWS; the order card inside Claude checks the signature itself, see/ui/cards.html), a hash-chained audit log plus MariaDB system versioning: what the mandate said when each order was decided.
Try it: watch agents order live at sous.saarikko.dev · try it in the browser in 5 minutes, nothing to install or paste: /ui/try.html (one click makes your sandbox; the agent orders, you approve, you check the log; your own Claude connects from the same page) · the same by curl: docs/pitch/try-it.md · approve as the buyer: /ui/buyer.html · try six attacks: /ui/break.html · in claude.ai add the connector https://sous.saarikko.dev/connect/mcp and press "Create a demo sandbox and connect" (on staging now, https://staging.sous.saarikko.dev/connect/mcp, until production is redeployed; you get the approver token, the assistant only the agent token; other clients: docs/CONNECT.md) · honest numbers, held-out set included: docs/STATUS.md.
Download a verification bundle from an order in the reference buyer, then verify it with independently trusted public keys (Node 22+, no dependencies):
node scripts/verify_buyer_bundle.mjs order-proof.json trusted-jwks.json
node scripts/verify_buyer_bundle.mjs --tamper order-proof.json trusted-jwks.jsonThe second command demonstrates rejection of an altered receipt and preserves the saved proof.
Judge path in 5 minutes
The demo server is production, https://sous.saarikko.dev, set as SOUS below (the earlier 135-181-8-209.sslip.io and staging.135-181-63-134.sslip.io names still work). It is redeployed from main until the Sun 15.11 08:00 Helsinki freeze. Staging, https://staging.sous.saarikko.dev, is the fallback if production is ever down and usually runs newer code (on Sat 14.11 14:11Z /health showed prod at cdb7670, staging at ede8518); docs/STATUS.md says what runs where. Needs curl and jq.
Dates: Valio's mock gives Stock products a cut-off of Sun 15.11 17:00 for Monday delivery. After that the quickstart's Monday date is refused (availability.cutoff_passed) unless the server runs the disclosed demo clock (SOUS_DEMO_NOW, PROTOCOL.md section 21; shown in /health as clock.mode) or you ask for a later date.
Get a private sandbox: copies of the hotel, hospital and school mandates, valid 24 h, 15 live Valio lookups. The two tokens in the response are shown once.
export SOUS=https://sous.saarikko.dev curl -s -X POST $SOUS/sandbox | tee sandbox.json | jq '{sandbox, mandates: [.mandates[] | {mandate_id, rules}]}'Run the quickstart: the response's
quickstart.curl_quotegets a signed quote for 100 L of lactose-free milk for the hotel, andquickstart.curl_orderorders it.Q=$(jq -r .quickstart.curl_quote sandbox.json | sh | jq -r .quote.id) jq -r .quickstart.curl_order sandbox.json | sed "s/<quote.id>/$Q/g" | sh | jq 'if .error then . else {id, status, tier: .decision.outcome, rules: [.decision.findings[].rule]} end'Within the hotel's limits it is
ordered, tiersilent(auto), rulemandate.ok. Every finding carries arule_url. The idempotency key is your sandbox id plus the quote id: run the order line again and you get the same order back, not a second one. Keys are scoped per agent, so no two judges clash.Approve a held order as the human: every new sandbox is seeded with a hotel standing order (four weeks of simulated history, milk creeping up) and this week's renewal already held for approval by the drift rules (
standing.quantity_creep,standing.quantity_trend,standing.cost_drift);seededin the response names it. It is priced from reference prices stored in the gateway, not a live Valio quote, and says so on the order; creating the sandbox makes no Valio call. Open the seller inbox, pasteapprover_tokenwhen it asks for the approver code, and approve or reject. The token works only on your sandbox's orders.jq '.seeded | {held_order_id, why, reasons}' sandbox.json jq -r .quickstart.seller_ui sandbox.json # $SOUS/ui/?sandbox=SBX-....#/inboxCheck the rules and the audit chain:
curl -s $SOUS/rules | jq '.tiers, .count' # every rule: id, owner, tiers, plain text curl -s $SOUS/audit/verify | jq # recomputes the hash chain: "ok": true, events, head curl -s "$SOUS/audit?sandbox=$(jq -r .sandbox.id sandbox.json)&limit=5" \ -H "Authorization: Bearer $(jq -r .agent_token sandbox.json)" | jq '.events[] | {seq, event, hash, prev_hash}'Connect an MCP client to
$SOUS/mcpwith the agent token, then paste one of the fourprompts. Prices and stock are live, so products and totals vary; each prompt'sexpectsays which tier and rule to look for: hotel milk (usually ordered with an FYI,spend.notify), hospital substitute (held for a human,substitution), past the cut-off (refused after the cut-off,availability.cutoff_passed), school over budget (refused above €1,500,spend.hard_limit).jq -r .quickstart.claude_code sandbox.json | sh # claude mcp add --transport http sous $SOUS/mcp --header "Authorization: Bearer <agent_token>" jq -r '.prompts[] | .title, .prompt, ""' sandbox.jsonFrom claude.ai, Claude Desktop or ChatGPT instead: add a custom connector with URL
$SOUS/connect/mcp, press Connect and paste theagent_tokenon the Sous sign-in page (OAuth; never the approver token). Cursor, VS Code and the details: docs/CONNECT.md.
Evaluation, as docs/STATUS.md states it. Held-out (LLM_EVAL.md, CI 43620135327): 15 buyer requests written blind before reading the code, Gemini 4 Hydrogen + Sous agent, 3 runs each: 0 false auto-approvals, 9/9 unsafe requests stopped (all by the gateway, the agent placed every one), but only 3/36 should-order runs matched the need (one item per request, headcounts read as pack counts). After the fixes, held-out set 2 (LLM_EVAL.md, CI 43628767992, 20 new blind requests x 3 runs, run once): again 0 false auto-approvals, 12/12 should-hold held or refused; wrong dates 12 -> 0, expected lines at the right amount 10/96 -> 73/167, dishes cookable 0/9 -> 5/9; whole requests matched with no correction still 3/48 (stock on hand read as an order, long Finnish lists merged, counts as cartons), but 27 of those runs waited for a human instead of shipping a guess and 3 went through alone (a starch as thickener, findings). The rest are our own cases. From one full CI run on CRACI, 43587725624 on f31967b: scripted cases (no LLM, live Valio prices) 60/60, 43/43 unsafe actions blocked or escalated; LLM buyer agent on 48 one-sentence cases: Kimi K3.1 Pro (Verda Inference) 47/48, Gemini 4 Hydrogen 46/48, Nemotron 3.5 Super VL 30/48, with 0 mandate violations auto-ordered by any of them (unsafe requests blocked or escalated 34/34, 34/34, 33/34); mandate drafting by Gemini 23/23. Conformance kit 17/17 on staging (CONFORMANCE.md); check-the-checker catches 47/47 planted violations (EVAL_INTEGRITY.md); Monday rush, 60 agents on 400 units of stock: 0 over-allocated (ROBUSTNESS.md). Recipe planner, 7 menus (CI 43595658113, after the R05 crash fix): recipe table 7/7, Gemini 5/7, Kimi 5/7, every miss a quantity outside our 35 % bar. Red team on a Verda GPU (REDTEAM.md, 01480fd): 2,000 generated requests through a self-hosted Qwen3-30B-A3B (vLLM), 976/997 unsafe held or refused; it found 3 order-slicing misses in the gateway (demo mandates had the split window off), fixed with a 60-minute split window (ad366ce; Gemma 4 re-run on 100 slicing requests: 0 gateway violations). Spend so far: $56.01 known (COSTS.md).
Related MCP server: AchPay
Valio's brief → where it is in Sous
Valio's brief | In Sous | Number or link |
Flow Request → Lookup → Order → Approval |
| CI eval: 60/60 scripted cases end to end (STATUS) |
1. Lookup: structured search of products, prices, availability |
| 17,546 products fi/sv/en; avg 0.47 Valio calls per request, max 2 (CI 43587725624) |
2. Request and order at a fixed price |
| 20 parallel orders on one quote → 1 ordered, 19 rejected |
3. Permissions and approval: agent alone vs human sign-off | Signed mandate → tiers silent / notify / ask / block, every rule at | 43/43 unsafe blocked or escalated; red team 976/997 (REDTEAM.md) |
Bonus: more than price | Quote lines carry delivery reliability, labels/certifications, CO₂ estimate (range across options), origin | low reliability (< 90 %) is flagged on the line |
Bonus: exceptions | Partial availability → signed options (part now + rest later, later date, substitute), type-aware substitutes, use-aware substitution (a substitute is judged for what the product is used for: another brand of whipping cream for a dessert asks a person, coffee cream for coffee doesn't; rule | eval cases O01–O06; PROTOCOL 5.1, 5.1.1; tests/test_use_sensitivity.py |
Bonus: exceptions, the buyer's needs change | A ward goes nut-free mid-contract: the mandate update re-checks standing orders, held and upcoming orders and remembered approvals; conflicts are held with a reason ( | tests/test_needs.py; PROTOCOL 8.1 |
Bonus: explain decisions | Every finding has a rule id, plain-language reason and |
|
Good to know: not a chatbot; query APIs only when needed | MCP/OpenAPI for any agent; Valio is called only for a quote's shortlist, cached, with a shared budget (Aiven Valkey) | 0.47 calls/request; never a full-catalog fetch |
Good to know: not production-ready in 48 h | Demo-grade identity, list prices, ERP hand-off stub labelled simulated |
Where to read more
docs/pitch/try-it.md: the sandbox walk-through as a one-page handout.
docs/STATUS.md: what works, what is deployed where, evaluation with run ids, assumptions, open questions.
docs/PROTOCOL.md: the full protocol. Start with 6. Tiers and rules, 12. Audit log and 18. Judge sandbox.
docs/DECISIONS.md: technical choices and the path to production.
docs/INTEGRATION.md: how Sous plugs into Valio Aimo's systems: orders to SAP (ORDERS05 outbox, stub today), customer accounts (credit limit, payment block, delivery days), contract prices, routes, a phased plan, and what we don't know.
docs/CONFORMANCE.md: one command (
scripts/conformance.py --base <url>) runs 17 black-box checks (signatures, tampering, idempotency, self-approval, private reads, hard limit, prompt injection, audit, stock exposure) against any Sous gateway; 17/17 on staging, run output included.docs/STANDARDS.md: mapping to AP2, ACP, A2A, MCP server cards, schema.org, W3C VC 2.0, JOSE, and Peppol BIS Ordering 3.0 (UBL Order and Order Response per order).
docs/EVAL_INTEGRITY.md: checking the checker, 47 planted violations the evals and verifiers must catch.
docs/ROBUSTNESS.md: the Monday rush, 60 agents on one short product, stock reservations and fair share.
docs/CAPACITY.md: measured capacity of one gateway process (4 CPUs, MariaDB, Valio replayed): ~23 req/s, ~3 orders/s; the bottleneck and the scale-out plan.
docs/CRACI.md: CI on CRACI runners, deny-by-default egress per job and the signed egress evidence.
docs/COSTS.md: every paid model run and running service.
docs/LLM_EVAL.md (per-model LLM eval, case by case), docs/CI.md, docs/VERDA.md, docs/GEMINI.md, docs/NEMOTRON.md, docs/AIVEN.md, docs/demo-runbook.md.
The rest of this README: features, API table, local setup, configuration, architecture.
What it does
Search 17,546 products in Finnish, Swedish and English, with filters for claims, allergens, labels, temperature and country of origin. Search uses a local index and costs no Valio API calls. Type-aware ranking (9eb15ec): 'laktoositon maito' ranks plain lactose-free milk above coffee milk; precision@3 90.5 % → 99.3 % on our own 49 tuning queries.
Signed quotes with live price and availability for a shortlist only: 2 batched Valio API calls per uncached quote, 2 more when an item is short and substitutes are priced for the options (1.0 on average with the cache in
evals/results.json). Pack and lot sizes, VAT, cut-offs, stock vs Terminal, delivery reliability, CO₂ estimate, splits when stock is short.Mandate engine in plain code. Every order lands in one tier: silent, notify, ask or block, with the rule that fired.
What always needs a human in the demo mandates: a substitute for what was asked, a product the buyer has never bought before, a unit price more than 20 % off the last price paid, and anything past the daily or weekly budget or that several small orders add up to past the auto-approve limit. Valio Aimo can pause all agent ordering (every buyer or one buyer) with one call; nothing is ordered or approved until it resumes.
Autonomy levels, capped by Valio Aimo: a mandate lets its agent advise only, reorder the buyer's usual products, or order; Valio can cap any buyer (or all) lower with one call, and held orders name Valio's reason.
Order cards in the chat (MCP Apps): in Claude (web, desktop, phone) every quote, order, substitute and renewal arrives as a card under the tool call, served by the gateway (
ui://sous/card.html): lines, totals, the mandate's decision rule by rule, the approval link for the buyer's person, and the gateway's Ed25519 signature checked inside the chat. Without Claude:/ui/cards.htmldraws real demo orders with the same card.Lost demand, Valio's view (
/stats/lost-demand, stats page): the value of orders Valio couldn't supply vs ones the buyer's rules refused or a human declined, quantities no product covered, and why products were passed over (cut-off, stock, holds).Peppol BIS Ordering 3.0: every order as a UBL Order and Valio's Order Response (accepted / held / rejected) at
/orders/{id}/peppol/order.xmland/order-response.xml, for buyers' ERP and e-procurement systems. Validated against the official UBL 2.1 XSD and OpenPeppol BIS schematron in CI (9e5ed51).Undeclared allergens, read by the Gemini Decisions API: 30 % of food products declare no allergens. For those,
gemini-decision-1answers one yes/no per avoided allergen from the product's name and ingredients; at 80 % or more the order is refused (allergen.likely). Below that it stays unknown and a human decides: a model answer never makes a product safe. Accuracy on products whose allergens are known is on/proof.Orders and approvals: one order per quote, idempotency keys, cut-offs re-checked at order and at approval, signed receipts. A human approves or rejects from a phone link before an approve-by time: the earliest cut-off minus 15 minutes, capped by the mandate's and sandbox's expiry (497be36). Explanations and rule texts also in Finnish (
/rules?lang=fi, 59fcce3). With no real order API, the ERP hand-off is a stub and labelledsimulated(3509492).Substitutes good / better / best that understand product type (sour milk is not milk), keep claims and allergen rules, and list each compromise.
Standing orders with drift detection: a weekly order is renewed live and compared with its last 4 renewals. Quantity creep, cost or price rises, a switched product or a new supply risk lift the renewal to ask.
CO₂ as a range: each quote line and the quote total show an estimate plus the low and high among the options the agent shortlisted, so the effect of the choice is visible (a spread between options, not a measurement uncertainty).
Purpose per item: say what a product is for (
coffee,recipe: lasagne,allergy menu), per order item or once in the mandate. Coffee cream may be any brand; a substitute in a recipe needs the buyer's yes; for an allergy recipe only the exact product goes through, and Valio Aimo's suggestions, offers and substitutions at pick follow the same rule. See PROTOCOL.md section 32.Origin check: every quote line and card shows the supplier-declared country of origin, a quote summarises where the goods come from, search takes
origin=, and a mandate can allow or avoid countries and groups (EU, NORDIC ...). Unknown origin is never treated as allowed. See PROTOCOL.md section 30.Valio suggests: Valio Aimo's own sponsored suggestions (cheaper, certified, more reliable, lower CO₂, seasonal, new), pre-checked against the buyer's mandate. Offers only, never orders, and audited.
Notifications for notify and ask orders and human decisions: in-app feed, ntfy push to a phone, signed webhook.
Audit: a hash-chained log both sides read, plus MariaDB system-versioned tables (the mandate as of each decision).
LLM buyer agent (
agent/): any OpenAI-compatible model (Gemini, Verda Inference, self-hosted vLLM) turns a sentence into the gateway's MCP tool calls. The runtime binds identity, mandate and idempotency; the model never decides. Scored per model byscripts/llm_eval.py.Snap a photo of the order (CLI;
/agent/runs on staging only, and photo upload needs an NVIDIA key there): NVIDIA Nemotron 3.5 Super VL reads a handwritten order list, a menu or a delivery note (Finnish, Swedish, English); the agent turns it into one basket quote under the mandate, and low-confidence lines go to a human instead of the basket. See docs/NEMOTRON.md.Response options when an item can't be covered as asked (part now, rest later, a substitute...), each with a mandate preview. Rule register at
GET /rules.Evaluation (our own cases, live Valio prices; details and run ids in docs/STATUS.md), all from CI run 43587725624 (f31967b) on CRACI: scripted, no LLM: 60/60 cases, 43/43 unsafe actions blocked or escalated (incl. partial-availability options and attack cases: slicing, self-approval, prompt injection, unusual quantity). LLM agent, 48 one-sentence cases, all three models in the same run: Kimi K3.1 Pro 47/48, Gemini 4 Hydrogen 46/48, Nemotron 3.5 Super VL 30/48; 0 mandate violations auto-ordered with any of them. The miss all three share, S04, is a case whose premise (only a coconut cream without allergen data exists) holds only for the scripted run.
Try it yourself
The demo server ($SOUS, see Judge path; production is a Verda CPU instance in Finland) and staging both serve /docs, /mcp (also /mcp/), /ui/ and /buyer/; human actions need the approver code. Get a private sandbox there (copies of the hotel, hospital and school mandates, valid 24 h, kept out of the curated demo data):
curl -s -X POST $SOUS/sandbox | jqThe response has your sandbox id (e.g. SBX-7Q2K), mandate and agent ids, an agent token and an approver token (both shown once), and ready-to-paste quickstarts. Then:
Connect Claude Code with the
quickstart.claude_codeline:claude mcp add --transport http sous <base>/mcp --header "Authorization: Bearer <agent_token>".Paste one of the four
prompts(hotel milk, hospital substitute, past the cut-off, over the budget) and watch the gateway decide: ordered, ordered with an FYI, held for a human, or refused with the rule.Open
quickstart.seller_ui(/ui/?sandbox=SBX-7Q2K#/inbox) and approve or reject your held orders with the approver token. It works only on your sandbox's orders.Open
quickstart.break_ui(/ui/break.html?sandbox=SBX-7Q2K), paste your agent token and try six attacks (self-approval, slicing, prompt injection, a tampered quote, replay, over the limit): each shows the verdict, the rule and the audit events, with no Valio call.
Each sandbox has 15 live Valio Aimo lookups (cached offers are free); substitute lookups count toward the same budget, like quotes (and so do suggestions); GET /sandbox/<id> shows what's left. One-page handout: docs/pitch/try-it.md; details in PROTOCOL.md section 18.
API and MCP at a glance
Area | REST | MCP tool |
Catalog |
|
|
Substitutes |
|
|
Quotes |
|
|
Orders |
|
|
Approvals (human) |
| none, on purpose |
Mandates |
|
|
Suggestions |
|
|
Standing orders |
|
|
Notifications |
| |
Audit and trust |
| |
Discovery and standards |
| |
Judge sandbox |
|
MCP also offers the resources sous://allergens and sous://mandates/{id}, and a purchase prompt. Full details, error codes and rule ids: docs/PROTOCOL.md. How each object maps onto open standards (AP2, ACP, A2A, MCP server cards, schema.org, W3C VC 2.0, JOSE), what is exported and what is still missing: docs/STANDARDS.md.
Quick start
Needs Python 3.11+, uv and a Valio Aimo API key (from the Junction challenge portal).
uv sync
cp .env.example .env # set VALIO_AIMO_API_KEY
uv run python -m scripts.fetch_data # download product, reliability and CO2 files, build the search index
uv run python -m scripts.seed # issue the demo mandates (hotel, hospital, school, Kaarna Catering)
uv run uvicorn sous.api:app --port 8000Then open:
http://localhost:8000/docs for the REST API (OpenAPI schema at
/openapi.json)http://localhost:8000/ui/ for Valio Aimo's view: approval inbox, orders, mandates, audit log
http://localhost:8000/buyer/ for the reference buyer (see buyer/README.md)
Connect Claude Code as the buyer agent over MCP:
claude mcp add --transport http sous http://localhost:8000/mcpThen ask it, for example: "You are aurora-purchasing-agent with mandate M-HOTEL-AURORA. Buy 1,000 litres of lactose-free milk for delivery on Monday." Against the demo server, use $SOUS/mcp instead.
Mandates with require_agent_auth (the seeded M-HOTEL-AURORA-SECURE, whose token the seed prints once) also need the agent's credential. Pass it as a header:
claude mcp add --transport http sous http://localhost:8000/mcp --header "Authorization: Bearer <token>"Without it, quotes and orders on that mandate get agent_auth_required. See PROTOCOL.md, section 16.
The MCP server also runs over stdio: uv run python -m sous.mcp_server.
Demos and evaluation (each takes --base <gateway URL>; see docs/demo-runbook.md):
uv run python scripts/demo.py --list # the video's scenes, played over REST
uv run python scripts/demo_standing.py # standing order drifts, renewal is held (needs the approver code if set)
uv run python scripts/eval.py # the scripted buyer requests (60 cases), live Valio prices -> evals/results.json
uv run python -m agent.buyer "We need 1,000 litres of lactose-free milk delivered on Monday." \
--mandate M-HOTEL-AURORA --agent aurora-purchasing-agent --gateway http://localhost:8000
uv run python scripts/llm_eval.py --provider verda # the eval sentences through the LLM agent, per modelThe LLM agent needs GEMINI_API_KEY, VERDA_INFERENCE_API_KEY or LLM_API_KEY with LLM_BASE_URL (see agent/llm.py).
From a menu to one order (recipe planner)
A chef says what they are cooking, not what to buy. agent/recipe.py turns that into one basket quote:
uv run python -m agent.recipe --mandate M-HOTEL-AURORA --agent aurora-purchasing-agent \
"Lasagne for 120 on Monday, we have 10 kg flour and plenty of salt" # add --order to place itIt prints the chain: recipe (dishes, portions, per-portion ingredients) -> ingredients (scaled, minus stock on
hand, rounded up, in kg/L) -> products (up to 5 catalog candidates per ingredient, filtered by the mandate's
allergen and claim rules) -> one signed basket quote (the gateway picks packs and the cheapest orderable product,
reports what is uncovered) -> substitutes for gaps -> mandate preview -> order or ask. Gemini (when
GEMINI_API_KEY is set) only reads the chef's sentence into schema-checked JSON; every number after that is code.
Without a key, or with --provider table, a curated recipe table (agent/recipes.json: lasagne, lohikeitto,
joulukinkku, rosolli, karjalanpaisti, kasvissosekeitto) does the reading, and the output says which path was used.
agent.buyer routes menu-shaped requests here automatically. Eval: uv run python scripts/recipe_eval.py --providers table gemini (7 cases, evals/recipe_cases.yaml). See docs/GEMINI.md and
PROTOCOL.md, section 20.
Tests: uv run pytest -q (offline; the MariaDB tests run when SOUS_TEST_MARIADB_URL is set).
Configuration
Read from the environment, or from .env in development. Never commit .env. The project was called Valtakirja before; the old VALTAKIRJA_* names still work as a fallback for every SOUS_* variable.
Variable | Default | Purpose |
| (empty) | Key for Valio Aimo's price, availability and download APIs. Required. |
|
| Valio Aimo API base URL. |
|
| Customer number sent to Valio Aimo (the mock ignores it). |
|
| Data files, catalog index, SQLite state and the Ed25519 signing key. |
|
| SQLite state file, used when no MariaDB URL is set. |
| (empty) |
|
|
| Quote lifetime in seconds (shortened further by order cut-offs). |
| (empty) | If set, human actions (approve, reject, issue or revoke a mandate) need it in the |
| (empty) |
|
| (empty) | Public URL, used to build |
|
| POST requests per client IP per minute. |
|
| Per-process cap on live Valio Aimo API calls per minute. |
| (empty) | Aiven for Valkey ( |
|
|
|
|
| Shared Valio Aimo calls per minute across all deployments (sliding window in Valkey). |
| (empty) | Used by |
| (empty) |
|
| (empty) | ntfy topic for push notifications to the buyer's phone. Optional |
| (empty) | Webhook that receives notifications as JSON. With |
| (empty) | Model keys for the LLM buyer agent ( |
| (presets) | Override the agent's model: |
Docker
cp .env.example .env # set VALIO_AIMO_API_KEY, MARIADB_PASSWORD (any random string), SOUS_APPROVER_CODE
docker compose up -d --buildThis starts the gateway on port 8000 and MariaDB 11.4. On first start the container downloads Valio Aimo's data, builds the index and issues the demo mandates; later starts reuse the valtakirja-data volume. State lives in the mariadb-data volume.
Architecture
flowchart LR
subgraph Buyer side
H[Buyer human]
A[Buyer agent<br/>Claude Code, agent/ LLM agent, scripts]
B[Reference buyer<br/>/buyer]
end
subgraph Sous gateway
MCP[MCP server<br/>/mcp]
REST[REST + OpenAPI<br/>/docs]
SVC[service.py<br/>search, quote, order, approve]
CAT[(Catalog index<br/>SQLite FTS5 trigram)]
Q[quote.py<br/>packs, lots, cut-offs, issues]
M[mandate.py<br/>rules engine, 4 tiers]
SO[standing.py<br/>standing orders, drift]
SG[suggest.py<br/>Valio suggests]
N[notify.py<br/>feed, ntfy, webhook]
SIG[signing.py<br/>Ed25519 JWS, JWKS]
ST[(store.py<br/>MariaDB system-versioned<br/>or SQLite)]
UI[Valio Aimo UI<br/>/ui]
end
V[Valio Aimo APIs<br/>price, availability]
A --> MCP --> SVC
B --> REST
A --> REST --> SVC
H -- approve / reject --> UI --> REST
SVC --> CAT
SVC --> Q -- shortlist only --> V
SVC --> M
SO --> SVC
SG --> M
SVC --> N -- notify / ask --> H
SVC --> SIG
SVC --> STRepo layout
sous/ the gateway
api.py REST API, mounts MCP, UI and buyer
mcp_server.py MCP tools, resources and prompt (same operations as REST)
service.py search, quote, order, approve, substitutes, history
catalog.py static catalog and FTS5 trigram search, allergen checks
quote.py offers, pack and lot arithmetic, issue codes, splitting, quote expiry
mandate.py mandate model and the deterministic rules engine
substitutes.py good / better / best substitutes, product-type aware
standing.py standing orders and drift detection (standing_store.py: storage)
suggest.py Valio suggests: sponsored, mandate-checked suggestions
notify.py notifications: feed, ntfy push, signed webhook (notifications_store.py)
signing.py Ed25519 compact JWS and JWKS
store.py mandates, quotes, orders, hash-chained audit log (MariaDB or SQLite)
aimo.py Valio Aimo API client: batching, caching, call budget
config.py settings
agent/ LLM buyer agent: buyer.py (MCP tool loop), llm.py (OpenAI-compatible model presets)
recipe.py (menu -> basket), vision.py (photo -> basket, Nemotron VL)
scripts/ fetch_data.py, seed.py, demo.py, demo_standing.py, eval.py, llm_eval.py
evals/ cases.yaml (60 cases), results.json, llm_results.*.json, vision/
web/ Valio Aimo UI: approvals, orders, mandates, audit, stats, live activity
buyer/ reference buyer in the browser, verifies JWS with Web Crypto
tests/ pytest, offline plus MariaDB
docs/ PROTOCOL, DECISIONS, STATUS, CI, LLM_EVAL, VERDA, demo runbook, pitch/License
MIT, see LICENSE. Valio Aimo's product data is not included in this repo; download it with your own API key. Without it uv run pytest -q still passes offline; the few tests and evals that need Valio data skip with the reason, and docs/PUBLISHING.md lists the commands that rebuild each file.
About this public copy
This repository is the public copy of the team's private development repository, made with
scripts/publish_public.sh (docs/PUBLISHING.md). The full commit history
is kept, filtered with git filter-repo: Valio Aimo's data files (product download extracts and recorded API responses)
and the team's internal coordination notes are removed from every commit, and personal emails are mapped to GitHub
noreply addresses. Commit hashes in the docs were rewritten to this repository's hashes. CI run ids in the docs refer to
the development repository's GitHub Actions runs and are kept as plain ids; recorded results (evals/*.json) are left as
recorded, so their provenance (commit, CI run) refers to the development repository.
Available Tools
14 toolscan_i_orderCore 3/4 shortcut: Can I order this?A
What am I allowed to do? One step: quotes the request live under the mandate (same as request_quote) and answers yes / needs_a_human / no, with the rules that decided, the lines it would order and the quote_id. Places no order. If the answer is yes or needs_a_human, call place_order with quote_id (and an idempotency_key) before expires_at; if no, tell the user why. items as in request_quote (amount in L or KG, several ids).
Inputs: items (required), mandate_id (required), delivery_date (required), agent_id, +2 more. Returns: can_i_order: the mandate's answer for this request, in one step, and the quote…
create_standing_orderOptional: Create a weekly standing orderB
Save the buyer's recurring weekly order (e.g. milk + butter + cream every Monday). Places no order.
items: [{product_ids: [...candidates], amount, unit: 'L' | 'KG' | 'SALES_UNIT', label: 'milk'}]. weekday: mon..sun, the usual delivery day. Renew it each week with renew_standing_order.
Inputs: name (required), items (required), agent_id (required), mandate_id (required), +1 more. Returns: id, name, buyer, items, +6 more
get_mandateOptional: Read the mandateARead-onlyIdempotent
The mandate you act under (spend limits, allergen and claim rules, what needs a human), with its signature. Only your own mandate (send your Authorization: Bearer header or agent_token) or a public demo mandate. Returns {mandate (schema /schemas/mandate.json), jws (sous-mandate+jws), issuer, credential?, revocation?}.
Inputs: mandate_id (required), agent_token. Returns: get_mandate: the signed mandate payload, its JWS (typ sous-mandate+jws), issuer…
get_orderOptional: Order statusARead-onlyIdempotent
Current status of an order (ordered, pending_approval, refused, rejected, expired, expired_cutoff) and any human decision. Only orders under your own mandate: send your Authorization: Bearer header (or agent_token). Returns the order (schema /schemas/order.json) with approval {by, approved, note, at} once a human decided, a fresh receipt_jws, and agent_claim (the rationale, labelled unverified). After delivery problems it carries amendments[] from Valio (short pick, delay, substitution at pick, cancelled line; status pending_buyer waits for the buyer, ask-tier for their human, not you) and credit_notes[]. erp: the hand-off to Valio Aimo's order system; erp.simulated / status "simulated" / SIM- id means placed with Sous, Valio hand-off simulated in this demo (no real order reached Valio).
Inputs: order_id (required), agent_token. Returns: An order with its signed receipt. Schema: /schemas/order.json.
get_productOptional: Product detailsARead-onlyIdempotent
Full static details of one product: names, pack sizes, allergens, claims, labels, reliability, CO2 estimate.
No price or stock: those come only from request_quote.
Inputs: product_id (required). Returns: Full static product record (names, units, allergens, claims, labels, reliabilit…
get_standing_orderOptional: Standing order, history and drift baselineBRead-onlyIdempotent
A standing order with its renewals (newest first, simulated ones marked) and the drift baseline. Returns {standing_order, renewals, baseline, next_delivery_date, versions, rules}.
Inputs: standing_id (required), agent_token. Returns: rules, baseline, renewals, versions, +2 more
get_suggestionsOptional: Valio Aimo's suggestions (sponsored)ARead-only
Valio Aimo's own COMMERCIAL suggestions for your buyer: the seller promoting products, not neutral advice.
Better-value alternatives to what the buyer usually orders (cheaper per L/KG, a certification label, more reliable delivery or a lower CO2 estimate), seasonal products for delivery_date, and recently listed ones. basis: optional product ids (e.g. the current cart) to find alternatives for, besides order history. Everything was pre-checked against the mandate (nothing it would block is shown) and has a live offer and mandate_check (the tier it would get at the usual quantity). Nothing is ordered. Present each one to the buyer as "Valio Aimo suggests...", with its reason and disclosure; never as your own recommendation, and never order one unless the buyer asks: then request_quote as how_to_accept says. Read-scoped like get_mandate: send your Authorization: Bearer header (or agent_token) unless the mandate is a public demo; without one the error is agent_auth_required.
Inputs: agent_id (required), mandate_id (required), delivery_date (required), basis, +2 more. Returns: note, buyer, agent_id, disclosure, +4 more
list_offersOptional: Valio Aimo's offers (sponsored)ARead-onlyIdempotent
Valio Aimo's active COMMERCIAL offers made to your buyer: signed (jws, typ sous-offer+jws) prices below the current one for a reason (near best-before, seasonal, a replacement for a recalled product, a new product), with a quantity cap and valid_until. Products the mandate rules out (avoided allergens, categories, claims) were never offered. Nothing is ordered: present each to the buyer as "Valio Aimo offers...", with its headline and disclosure, and only if the buyer wants it call request_quote with offer_id as how_to_accept says; the mandate decides as always (a first-time product may still need a human). Read-scoped like get_mandate: send your Authorization: Bearer header (or agent_token).
Inputs: mandate_id (required), agent_token. Returns: note, buyer, offers, disclosure, +2 more
my_mandatesCore 1/4: Which mandates am I acting under?ARead-onlyIdempotent
Call this first. The mandates your credential (Authorization: Bearer agent token, or the OAuth sign-in of this connector) was issued for: mandate_id, buyer, label (e.g. hotel / hospital / school), agent_id and a one-line summary of the rules. Pick the mandate by the buyer the user names ("the hotel"); pass its mandate_id to request_quote / place_order (agent_id can then be left out). active=false: revoked, expired or its sandbox closed. Empty without a credential: ask the user for the agent token or a mandate_id; never guess one.
Inputs: agent_token. Returns: my_mandates: the mandates the caller's credential covers.
place_orderCore 4/4: Place the orderADestructiveIdempotent
Order a quote under a mandate. This is the only call that commits the buyer to a purchase.
The mandate engine decides, not you: status ordered (silent/notify), pending_approval (a human must approve at approval_url; poll get_order) or refused (decision.findings say which rule fired). Always send an idempotency_key and reuse it on retries: the same key never orders twice (the retry returns the original order); reusing a key for a different quote fails with idempotency_key_reused. Returns the order (schema /schemas/order.json): id (PO-...), status, decision {outcome, findings}, lines, totals, approval_url when pending, receipt_jws (signed receipt) and, once ordered, erp (the hand-off to Valio Aimo's order system). In this demo erp.status is "simulated" with a SIM- confirmation_id: tell the buyer the order is placed with Sous and the Valio hand-off is simulated, not that Valio confirmed it. rationale: one sentence on why the buyer needs this order; the approver sees it next to the mandate's findings. declared_risks: optional, up to 5 short doubts of your own (as in request_quote); each holds the order for a human. buyer_request: the buyer's own message, verbatim, if you didn't pass it to request_quote (checked the same way). agent_id: optional when your credential covers mandate_id (see my_mandates). agent_token: only if you can't send the Authorization: Bearer header and the mandate requires agent auth.
Inputs: quote_id (required), mandate_id (required), agent_id, rationale, +4 more. Returns: An order with its signed receipt. Schema: /schemas/order.json.
renew_standing_orderOptional: Renew a standing order (with drift check)ADestructive
Renew this week's standing order: live quote, drift check against past renewals, then the order.
amounts: this week's amount per item, in item order, if it differs from the usual (omit for the usual). The gateway compares with the median of recent renewals: quantity creep, cost or price drift, a different product, new Terminal or reliability issues. Any drift puts the order on hold for a human (pending_approval); tell the buyer drift.summary and share order.approval_url. delivery_date defaults to the next delivery day. agent_id: optional when your credential covers the standing order's mandate (see my_mandates). agent_token: only if you can't send the Authorization: Bearer header and the mandate requires agent auth.
Inputs: standing_id (required), amounts, agent_id, rationale, +2 more. Returns: drift, order, renewal
request_quoteCore 3/4: Quote with live price, stock and the mandate checkA
Get a signed, expiring quote with live prices and availability from Valio Aimo. Places no order.
delivery_date: YYYY-MM-DD.
items: one per need: {product_ids: [...], amount, unit: 'L' | 'KG' | 'SALES_UNIT', substitute_for?, use?}
(use: what the product is for, e.g. 'coffee', 'dessert', 'ingredient-in-recipe:Tiramisu'; a substitute that
doesn't suit it, like another brand of whipping cream for a dessert, waits for a person)
(pack, packs mean SALES_UNIT = cartons/packs as sold; 'ST' (PCS, KPL) counts pieces when the product has
them: 20 ST of a carton of 6 = 4 cartons, rounded up, and the line says so in unit_note).
Give several candidate ids per need and an amount in L or KG: the gateway picks the cheapest orderable
one per L/KG, splitting across products when stock is short. Mark substitutes with substitute_for.
Always pass mandate_id: the quote then includes mandate_preview (silent | notify | ask | block, and why).
Check quote.lines[].orderable, quote.uncovered and mandate_preview, then place_order with quote.id
before quote.expires_at.
If quote.options is present, an item can't be covered as asked (stock short, later date, cut-off passed,
undeliverable). Each option is a complete alternative for that item (kind, lines, total, delivery_dates,
tradeoff, substitute, mandate_preview), best first. Present them to the buyer, or pick per the mandate
(an option whose mandate_preview is silent/notify needs no human). Never silently drop the uncovered amount.
option_id: to take an option, call request_quote again with only agent_id, mandate_id and option_id
(e.g. 'Q-1A2B3C4D.O2'): you get a fresh signed quote with that item's lines replaced; then place_order it.
rationale: one sentence on the buyer's business need (e.g. "breakfast milk for 400 guests"); it is signed into
the quote and shown to the humans who audit or approve. State the need, not your reasoning steps.
declared_risks: optional, up to 5 short sentences (200 chars each) on what YOU are unsure about (e.g. "not sure
10 L covers Saturday"). Each one holds the order for a human (rule agent.declared_risk) with your words; it
can never make an order go through more easily. Say it here rather than ordering on a guess.
buyer_request: the buyer's own message to you, verbatim (at most 2,000 characters), not your summary. Signed
into the quote; the gateway reads its quantities ("1 539 litraa" = 1,539 L) and, when the quote has none of
them, asks a person (rule quantity.request_mismatch). mandate_preview.request_quantities shows what it read.
offer_id: a Valio Aimo offer from list_offers (e.g. 'OF-1A2B3C4D'), only when the buyer wants it: name the
offer's product_ids in items; they are priced at min(offer price, live price) and the quote carries offer.
The mandate decides as always (rule offer.applied tells the buyer).
agent_id: optional when your credential covers mandate_id (see my_mandates); required without a mandate.
agent_token: only if you can't send the Authorization: Bearer header and the mandate requires agent auth.
Returns {quote (schema /schemas/quote.json), jws (sous-quote+jws over quote)}.
Inputs: items, agent_id, offer_id, option_id, +6 more. Returns: request_quote: the quote payload and its JWS (typ sous-quote+jws). Schema of `q…
search_productsCore 2/4: Search the catalogARead-onlyIdempotent
Search Valio Aimo's catalog (17.5k products) by name, brand or ingredient in Finnish, Swedish or English.
claims: required nutritional claims, e.g. FREE_FROM_LACTOSE, FREE_FROM_GLUTEN, VEGAN. avoid_allergens: GS1 codes to exclude, e.g. AM (milk), AN (tree nuts), AP (peanuts), AW (gluten), AE (egg). Group codes cover the family (AN = all tree nuts). Products that contain them are dropped; products with no allergen data are kept but marked 'unknown'. Full list: resource sous://allergens. labels: e.g. GOODS_FROM_FINLAND_BLUE_SWAN, EU_ORGANIC_FARMING, MARINE_STEWARDSHIP_COUNCIL_LABEL. temperature: ambient, chilled or frozen. food_only: default ranks food first (equipment first only when the query names it, e.g. "milk jug"); true drops non-food (tableware, cleaning, disposables); false = no preference. origin: keep only products declared from these countries or groups, e.g. ["fi"], ["EU"], ["NORDIC"]; the country is supplier-declared (not verified) and products without one drop out. Every card and quote line carries country_of_origin. Synonyms work across languages: "butter" also finds voi / smör. Returns {products: [card], note}: cards without price or stock; pass several of the ids to request_quote next.
Inputs: query (required), limit, claims, labels, +5 more. Returns: note, products
suggest_substitutesOptional: Suggest substitutesARead-only
Good / better / best substitutes for a product that can't be ordered (or for any product).
purpose: what the buyer uses the product for ('coffee', 'recipe: lasagne', 'allergy menu'); substitution exact | ask | allowed. A product the buyer needs exactly (by purpose or the mandate's product_purposes) has no substitutes; for 'ask', any substitute needs the buyer's yes. Pass the same purpose on request_quote items.
Same category, measure and temperature class; keeps the original's dietary claims and the mandate's allergen rules (never relaxed). Each option lists its compromises in plain language and has a live offer. Tell the buyer the compromises, then request_quote for the chosen one with substitute_for=. Another kind of product is never offered: with nothing acceptable, no_safe_substitute says to ask a human and not_offered lists what was ruled out and why. delivery_date null = the next delivery day. With mandate_id it is read-scoped like get_mandate (send your Authorization: Bearer header or agent_token). use: what the product is used for (coffee, whipping, dessert, baking, cooking, drinking, melting, ingredient-in-recipe:). Whipping cream for a dessert needs the same product (another brand whips differently); for coffee any brand will do. Options then carry use_sensitivity, use_ok and use_reason.
Inputs: amount (required), agent_id (required), product_id (required), delivery_date (required), +8 more. Returns: options, original, next_step, considered, +2 more
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 14 tools
Most tools target clearly distinct stages of the procurement workflow: discovery, quoting, ordering, mandate inspection, and standing orders. The main overlap is request_quote versus can_i_order, which explicitly performs the same live quoting step but returns a permission answer; get_suggestions and list_offers also both surface commercial recommendations, though their descriptions distinguish neutral alternatives from seller offers.
Nearly all tools follow a predictable snake_case verb_noun pattern such as search_products, request_quote, place_order, and create_standing_order. my_mandates is a minor deviation as a noun phrase with a possessive, and can_i_order is phrased as a question, but the conventions remain readable and mostly consistent.
With 14 tools, the set is well-scoped for a complex food-procurement agent that must handle product search, quotes, mandates, orders, offers, and standing orders. Each tool has a clear role in the workflow, and the count stays within a reasonable range without obvious padding.
The surface covers the core lifecycle: find products, inspect details, quote, check permissions, place orders, track order status, inspect mandates, and manage standing orders. Minor gaps exist around canceling or amending orders and listing past orders, but these appear to be handled through human approval flows or Valio-side amendments rather than blocking core workflows.
Maintenance
Related MCP Connectors
Autonomous commerce for AI agents: discover, quote, order, pay, verify.
Hosted MCP for e-commerce: live product catalog, stock, and pricing for AI agents.
Search multi-merchant supply, checkout, and track orders via MCP.
Multi-tenant MCP gateway for AI commerce. One connection, every store.
Related MCP Servers
- FlicenseNot gradedqualityBmaintenanceEnables AI buyers to discover products, create orders, and complete payments across any merchant implementing the protocol, with enforced spending limits, consent checks, prompt-injection sanitization, and an immutable audit trail.-
- FlicenseNot gradedqualityBmaintenanceEnables AI agents to securely search products, obtain signed quotes, create checkouts, and track orders without directly handling prices or payment amounts, enforcing spending policies and audit trails.1-

GateCoreofficial
AlicenseNot gradedqualityCmaintenanceEnables AI agents to discover governed capabilities, inspect machine-readable contracts, and request policy-checked procurements with signed receipts and audit trails.Apache 2.0- AlicenseNot gradedqualityBmaintenanceEnables AI agents to discover products, verify signed catalog feeds, negotiate within published policies, and place mandate-bound orders with signed receipts via MCP, A2A, or REST.1MIT