A demo built for the Junction 2026 Valio Aimo challenge, on synthetic data. Not the wholesaler's own shop.

web/docs/offers.md

The wholesaler's selling agent, and its open offer

The partner's scenario: "Valio Aimo's own agent spots a new or seasonal product and proactively suggests it to the buyer's agent." Here the wholesaler's agent speaks to the buyer's agent, not to a person, through an open, signed message that any agent can check offline and answer through MCP or REST. An offer never buys anything. The buyer stays in control: it can say no, mute a kind of suggestion, or turn offers off, and the wholesaler's agent reads that before it picks anything.

PieceFile
Selection, guardrails, the signed offer, its offline check, the buyer's own check (plain code, no model)src/core/offers.ts
The season calendar for Finland (month by product kind, each entry with a public source)src/core/season.ts
Runs, campaigns, answers, records, the ledger (type offer)src/server/offers/service.ts
The kitchen's own agent: data and actions for the conversationsrc/server/offers/thread.ts, words in say.text.ts
MCP tools (beside the ordering table)src/server/mcp/offer-tools.ts
RESTsrc/app/api/v1/offers/** (in the OpenAPI document)
Operations page/ops/offers, src/app/ops-api/offers/route.ts
Offline check with only jose and the JWK Setpnpm verify:offer (scripts/verify-offer.ts)
End to end on the real catalogue, and as an outside MCP agentpnpm offers (scripts/offers.ts)

What the selling agent proposes, and from what

For each kitchen, at most a few products (never more than two offers a kitchen in seven days, one per product family per run). It looks first at master data, free, through the one product search (server/search findProducts, recall only, no relevance judge, so a run costs no model call): the families the kitchen buys, like-for-likes of its usual product, the products the wholesaler listed as new, and a campaign's product. Then one live price call for at most six products (the shortlist and the kitchen's usual products to compare with). Only these reasons can be given, each only when the data backs it:

ReasonBacked by
new_in_assortmentthe wholesaler's own listing record (a staff member lists the product as new when starting a campaign), for 90 days
in_seasoncore/season.ts: in season this month in Finland, harvest (berries, mushrooms, root vegetables, crayfish, moose) or festive (Christmas, Shrove Tuesday, Easter, May Day), with its source
cheaperlike for like with what the kitchen orders every week (same family and unit, the same material claims, a pack within half to twice the size, the same kind), cheaper per litre or kilo by at least 3 %, both at live prices the wholesaler signs, both pack contents certain
more_reliablelike for like, on time at least 3 points more often (the partner's delivery file, both known)
lower_carbonlike for like, at least 10 % less CO2e per litre or kilo (both known; product-group averages are said to be averages)
new_to_kitchennever bought, in a family the kitchen buys. Only ever a second reason

Plain code writes the one-line reason from the reasons. A model may write it instead, opt-in (DOCK_OFFER_LLM=on, DOCK_OFFER_MODEL): the grounding check (core/grounding.ts, surface offer) holds it to the offer's own figures, and a line that fails is replaced by the plain-code one. The model chooses nothing. It is off by default because of what we measured on 14 November on the real catalogue: a cheap model's lines passed the check (every number was in the data) while saying what the data does not ("has not ordered milk before" to a kitchen that orders milk weekly; "10 kitchens" made from the count of its own orders). The grounding check holds figures, products, dates and allergens, not meaning.

Guardrails

Checked in this order for every candidate; the first that fails decides, and a held-back product is written to the ledger with its rule (core/offers.ts guardOffer, tests in core/offers.test.ts):

RuleWhat it stops
offers_offthe kitchen turned offers off: nothing at all
forbidden_by_rulewhat the kitchen's signed mandate, a rule its people stated, or its memory forbids (the core's own hard-rule check, core/clarify.ts violation, including the allergen-evidence rule), or what its memory says to avoid
mutedthe product, its family, or the kind of reason the kitchen muted
declined_recentlya product the kitchen declined in the last 30 days
already_offeredthe same product again while an offer of it is open
weekly_capa third offer within seven days, whatever campaigns staff start
not_availableno live price or delivery now
unsupported_claima reason (or a campaign's wanted claim) the data does not back as stated, recomputed from the sources; or nothing worth saying at all

The recall radar's held products are never considered. The kitchen's own agent does not rely on any of this: before it says an offer, it verifies the signature and checks the offer again against the kitchen (buyerCheck).

The offer: format

A W3C Verifiable Credential (Data Model 2.0) secured with JOSE, like the product claims (core/claims.ts): a compact JWS, typ: vc+jwt, cty: vc, Ed25519 (RFC 8037), signed with the wholesaler key in the published JWK Set (/.well-known/jwks.json, also /api/v1/keys).

FieldMeaning
header alg, kid, typ: vc+jwtwhich published key to check it with
iss / issuerurn:dock:wholesaler, who offers
audthe buyer organisation it is addressed to
subthe product id
jtithe offer id
nbf, exp / validFrom, validUntilhow long the price and the offer hold: at most 24 hours (as long as a price claim), never past the price's stated validity
type["VerifiableCredential", "DockProductOffer"]
credentialSubject.offer.isOrderalways false: an offer buys nothing
.sellerthe wholesaler and its selling agent
.productid, name, family, unit, pack size, claims, allergens (and whether declared), labels
.reasonswhy, each with its figures (above) and, for a season, its source
.linethe reason in one line
.priceper pack, per litre or kilo, EUR, and the id of the signed price claim it rests on
.suggestedPacksthe kitchen's usual amount for a like-for-like, else one pack to try
.terms"This is an offer, not an order…": accepting starts an ordinary order
.evidencethe signed price and origin claims it rests on, by id and SHA-256 of their tokens

To check one with nothing but jose:

curl -s $DOCK/api/v1/offers -H "Authorization: Bearer $DOCK_MANDATE" -H "Dock-Agent-Id: $DOCK_AGENT_ID" > offers.json
curl -s $DOCK/.well-known/jwks.json > jwks.json
pnpm verify:offer offers.json --jwks jwks.json --org <your org id>

It fails when one byte is changed, for another key, after it expires, for another buyer, or for a token that is not an offer.

Answering an offer: MCP and REST

MCP toolRESTDoes
list_offersGET /api/v1/offers[?all=true]the offers to this kitchen, each with its token and verified, and what the kitchen muted
accept_offerPOST /api/v1/offers/{offerId}/acceptstarts an ordinary order for the product through the intake (mistake check, options, a fresh signed quote, the mandate gate, its sign-off). Buys nothing itself
decline_offerPOST /api/v1/offers/{offerId}/declinenot wanted; not offered again for 30 days
mute_offersPOST /api/v1/offers/muteproduct, family, reason or all (offers off); undo lifts it

The offer tools are published by the MCP door beside the ordering tools but are not part of the ordering table (TOOLS): the crash-test harness digest and the kitchen's own agent's tool table are unchanged, and the buyer runner offers its model only the rated table.

The kitchen's own agent

The kitchen's side is the conversation: at most one offer, said by the agent in one or two plain sentences with its reason and figures, then a question answered in words (yes, no, "don't suggest this kind again"). The offer's proof and the selection reasoning are in the closed steps view. The thread calls src/server/offers/thread.ts:

CallReturns / does
offerForThread(rt, session, kitchenName, lang)undefined, or { offerId, say, ask, steps, proof, sheet, reasons, validUntil, facts }: say and ask in the kitchen's language (en, fi, sv); steps for the steps view; proof the signed token and keys URL; sheet what a yes puts on the order sheet (product, packs, the next day the kitchen usually orders that family); facts for grounding the agent's own line
offerAnswerOf(words)"yes", "no", "mute" or "off" from the person's words (en, fi, sv; yes and no by channels/speech.ts yesOrNo), or undefined
answerOffer(rt, session, offerId, answer, { by, via, words, name, lang })no, mute, off: done and recorded, returns reply; yes: checked again, returns sheet
offerOnSheet(rt, session, offerId, { by, via, name, lang }, requestId)after the thread opened the order sheet for a yes: records the acceptance against the sheet's request, returns the reply line

Operations

/ops/offers: what the selling agent offered to whom, why (the reasons and the line), and what happened (open, accepted into which request, declined, muted, expired), what it held back and by which rule, each offer's signature checked in the browser, what kitchens muted. Staff run the agent now, or start a campaign for a product (listed as new, and with the claims they want made): the agent decides kitchen by kitchen whether it is relevant and says why not.

The ledger

Every offer, every product held back, every campaign and every answer is an entry of type offer (data.step: offered, withheld, campaign, accepted, declined, muted, unmuted), in the same hash chain as the orders. Both sides read them in full (core/audit.ts, kind offered): they hold nothing of the kitchen's memory.