BookBaltic Scheduling Extensions for USP / UCP Local Services
Status: Draft. Versions per extension: 2026-11-03 for staff_choice, party and intake (breaking changes, see Namespace and versioning), 2026-11-01 for the others. Proposed as input to the UCP Local Services charter (#922), not yet accepted by USP or UCP.
Published: 2026-10-29
Author: IT Hospitality SIA (BookBaltic), Riga, Latvia
Targets: Universal Scheduling Protocol, USP draft 2026-08-20; UCP Local Services charter (#922)
License: Apache 2.0, same as USP
Schemas: staff_choice.json, party.json, handoff.json, discovery_hints.json, recommended_starts.json, intake.json, directory_search.json
Summary
Seven optional vendor extensions in the com.bookbaltic.services.* namespace that sit on top of USP draft 2026-08-20 without changing it:
| # | Capability | Extends | What it adds |
|---|---|---|---|
| 1 | com.bookbaltic.services.staff_choice | availability, bookings | "Any staff" as a first-class choice, pinned vs preferred staff, what happens when the staff member drops out |
| 2 | com.bookbaltic.services.party | catalog, availability, bookings | Same-start multi-guest bookings (couples), one resource of a type per guest, per-guest level and price, party-only services, guest names from UCP Lodging |
| 3 | com.bookbaltic.services.handoff | catalog, availability | A vendor-format browser link per start that opens the merchant's own booking page with service, start, party size and level preselected, no hold; target form is UCP permalink (#523) with booking/* field paths |
| 4 | com.bookbaltic.services.discovery_hints | catalog | Optional merchandising hints: signature services with season state, "most chosen", dated news |
| 5 | com.bookbaltic.services.recommended_starts | availability | Recommended starts instead of the full grid, a point check "is this time free for N guests", advertised abuse limits |
| 6 | com.bookbaltic.services.intake | USP booking_questions (11.4) | What USP 11.4 Booking Questions defers: a form per guest of a party, and an agent draft of non-sensitive answers that only the guest confirms through their own link |
| 7 | com.bookbaltic.services.directory_search | dev.ucp.common.directory (RFC #937 draft) | Search for free time across the businesses a regional directory lists, request shaped after UCP location_search; brand facts, published ranking rule, and a directory that never takes guest funds while each business stays the Merchant of Record |
Every extension degrades to plain USP: an agent that knows none of them can still book (any staff, pair as party_size: 2) or send the guest to service.url.
Motivation
AI agents increasingly answer "book us a spa for two on Saturday" in one conversation, and USP gives them catalog, availability, holds and booking. Small wellness venues hit six gaps in practice: guests ask for a specific therapist, couples want to lie side by side at the same time, read-only integrations need a way to hand the guest to the merchant's page with the choice already made, agents need to know which service is the signature one this season, small merchants do not want to publish their full calendar (it leaks occupancy and invites scraping), and every guest must fill a health questionnaire before the visit. The first live implementation of these extensions is HEADSPA (head-spa.lv), a head spa studio in Riga: eight head spa rituals, three levels (Basic, Premium, Deluxe), solo or pair, one session at a time on two couches in one room. Its signature ritual N° 08 Ogonhi is pair-only and seasonal (September-November, March-April). HEADSPA declares four of the seven extensions live in its profile (party, handoff, discovery_hints, recommended_starts, each at 2026-11-01); staff_choice and intake are specified but not declared, and directory_search is declared by BookBaltic's own directory profile. For party only the catalog part is live; slot guest_assignments and booking guests[] are specified, not served. This draft turns that into reusable extensions that BookBaltic operates for Baltic merchants.
Relationship to USP and #922
What already exists and is referenced, not redefined:
| Need | Already in USP 2026-08-20 |
|---|---|
| List staff for a service | Service.resources[] with type: "staff", selectable: true, options[] (Section 3.10) |
| Availability per staff | resource_id filter on POST /availability/query (Section 4.3.1) |
| Book a chosen staff member | One slot per resource combination; picking the slot picks the staff (Sections 4.1, 5.3.1) |
| Party size | party_size on query, hold conversion and booking; spots on holds (Sections 4.2.1, 5.3.1) |
| Price per guest | pricing.model: "per_person" (Section 3.8) |
| Minimum / maximum party | capacity {min, max} on the service (Section 3.3) |
| Holds with TTL, hold limits | Section 4.2, 10.1.2 (hold_limit_exceeded = "release an existing hold", TTL SHOULD be 5-10 min) |
| Rate limiting | Section 10.2.2 (429, Retry-After, RateLimit-* headers) |
| Seasonal suspension | service.status: "suspended", service.suspended webhook (Section 5.4.2) |
| Coarse availability for discovery | availability_hint with summary and slot bitmaps (Section 3.6) |
| Post-booking continue URL | booking.actions[].continue_url, booking_url (Section 5.2) |
| Waitlist | Section 11.1 |
| Booking questions, including pre-visit intake | Section 11.4 Booking Questions (dev.usp-protocol.services.booking_questions, added 2026-10-31): purpose: intake, completion_timing, subject, sensitive, business-surface collection, buyer-input Action |
| A browser link that initialises a choice without ordering | UCP dev.ucp.shopping.permalink (#523): config.endpoint, compact item path, extension-defined field paths, GET navigation, inputs untrusted, never an order authorization, attribution kept |
| Guests of one booking and their roles | UCP Lodging (#780): root guests[], per unit guest_assignments[] {guest_id, role} with role primary or accompanying, occupancy |
| A fresh buyer decision after the order | UCP #944 (decision_deadline, silence is not approval) and #896 (otherwise: cancel or contact_buyer, agreed before the order); goods side only |
| Per-guest add-ons | UCP #960 (applies_to: "guest", selected[].guest_ids) |
Gaps this draft fills: (a) "any staff" without N duplicate slots, how firm the staff choice is, and staff dropout; (b) more than one resource of the same type on one slot (two couches, two therapists), per-guest levels, co-location, pair-only services; (c) a link per start for read-only merchants, served today in a vendor format and meant to become UCP permalink field paths for a service start; (d) merchandising hints (no USP field); (e) a merchant-curated subset of starts plus a point check, and machine-readable abuse limits; (f) booking questions per guest of a party and an agent draft of non-sensitive answers confirmed by the guest, which USP 11.4.13 defers.
Not specified here: day entry without a fixed start and group tickets (saunas, thermal baths, experiences). No UCP issue covers that case today: #479 (Booking Availability) covers timed-entry tickets and restaurants with a full slot grid (available_count, capacity, unavailable_reason), not an entry window without a start time. Our case is a day pass or entry window with no start time and an area capacity; we would bring it to #479 and the USP roadmap item "Wellness & Spa" rather than define a schema of our own.
Against UCP #922: Phase 1 covers appointment and group sessions with full prepayment and holds (availability capability); Phase 2 covers reschedule, no-show, waitlist, pay at venue; Phase 3 lists "vertical extensions in vendor namespaces (... party-size rules)", embedded booking UI delegation and intake forms. Input per extension: 1 - staff_mode to Phase 1 availability, staff_preference and staff_change to Phase 2 post-booking; 2 - Phase 3 party-size rules, with the resource side in the discussion #1022 capacity draft; 3 - the #523 permalink spec for services; 4 - an optional catalog annex; 5 - Phase 1 availability, additive to #317 SearchAvailability and discussion #1022; 6 - Phase 3 intake forms, as a revision of USP 11.4.
Related work checked on 2026-11-03: discussion #1022 (small merchants: "specific professional (or anyone available)", bookable_resources, requires[], capacity_pool), #317 (SearchAvailability, HoldSlot), #479 (timed entry), #896 and #944 (post-order buyer decisions, goods), #960 (per-guest add-ons), #897 (Food checkout requested_fulfillment_time and options[]), #523 (permalink), #780 (Lodging guests), #953 and #954 (lodging guest identity, not forms), #1009 / PR #1010 (business incentives before the cart), USP 11.4. None covers staff dropout, a resource per guest on one slot, recommended starts with a point check, a no-hold rule for a permalink to a service start, or per-guest intake with an agent draft.
Namespace and versioning
- Namespace
com.bookbaltic.services.{name}. Spec pages:https://bookbaltic.com/specs/usp/{name}with_written as-; schemas:https://bookbaltic.com/schemas/usp/{name}.json. Per USP Section 2.5 thespecandschemaURLs MUST be onhttps://bookbaltic.com/...(scheme and host exactly; notwww.). - Versions are dates
YYYY-MM-DD; first version2026-11-01. Breaking changes take a new date. Each extension has its own version; the schema at its$idalways describes the current one. 2026-11-03, breaking, aligned with the UCP proposals: staff_choice -staff_change.respond_byrenameddecision_deadline, required whenoutcomeisawaiting_buyer; party -ServiceExt.per_guest_optionsandGuest.add_on_idsremoved (per-guest add-ons follow #960); intake -agent_draftvaluescontact_onlyandcontact_and_yes_noreplaced bynon_sensitive,GuestQuestionState.expires_atrenameddue_at,fhir_questionnaire_urlremoved,guests[]added to the booking. A profile that declares party, staff_choice or intake at2026-11-01refers to the earlier shape and SHOULD move to2026-11-03. handoff, discovery_hints, recommended_starts and directory_search stay2026-11-01: only descriptions and rules were clarified.- Each schema declares
requires: protocol>= 2026-08-20and its parent capabilities. - Extension data lives in one property per object, keyed by the capability name, so it never collides with future USP fields and a base client ignores it.
- Data is served by the merchant's USP REST service declared in
ucp.services["dev.usp-protocol.services"](USP Section 7.2); the profile declares capabilities and endpoints, not catalog or slot data. Paths below (/services/list,/availability/query,/availability/recommended,/availability/check) are relative to thatendpoint. - The operator is named at the profile root as
"com.bookbaltic.operator": {"name", "legal_entity", "url", "role": "technical_operator", "receives_guest_funds": false}. It identifies who implements USP for the business; it is not the seller.
Profile entry (UCP-native /.well-known/ucp shown; the Standalone /.well-known/usp entry is identical in shape):
"services": {
"dev.usp-protocol.services": [{ "version": "2026-08-20", "transport": "rest",
"endpoint": "https://www.head-spa.lv/usp/v1",
"spec": "https://usp-protocol.dev/specification",
"schema": "https://usp-protocol.dev/schemas/openapi/usp-rest.json" }]
},
"capabilities": {
"dev.usp-protocol.services.availability": [{ "version": "2026-08-20", "holds": true,
"spec": "https://usp-protocol.dev/specification#4-availability",
"schema": "https://usp-protocol.dev/schemas/services/availability.json" }],
"com.bookbaltic.services.party": [{
"version": "2026-11-03",
"extends": ["dev.usp-protocol.services.catalog", "dev.usp-protocol.services.availability", "dev.usp-protocol.services.bookings"],
"spec": "https://bookbaltic.com/specs/usp/party",
"schema": "https://bookbaltic.com/schemas/usp/party.json"
}],
"com.bookbaltic.services.recommended_starts": [{
"version": "2026-11-01",
"extends": "dev.usp-protocol.services.availability",
"spec": "https://bookbaltic.com/specs/usp/recommended-starts",
"schema": "https://bookbaltic.com/schemas/usp/recommended_starts.json",
"config": { "limits": { "...": "see Abuse limits" } }
}]
}
1. Staff selection
Base USP already does: list staff (resources[].options), filter by resource_id, book by slot. Gaps: (i) with N therapists free at 15:00, USP requires N slots; an agent that does not care must deduplicate, and the merchant cannot keep assignment flexible; (ii) no way to say whether the guest insists on that person; (iii) no defined behaviour when the assigned staff member becomes unavailable.
Related: discussion #1022 lists what a small salon sells: a service, a time slot and "often a specific professional (or anyone available)"; staff_mode: "any" is our concrete shape for it and is an extension of USP 4.1 (one slot per resource combination). The goods side already has the same pattern: #896 lets the buyer agree ahead of time what happens on a surprise after the order (otherwise: cancel | contact_buyer), and #944 defines how a business asks for a fresh buyer decision after the order, with a decision_deadline. A general post-order consultation protocol stays with #944; awaiting_buyer is one domain case of it. staff_preference and staff_change are the services counterpart; if #896 and #944 land, staff_preference can be expressed as a services contingency and awaiting_buyer as a #944 consultation with the same deadline. The fields stay on the booking so free bookings without checkout (#922 Phase 2) work too. Against #922: staff_mode is input to Phase 1 availability, staff_preference and staff_change to Phase 2 post-booking.
Not live: HEADSPA does not publish staff, so the extension is specified but not declared in its profile.
Schema
{
"$id": "https://bookbaltic.com/schemas/usp/staff_choice.json",
"requires": { "protocol": { "min": "2026-08-20" },
"capabilities": { "dev.usp-protocol.services.availability": { "min": "2026-08-20" },
"dev.usp-protocol.services.bookings": { "min": "2026-08-20" } } },
"$defs": {
"AvailabilityQueryExt": { "type": "object", "properties": {
"staff_mode": { "enum": ["any", "per_staff"], "default": "per_staff",
"description": "any: one slot per start, staff assigned by the merchant. per_staff: base USP behaviour." } } },
"SlotExt": { "type": "object", "properties": {
"staff_assignment": { "enum": ["assigned", "any"] },
"eligible_staff_count": { "type": "integer", "minimum": 1,
"description": "Optional. How many staff could take this start; never their identities." } } },
"BookingRequestExt": { "type": "object", "properties": {
"staff_preference": { "enum": ["required", "preferred", "none"], "default": "preferred" } } },
"BookingExt": { "type": "object", "properties": {
"staff_preference": { "enum": ["required", "preferred", "none"] },
"staff_change": { "type": "object", "required": ["changed_at", "outcome"], "properties": {
"changed_at": { "type": "string", "format": "date-time" },
"outcome": { "enum": ["reassigned", "awaiting_buyer", "canceled_by_business"] },
"previous": { "$ref": "#/$defs/StaffRef" },
"current": { "$ref": "#/$defs/StaffRef" },
"decision_deadline": { "type": "string", "format": "date-time",
"description": "As UCP #944: the last instant a buyer decision is admitted. Required when outcome is awaiting_buyer." } },
"if": { "properties": { "outcome": { "const": "awaiting_buyer" } } },
"then": { "required": ["decision_deadline"] } } } },
"StaffRef": { "type": "object", "required": ["id", "name"], "additionalProperties": false,
"properties": { "id": { "type": "string" }, "name": { "type": "string", "description": "Display name only" } } }
}
}
Rules
staff_mode: "any"returns at most one slot per start;resourceson that slot omits staff; the booking gets staff on confirmation or at service time.eligible_staff_countis optional and a count, never a list; a business that does not want to expose it omits it.- On staff dropout the merchant MUST apply the booking's
staff_preference: noneorpreferred: reassign and sendbooking.rescheduledwithstaff_change.outcome: "reassigned".required: setoutcome: "awaiting_buyer"withdecision_deadlineand offer the other person, a reschedule, or free cancellation. As in #944, silence MUST NOT be read as approval: no answer bydecision_deadlinemeans free cancellation with a full refund, never reassignment.- No one can take the visit:
canceled_by_business, full refund. - A dropout MUST NOT move the start time; a time change is a normal reschedule under the booking's policy (#922 Phase 2).
- As in #896, the platform MUST show
staff_preferenceto the guest before booking and MUST NOT send a value the guest has not seen. The defaultpreferredis what most guests mean when they name a person without saying more. - The agent MUST tell the guest the outcome: who will serve them, or what they must decide and by when.
canceledfollows the UCP spelling (checkout.json,lodging/booking.json).
Example (HEADSPA, illustrative staff; not declared in its profile)
Request:
{ "service_id": "svc-aqua-head-spa", "start_date": "2026-11-07", "end_date": "2026-11-07",
"party_size": 1, "com.bookbaltic.services.staff_choice": { "staff_mode": "any" } }
Response slot:
{ "id": "slot_20261107_1400_any", "service_id": "svc-aqua-head-spa",
"start": "2026-11-07T14:00:00+02:00", "end": "2026-11-07T15:30:00+02:00", "duration": "PT90M",
"state": "available", "resources": [{ "id": "room-1", "type": "room", "name": "Ritual room" }],
"com.bookbaltic.services.staff_choice": { "staff_assignment": "any", "eligible_staff_count": 2 } }
Booking after the pinned therapist dropped out:
{ "com.bookbaltic.services.staff_choice": {
"staff_preference": "required",
"staff_change": { "changed_at": "2026-11-06T09:12:00+02:00", "outcome": "awaiting_buyer",
"previous": { "id": "staff-anna", "name": "Anna" },
"current": { "id": "staff-liga", "name": "Liga" },
"decision_deadline": "2026-11-06T20:00:00+02:00" } } }
Without the extension: the agent gets base per-staff slots and books one; it cannot express "any" or "required", and a dropout arrives as an unexplained booking.rescheduled.
Open questions: should staff_change become a new USP webhook booking.resource_changed rather than ride on booking.rescheduled? Should per-staff price differences use slot.pricing only (we assume yes)?
2. Pair / couples booking
Base USP already does: party_size, per_person pricing, capacity {min,max}, availability filtered by party_size. Gaps: USP says a slot "carries at most one resource of each type", so two couches or two therapists for one start cannot be expressed; party_size on appointment is "typically 1" with no per-guest detail; no per-guest level; no co-location ("side by side in one room").
Schema
{
"$id": "https://bookbaltic.com/schemas/usp/party.json",
"$defs": {
"ServiceExt": { "type": "object", "properties": {
"allowed_party_sizes": { "type": "array", "items": { "type": "integer", "minimum": 1 },
"description": "Exact sizes accepted, e.g. [1,2] or [2]. Narrower than capacity {min,max}." },
"party_pricing": { "enum": ["per_guest", "per_party"],
"description": "per_guest: sum of each guest's level price. per_party: base amount for the party." },
"levels": { "type": "array", "items": { "$ref": "#/$defs/Level" } },
"co_location": { "enum": ["same_room_side_by_side", "same_room", "none"] } } },
"Level": { "type": "object", "required": ["id", "name", "price"], "properties": {
"id": { "type": "string" }, "name": { "type": "string" },
"price": { "type": "integer", "description": "Per guest, minor units, service currency" },
"duration": { "type": "string", "description": "ISO 8601" } } },
"SlotExt": { "type": "object", "properties": {
"party_size": { "type": "integer" },
"guest_assignments": { "type": "array", "description": "As UCP Lodging guest_assignments, per guest instead of per room: which resources each guest gets on this slot.",
"items": { "type": "object", "required": ["guest_id", "resources"], "properties": {
"guest_id": { "type": "string", "description": "Matches guests[].id in the booking request" },
"role": { "type": "string", "description": "primary or accompanying, as UCP Lodging; unknown values read as accompanying" },
"resources": { "type": "array", "items": { "type": "object",
"required": ["id", "type"], "properties": { "id": {"type":"string"}, "type": {"type":"string"}, "name": {"type":"string"} } } } } } } } },
"BookingRequestExt": { "type": "object", "properties": {
"guests": { "type": "array", "description": "Root guest pool, as UCP Lodging guests[]; plus a level per guest.", "items": { "$ref": "#/$defs/Guest" } } } },
"Guest": { "type": "object", "required": ["id"], "properties": {
"id": { "type": "string", "description": "Platform-generated, session-scoped, as UCP Lodging guests[].id" },
"role": { "type": "string", "description": "primary or accompanying. Added for services: UCP Lodging has role on guest_assignments only. Unknown values read as accompanying" },
"first_name": { "type": "string" },
"level_id": { "type": "string", "description": "levels[].id of the service" } } }
}
}
Rules
- A party booking is one booking, one slot, one hold (
spots=party_size), one payment. All guests start together; cancelling cancels the party. allowed_party_sizes: [2]marks a pair-only service; the merchant MUST also set basecapacity {min: 2, max: 2}so a base client reads the same rule.allowed_party_sizesstays withincapacity {min, max}.- With
per_guest, the total MUST equal the sum of the guests' level prices; withper_partyit is the base amount. Basepricing.model: "per_person"with the lowest level price stays correct for a party without levels. - Availability for each party size is its own read: a start absent for 2 may exist for 1 (different booking windows, two couches needed). Agents MUST NOT derive party availability from solo availability.
- Base
slot.resourceskeeps at most one resource per type (the shared room); per-guest couches and staff go inguest_assignments. Everyguest_assignments[].guest_idmatches aguests[].idof the booking request. - With
co_locationother thannone, the merchant MUST return a slot only if all guests fit it; slot length is the longest guest's level duration. An agent promises "side by side" only when the merchant declaresco_location. rolefollows UCP Lodging: a merchant MUST treat an unknown value asaccompanying. Guest 2 needs at most a first name; email and phone stay onbuyer.- Seasonal services: outside the season the merchant returns no slots and MAY set
status: "suspended"; the season itself is published via extension 4.
Not in this extension:
- Per-guest add-ons. If Local Services needs them, we follow the #960 shape (
applies_to: "guest",selected[].guest_ids) rather than define our own;add_on_idsandper_guest_optionsof the first draft are removed. - The service-level resource needs. That is the discussion #1022 draft (
bookable_resources,requires[] {resource_type, quantity, customer_selectable},capacity_pool). For a party we propose one addition to it, to settle with its author: a per-requirement scope, so{ "resource_type": "couch", "quantity": 1, "scope": "guest" }means one couch per guest and{ "resource_type": "room", "quantity": 1, "scope": "booking" }one shared room. - Group classes and capacity pools (USP
groupslots, discussion #1022capacity_pool), one guest with two services at the same time (discussion #1022mode: concurrent), guest identity beyond a first name (lodging #953, #1033). Day entry without a fixed start: we would bring that to #479 rather than define our own.
Example (HEADSPA)
Catalog fragment, live at GET https://www.head-spa.lv/usp/v1/services/svc-ogonhi:
{ "id": "svc-ogonhi", "name": "Ogonhi", "type": "appointment",
"duration": { "fixed": "PT180M" },
"pricing": { "model": "per_person", "amount": 9000, "currency": "EUR" },
"capacity": { "min": 2, "max": 2 },
"com.bookbaltic.services.party": { "allowed_party_sizes": [2], "party_pricing": "per_guest",
"co_location": "same_room_side_by_side",
"levels": [{ "id": "basic", "name": "Basic", "price": 9000, "duration": "PT180M" }] } }
svc-aqua-head-spa is live with allowed_party_sizes: [1, 2] and three levels per guest: 6500, 8000, 9500 with PT90M, PT105M, PT120M. Pair starts in the point check carry slot ids ending in :pair.
Slot (specified, not served; illustrative ids):
{ "id": "svc-aqua-head-spa:2026-11-07T14:00:pair",
"resources": [ { "id": "room-1", "type": "room" } ],
"com.bookbaltic.services.party": { "party_size": 2, "guest_assignments": [
{ "guest_id": "g1", "role": "primary", "resources": [ { "id": "couch-a", "type": "couch" }, { "id": "staff-anna", "type": "staff", "name": "Anna" } ] },
{ "guest_id": "g2", "role": "accompanying", "resources": [ { "id": "couch-b", "type": "couch" }, { "id": "staff-elina", "type": "staff", "name": "Elina" } ] } ] } }
Booking request (specified, not served; Aqua Head Spa, two guests, different levels):
{ "service_id": "svc-aqua-head-spa", "slot_id": "svc-aqua-head-spa:2026-11-07T14:00:pair", "hold_id": "hold_7f2",
"party_size": 2, "buyer": { "first_name": "Laura", "email": "laura@example.com" },
"com.bookbaltic.services.party": { "guests": [
{ "id": "g1", "role": "primary", "level_id": "basic" },
{ "id": "g2", "role": "accompanying", "first_name": "Marta", "level_id": "premium" } ] } }
Total 6500 + 8000 = 14500 minor units (145 EUR). HEADSPA runs read-only (no bookings capability) and hands the guest to its own page (section 3).
Without the extension: the agent books party_size: 2 at the per-person Basic price (130 EUR for two Basics); it cannot mix levels, and pair-only is still enforced via capacity.min.
Alignment. Guest pool and roles follow UCP Lodging (#780): root guests[] (id, first_name), guest_assignments[].guest_id, role with primary and accompanying, so one agent model covers a hotel stay and a spa visit; earlier draft names participants, guest_index and allocations are replaced. Added for services: guest_assignments[].resources, guests[].role (Lodging has role on guest_assignments only) and guests[].level_id. The slot carries USP id (USP 4.1); the booking request keeps slot_id. party_size keeps the name used by USP and discussion #1022 and stays compatible with #317 max_party_size. What is new here: one resource of the same type per guest on one slot (USP 4.1 allows one per type), co_location, a level and price per guest, and party-only services (allowed_party_sizes). The resource side belongs to the discussion #1022 capacity draft, which we propose to write together with its author rather than in parallel; the namespace moves to whatever #922 settles on.
Open questions: should multi-resource slots be a change to core USP Section 4.1 instead of an extension (revisit only if a second implementer needs per-guest resources in the base slot)?
Evidence: who else needs multi-resource slots
Same-start bookings that need two resources of one type are common in spa and salon software, rare elsewhere as a single booking:
- Spa couples. Zenoti: a couples service is for two guests "taken simultaneously in the same room", usually two staff, and is stored as two appointments (help). Book4Time "Enhanced Couples Booking" places both guests in a designated couples room, fixed pair price or per-guest choice (release). Booker advertises couples sessions (page). A Square seller reports about 60% of its services are couples massages and books them as two services by hand (thread).
- Salon groups. Phorest online group booking assigns a service per guest and needs several staff (help); Fresha group appointments (help); Booksy groups 2-10, staff-side only (help).
- Restaurants. Table combinations are a merchant-side setting; the guest sees one party size (TableCheck).
- Group classes, courts and escape rooms book one resource with capacity; medical teams combine different types. No platform evidence found for these as same-type pairs.
What this shows: the need is real for wellness, but every platform implements it as a party on one shared resource (couples room) plus linked per-guest appointments inside the merchant. Google Actions Center has the same shape as USP: one staff_id, one room_id, party_size per slot (spec). No USP issue or PR and no #922 comment discusses multi-resource or couples slots; #922 Phase 3 lists "party-size rules" as vendor extensions. Weak points: no adoption numbers, Mindbody and Vagaro not confirmed from their docs.
3. Booking handoff for the read-only phase
Base UCP and USP already do: UCP dev.ucp.shopping.permalink (#523) is a browser-addressable intent: a GET navigation that initialises shopping state from URL inputs, never authorises an order, treats inputs as untrusted and keeps attribution (utm_*, attribution/*). It is defined for shopping line items (/buy/{item}:{quantity}). USP has service.url (service page) and continue_url only once a booking or checkout exists (5.2). Gap: no permalink fields for a service start: an agent cannot open the merchant's page with service, start, party size and level preselected.
Status: vendor format. The fields below (permalink_template on the service, permalink_url on a start) are what HEADSPA serves today and stay in this extension because they are live. They are not a conforming UCP permalink. In UCP permalink_url names the link to an existing order or booking (order.json, lodging booking_confirmation.json), which a pre-booking start link is not. The target form is UCP dev.ucp.shopping.permalink (#523) applied to services, proposed upstream: the business declares the permalink capability with config.endpoint, and the agent builds the link from the compact item path and booking/* field paths addressing the USP BookingContext (schemas/paid_bookings.json):
| Choice | Field path | Notes |
|---|---|---|
| Service | compact item {service_id}:1 | booking/service_id MAY be omitted; the business takes it from the single compact item. |
| Start from availability | booking/slot/id | Preferred when the agent has a slot. |
| Start without a slot id | booking/slot/start | RFC 3339 with offset, percent-encoded (+ as %2B); the business resolves it to a slot (slot.id is required in SlotReference). |
| Party size | booking/party_size | The compact quantity stays 1 per booking; party size is never read from it. |
| Attribution | attribution/* or non-UCP utm_* | Unchanged from permalink. |
Target form (HEADSPA target, not served yet). Profile entry:
"dev.ucp.shopping.permalink": [{ "version": "2026-08-25",
"spec": "https://ucp.dev/2026-08-25/specification/permalink",
"schema": "https://ucp.dev/2026-08-25/schemas/shopping/permalink.json",
"config": { "endpoint": "https://www.head-spa.lv/book" } }]
A start with the slot id returned by availability (preferred):
https://www.head-spa.lv/book/svc-aqua-head-spa:1?booking/slot/id=svc-aqua-head-spa%3A2026-12-05T17%3A00%3Apair&booking/party_size=2&attribution/utm_source=agent
The same start known only by its time:
https://www.head-spa.lv/book/svc-aqua-head-spa:1?booking/slot/start=2026-12-05T17%3A00%3A00%2B02%3A00&booking/party_size=2&attribution/utm_source=agent
Possible resolution:
HTTP/1.1 303 See Other
Location: https://www.head-spa.lv/?ritual=svc-aqua-head-spa&guests=2&date=2026-12-05&time=17%3A00&utm_source=agent
Cache-Control: no-store
Referrer-Policy: no-referrer
In the target form a pre-booking start link gets no permalink_url field. A business that supports USP bookings without dev.ucp.shopping.checkout MAY declare the permalink capability; for it the booking root keeps the USP BookingContext shape (paid_bookings.json#/$defs/BookingContext), the business maps booking/slot/id and booking/party_size to the Create Booking fields slot_id and party_size (USP 5.3.1), and resolves booking/slot/start to a slot itself. A stale start keeps the 303 and lands the guest on the nearest valid step. On alignment the vendor fields below, including permalink_url on a start, are dropped; until then agents use them.
Schema
{
"$id": "https://bookbaltic.com/schemas/usp/handoff.json",
"$defs": {
"ServiceExt": { "type": "object", "properties": {
"permalink_template": { "type": "string",
"description": "Vendor format. RFC 6570 level 1 template; variables: locale, service_id, level, party_size, date, time. Resolved under the UCP permalink input rules (GET, no order, no hold, inputs untrusted); target form is a UCP permalink with booking/* field paths." },
"continue_url_template": { "type": "string", "deprecated": true, "description": "Earlier name of permalink_template; same value." },
"locales": { "type": "array", "items": { "type": "string" } } } },
"SlotExt": { "type": "object", "properties": {
"permalink_url": { "type": "string", "format": "uri", "pattern": "^https://", "description": "Vendor field: a pre-booking link to this start. Not the UCP permalink_url, which names an existing order or booking." },
"continue_url": { "type": "string", "format": "uri", "deprecated": true, "description": "Earlier name of permalink_url. In UCP continue_url resumes an existing session, which this link is not." },
"holds_slot": { "const": false } } }
}
}
Rules
permalink_urlfollows the UCP permalink input rules: HTTPS on the merchant's own origin (same origin asservice.url), a GET navigation, inputs untrusted, never treated as authorisation to book or charge. It MUST NOT carry buyer personal data.- No hold on load. Resolving the link MUST NOT create a hold and MUST NOT apply a hold id from the URL (in the target form
booking/hold_id); it consumes and drops it. This is the only new requirement on top of permalink:booking/booking_idandbooking/booking_statusare response-only and already never set (permalink Query Processing). Browsers, prefetchers, link-preview bots and scanners load links without buyer intent. The slot is held or booked only after an explicit guest action on the destination page. - Unlike the cart-merge MUST the Shopping TC reverted (#968, reverting #789), whose worst case stays inside the loader's own session, a hold removes a shared slot from every other buyer, so a link preview or scanner would cause harm outside the loader's session.
- The page MUST tolerate a stale or invalid choice (taken time, past time, level the service lacks, solo on pair-only): it opens at the nearest valid step for that service with the remaining choices kept, never an error page.
- Agents SHOULD tell the guest the time is not reserved until they confirm. Agents MAY append their own attribution parameters (
utm_*orattribution/*, as in permalink); merchants keep them as permalink allows for attribution and never forward sensitive values. continue_url_templateandcontinue_urlare deprecated synonyms with the same values, kept so agents that read the first draft keep working. In UCPcontinue_urlmeans resuming an existing session; this link starts nothing.
Example (HEADSPA live format)
"com.bookbaltic.services.handoff": {
"permalink_template": "https://www.head-spa.lv/{locale}?ritual={service_id}&level=basic&guests={party_size}&date={date}&time={time}&hs_via=ai_profile",
"locales": ["lv", "en", "ru"] }
The template is live on each service of POST https://www.head-spa.lv/usp/v1/services/list; continue_url_template is sent alongside with the same value. A start in a point check answer (5.2) carries "permalink_url": "https://www.head-spa.lv/en?ritual=svc-aqua-head-spa&level=basic&guests=2&date=2026-11-05&time=11%3A30&hs_via=ai_check" and continue_url with the same value; hs_via is the merchant's own source tag. The /en prefix is redirected: the site answers 307 to / with the query kept (Cache-Control: public, max-age=0, must-revalidate, Referrer-Policy: strict-origin-when-cross-origin), then 200; /lv and /ru answer 200 directly. Agents follow the redirect as an ordinary navigation.
Without the extension: the agent links service.url, and the guest picks everything again.
Open questions: does the services permalink overlap with ESP (USP Section 9.5) as a "link" delegation mode?
4. Discovery hints (optional)
Base USP already does: tags, categories, rating, status: suspended, availability_hint.summary. Gap: no structured way to say "this is our signature service, in season now / back in March", "guests choose this most", or "special this week until a date". Agents currently guess from free text or ranking signals, and often recommend an out-of-season or minor service first.
Schema
{
"$id": "https://bookbaltic.com/schemas/usp/discovery_hints.json",
"$defs": {
"ServiceExt": { "type": "object", "properties": {
"featured": { "type": "object", "required": ["state"], "properties": {
"state": { "enum": ["in_season", "out_of_season", "coming_soon", "always"] },
"season_months": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 12 } },
"available_from": { "type": "string", "format": "date" } } },
"most_chosen": { "type": "boolean", "description": "Merchant claim; no counts or ranks." },
"most_chosen_for_two": { "type": "boolean", "description": "Guests book this most often as a pair; merchant claim, no counts." } } },
"BusinessNews": { "type": "array", "maxItems": 5, "items": { "type": "object",
"required": ["id", "text", "valid_until"], "properties": {
"id": { "type": "string" }, "text": { "type": "string", "maxLength": 280 },
"service_ids": { "type": "array", "items": { "type": "string" } },
"valid_from": { "type": "string", "format": "date-time" },
"valid_until": { "type": "string", "format": "date-time" },
"url": { "type": "string", "format": "uri" },
"options": { "type": "array", "items": { "type": "object", "required": ["label"], "properties": {
"label": { "type": "string", "maxLength": 80 }, "url": { "type": "string", "format": "uri" } } } } } } },
"BusinessUpcoming": { "type": "array", "maxItems": 5, "items": { "type": "object",
"required": ["id", "name"], "properties": {
"id": { "type": "string" }, "name": { "type": "string" },
"tagline": { "type": "string", "maxLength": 140 },
"state": { "const": "coming_soon" },
"url": { "type": "string", "format": "uri", "pattern": "^https://" } } } },
"AlternativeOffer": { "type": "object", "required": ["type", "url", "text"], "properties": {
"type": { "enum": ["gift_card"], "description": "Open vocabulary; gift_card is the only value defined now." },
"url": { "type": "string", "format": "uri", "pattern": "^https://", "description": "Merchant origin, no personal data." },
"text": { "type": "string", "maxLength": 140 } } },
"BusinessOffers": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/AlternativeOffer" } }
}
}
Example (HEADSPA)
{ "id": "svc-ogonhi", "com.bookbaltic.services.discovery_hints":
{ "featured": { "state": "out_of_season", "season_months": [9, 10, 11, 3, 4], "available_from": "2027-03-01" } } }
News: { "id": "n-staro-riga", "text": "Staro Riga light festival, 15-18 Nov, evenings 17:00-23:00: the dry ritual Head Spa Dry before the lights, or warm tea and relaxation after", "valid_until": "2026-11-18T23:59:00+02:00", "service_ids": ["svc-head-spa-dry"], "options": [{ "label": "Head Spa Dry before the lights" }, { "label": "Warm tea and rest after the lights" }] } (no fixed time is sold; the agent offers recommended starts as usual). Upcoming: { "id": "amber-hidamari", "name": "Amber Hidamari", "state": "coming_soon" }; a ritual guests most often book as a pair carries "most_chosen_for_two": true.
Related: news overlaps with UCP #1009 / PR #1010 (business incentives shown before the cart, with validity dates, shopping only); we would rather use that shape with a services variant than keep news. featured with season state and most_chosen / most_chosen_for_two are not covered anywhere.
Why agents need it: the guest question is often "what should we do there", not "book X"; without hints the agent ranks by name match and may push an out-of-season signature service. Honest caveat: this is merchandising, not scheduling. It may fit better in UCP catalog (which already has featured for variants), schema.org, or the merchant's own llms.txt. We propose it as optional and are ready to move it. most_chosen and most_chosen_for_two are self-claims and agents SHOULD treat them as such (HEADSPA sets most_chosen_for_two only for a service with at least 50 % pair bookings and at least 5 bookings in 30 days); expired news (valid_until past) MUST be ignored. Services in upcoming are announced but not yet sold: agents MAY mention them and MUST NOT offer them for booking.
Without the extension: nothing breaks; the agent loses context.
Alternative offers
alternative_offers (business level) lets the merchant point the agent to something else to suggest when booking does not work out (point check negative, hold refused, date beyond the horizon) or when the guest wants a gift. Only type: "gift_card" is defined now.
"alternative_offers": [{ "type": "gift_card", "url": "https://www.head-spa.lv/gift-cards?hs_via=ai_check",
"text": "Give a ritual as a gift, or a gift card for an amount. Valid 6 months from purchase." }]
(HEADSPA terms: valid 6 months from purchase, section 8.4 of the site terms.) Agents MAY offer it once as an alternative, never instead of reporting the booking result honestly.
Honest caveat: a gift card is a regular product, not a scheduled service. Under UCP it can be sold through standard UCP checkout (dev.ucp.shopping.checkout) with no scheduling extension at all. We propose only the hint and the link here; full agent checkout of gift cards is a separate step outside this draft.
5. Recommended starts and point check
Base USP already does: full slot query (POST /availability/query) and availability_hint; USP already says absent hint data is neutral, not "unavailable", and that agents MUST NOT assume availability beyond a published horizon. Gap: a small merchant may not want to expose the full grid (occupancy leaks to competitors, scraping, agents booking awkward gaps). There is no way to say "these are curated suggestions, not everything", and no cheap yes/no question for a specific time. The two needs also have different reach: about 90 % of a small salon's bookings arrive one or two days ahead, while a personal agent plans a trip one to three weeks ahead or more, so suggestions stay short and the point check reaches as far as the merchant takes bookings.
Related: the open availability shapes all return the open slots: SearchAvailability in #317, the availability query sketched in discussion #1022, and the day and slot views in #479 (timed-entry tickets and restaurants with a full grid, with available_count, capacity and unavailable_reason). None has a mode where the merchant keeps the grid and answers one wish at a time. This extension is the alternative a merchant may advertise instead of them, not a replacement, and is additive to the #317 availability step and the small-merchant requirements in discussion #1022 (party_size has the same meaning and stays compatible with #317 max_party_size). The closest pattern is Food checkout (#897): the platform sends one requested_fulfillment_time and the business returns the options[] it chooses. alternatives is not named options[] on purpose: Food options[] are nearest bindable windows, point-check alternatives within horizon_days are recommended starts, not chosen by nearness to the asked time. Holds stay with HoldSlot (#317) and the #922 Phase 1 holds. Live today at HEADSPA.
5.1 Recommended starts
The merchant publishes up to 3 recommended starts per party type (solo, pair), chosen by its own rule. HEADSPA's rule: at least 8 working hours lead time, at most one start per day, prefer starts that close a gap next to an existing booking. The rule text is advisory; the shape is normative.
{
"$id": "https://bookbaltic.com/schemas/usp/recommended_starts.json",
"$defs": {
"RecommendedStarts": { "type": "object", "required": ["complete", "generated_at", "groups"], "properties": {
"complete": { "const": false, "description": "This is never the full grid." },
"generated_at": { "type": "string", "format": "date-time" },
"valid_until": { "type": "string", "format": "date-time" },
"policy": { "type": "object", "properties": {
"max_per_party_type": { "type": "integer", "maximum": 3 },
"min_lead_working_hours": { "type": "integer" },
"max_per_day": { "type": "integer" },
"horizon_days": { "type": "integer", "minimum": 1, "description": "How far ahead recommended starts go; same value as profile config.horizon_days" },
"check_horizon_days": { "type": "integer", "minimum": 1, "description": "How far ahead the point check answers; at most the merchant's booking window. Same value as profile config.check_horizon_days" } } },
"groups": { "type": "array", "items": { "type": "object", "required": ["party_size", "starts"], "properties": {
"party_size": { "type": "integer" },
"starts": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/RecommendedStart" } } } } } } },
"RecommendedStart": { "type": "object", "required": ["start", "service_ids"], "properties": {
"start": { "type": "string", "format": "date-time" },
"service_ids": { "type": "array", "items": { "type": "string" },
"description": "Services that fit this start for this party size." },
"slot_ids": { "type": "object", "additionalProperties": { "type": "string" },
"description": "service_id to USP slot id, when the merchant accepts holds or bookings." },
"permalink_url": { "type": "string", "format": "uri",
"description": "Optional. Vendor pre-booking link to this start (section 3), not the UCP permalink_url of an existing order. HEADSPA sends it on point-check alternatives, not on GET /availability/recommended." },
"continue_url": { "type": "string", "format": "uri", "deprecated": true, "description": "Earlier name of permalink_url" } } }
}
}
Delivered (a) on GET {endpoint}/availability/recommended, where {endpoint} is the USP REST endpoint from ucp.services (MCP: usp_availability_recommended, after the USP usp_* tool names), and (b) optionally (not served by HEADSPA yet) inside availability_hint of each service, so cached catalogs carry it.
Live fragment, GET https://www.head-spa.lv/usp/v1/availability/recommended (one start shown, service_ids and slot_ids shortened):
{ "complete": false, "generated_at": "2026-11-03T16:47:21.337Z",
"policy": { "max_per_party_type": 3, "min_lead_working_hours": 8, "max_per_day": 1, "horizon_days": 7, "check_horizon_days": 90 },
"groups": [ { "party_size": 2, "starts": [
{ "start": "2026-11-05T09:30:00Z", "service_ids": ["svc-aqua-head-spa", "svc-ogonhi"],
"slot_ids": { "svc-aqua-head-spa": "svc-aqua-head-spa:2026-11-05T11:30:pair", "svc-ogonhi": "svc-ogonhi:2026-11-05T11:30:pair" } } ] } ] }
5.2 Point check
POST {endpoint}/availability/check (MCP: usp_availability_check) answers one question: is this date and time free for N guests.
Request:
{ "date": "2026-11-05", "time": "11:30", "party_size": 2, "service_id": "svc-aqua-head-spa", "locale": "en" }
service_id is optional; without it the answer lists every service that fits.
Response (HEADSPA live shape; continue_url with the same value as permalink_url and the alternatives are left out):
{ "available": true, "business_timezone": "Europe/Riga",
"start": "2026-11-05T11:30:00+02:00", "party_size": 2,
"services_fit": [
{ "service_id": "svc-aqua-head-spa", "slot_id": "svc-aqua-head-spa:2026-11-05T11:30:pair",
"permalink_url": "https://www.head-spa.lv/en?ritual=svc-aqua-head-spa&level=basic&guests=2&date=2026-11-05&time=11%3A30&hs_via=ai_check" } ],
"alternatives_kind": "recommended", "alternatives": [ "..." ] }
When not available: "available": false, "services_fit": [], "alternatives": [ up to 2 RecommendedStart ] with "alternatives_kind" saying where they come from, plus optional alternative_offers (section 4):
- the asked date is within
horizon_days: up to 2 recommended starts (5.1),"alternatives_kind": "recommended"; - the asked date is past
horizon_daysbut withincheck_horizon_days: up to 2 free starts on the SAME day,"alternatives_kind": "same_day"; none when the day has no free start.
When available, alternatives (up to 2) and alternatives_kind follow the same rule.
{ "available": false, "services_fit": [], "alternatives": [ "..." ], "alternatives_kind": "same_day",
"alternative_offers": [{ "type": "gift_card", "url": "https://www.head-spa.lv/gift-cards?hs_via=ai_check",
"text": "Gift card for any ritual, solo or pair" }] }
Beyond the check horizon (asked date later than today + check_horizon_days in business_timezone):
{ "available": null, "status": "beyond_horizon", "check_horizon_days": 90,
"permalink_url": "https://www.head-spa.lv/en?ritual=svc-aqua-head-spa&level=basic&guests=2&hs_via=ai_check",
"alternative_offers": [{ "type": "gift_card", "url": "https://www.head-spa.lv/gift-cards?hs_via=ai_check",
"text": "Gift card for any ritual, solo or pair" }] }
The same alternative_offers MAY accompany a refused hold (slot_unavailable, hold_limit_exceeded) in the error messages details.
Field names: permalink_url replaces the first draft's continue_url (on services_fit and alternatives) and booking_url (beyond the horizon); the old names are still sent with the same value and are deprecated. In USP Booking.booking_url is an existing booking and in UCP continue_url resumes an existing session; neither applies here. permalink_url itself is the vendor name HEADSPA sends today: in UCP shopping and lodging it names the link to an existing order or booking, so the final name and shape of a pre-booking link follow the UCP permalink target form in section 3.
check_horizon_days is a hint in the sense #897 uses for available_methods[]; the authoritative signal is the live beyond_horizon status. Without service_id HEADSPA returns a permalink_url to its booking page without a preselected service.
5.2a Agent horizon
The merchant declares two horizons in the capability config: "horizon_days" - how far ahead recommended starts go (HEADSPA: 7), and "check_horizon_days" - how far ahead the point check answers (HEADSPA: 90, its whole booking window). check_horizon_days MUST be at least horizon_days and SHOULD NOT exceed the number of days in USP booking_window.max_advance, which stays the real booking limit on the merchant's site. Recommended starts never go past horizon_days. A point check past check_horizon_days returns status: "beyond_horizon" with a permalink_url (merchant origin, no hold, preselected where possible), never available: false. The agent MUST hand the guest to that URL and MUST NOT say the date is unavailable.
The merchant MAY also declare where the point check is served, as config.point_check: { "url": "https://www.head-spa.lv/usp/v1/availability/check", "method": "POST" } at HEADSPA. It is optional and informative: the address is always POST {endpoint}/availability/check relative to the USP REST endpoint, and when both are given they MUST name the same resource.
An agent allotment (capacity or booking windows set aside for agents) MAY apply only to near dates. On dates outside the allotment the point check answers from what the merchant itself sells online on that day; the merchant need not say which dates the allotment covers.
Rules:
- The answer MUST NOT reveal why a time is unavailable (booked, closed, too soon) and MUST NOT return more than 2 alternatives.
- Within
horizon_days,alternativesare recommended starts (same rule as 5.1), not chosen by nearness to the asked time, so repeated checks cannot reconstruct the grid. - Past
horizon_days,alternativesare at most 2 free starts on the same day, chosen by the merchant, with no reason codes and nothing about other days; together with the read rate limit this keeps a far date from exposing the calendar. - A point check is a read: it never holds or blocks inventory.
- The base
POST /availability/queryMAY still be served, MAY be limited to the same curated set, or MAY be withheld from unauthenticated callers; the merchant states which inconfig.full_grid, one ofserved,curated,authenticated_only.
5.3 How an agent reads it
- A time missing from recommended starts means "not recommended, ask with a point check", never "fully booked".
- An agent MUST NOT tell a guest a day is full based on recommended starts,
available: falsefor one time, or an emptysame_daylist; it says "that time is not free, here are two others" or offers the handoff page. - An agent SHOULD prefer a point check over scanning: one check per guest wish.
- For a date past
horizon_daysbut withincheck_horizon_days(a trip planned weeks ahead) the agent uses the point check for the guest's own date and time. - For a date past
check_horizon_daysthe agent does not check; it sends the guest topermalink_url(or the handoff template) and says booking that far ahead is done on the merchant's site.
Without the extension: a base client sees whatever the merchant serves on /availability/query (possibly the curated set) and may wrongly conclude the rest is booked. Merchants SHOULD also set availability_hint.summary to say so in words, for example "We show a few suggested times; ask for any other time".
Open questions: should complete: false move into core USP as a flag on the query response? Is "working hours" lead time worth standardising, or keep it as free policy text?
Abuse limits
Base USP already has: rate limiting with 429 and RateLimit-* headers (10.2.2), hold abuse prevention with hold_limit_exceeded, TTL 5-10 min, backoff (10.1.2). Gap: limits are only discovered by hitting them; an agent cannot plan. We propose advertising them in the profile.
Policy:
- Reads (catalog, recommended starts, point check, query) are rate limited per client by the merchant, for example 20 per minute per IP or per platform key. Reads never block inventory.
- Holds (later phase): an agent hold lives 5 minutes (
PT5M), within USP 10.1.2 ("Hold TTL SHOULD be between 5 and 10 minutes"). The merchant's own website may hold longer for a person filling the form (HEADSPA: 15 minutes); that is outside the protocol. At most 2 active holds at once per source (platform key, else IP); per buyer (email or phone) 1 active hold; confirmation only with full prepayment through UCP checkout. There is no daily cap per source: one platform carries many real guests, so a per-source daily cap would refuse genuine buyers.
Advertisement, as config on the capability entry. HEADSPA sends holds.enabled and holds.ttl today; the other holds fields are for a later phase.
"com.bookbaltic.services.recommended_starts": [{
"version": "2026-11-01", "extends": "dev.usp-protocol.services.availability",
"spec": "https://bookbaltic.com/specs/usp/recommended-starts",
"schema": "https://bookbaltic.com/schemas/usp/recommended_starts.json",
"config": {
"full_grid": "curated",
"horizon_days": 7,
"check_horizon_days": 90,
"point_check": { "url": "https://www.head-spa.lv/usp/v1/availability/check", "method": "POST" },
"limits": {
"reads": { "per_minute": 20, "scope": "ip", "blocks_inventory": false },
"holds": { "enabled": false, "ttl": "PT5M",
"max_active_per_source": 2, "max_active_per_buyer": 1,
"confirm_requires": "full_prepayment" } } } }]
Rules: advertised numbers are ceilings the merchant promises not to go below without a new profile; the live responses stay authoritative. scope is one of ip, platform, buyer. No new error is needed: exceeding a concurrent hold cap returns hold_limit_exceeded in the USP sense (the platform releases a hold it already has, USP 10.1.2); calling too often returns 429 with Retry-After as defined in UCP Core Protocol Errors (USP 10.2.2 in Standalone mode). A named rate_limit_exceeded code (#622) was closed while #600 is under review; if it returns, this extension uses it. ttl is the agent hold length.
6. Pre-visit intake per guest
Base USP already does: Section 11.4 Booking Questions (dev.usp-protocol.services.booking_questions, added 2026-10-31) covers pre-visit forms: Service.booking_question_sets[] with purpose: "intake", completion_timing: "before_confirmation" | "before_service", frequency, validity, subject: "buyer" | "recipient", due_at, items with sensitive and collection: "business_surface"; the booking carries booking_question_state[] (status, continue_url for pending business-surface work); the buyer-input Action requires the buyer, not the agent, to supply each answer; platforms MUST never infer an answer from memory without buyer confirmation (11.4.8, 11.4.11 platform rule 4). This extension does not redefine any of that.
Gaps (USP 11.4.13 defers them): (i) "Group forms per attendee": a pair needs two independent forms, one per guest, and USP models one subject per set (subject: buyer | recipient; "USP has no general participants concept"), a group service whose form needs answers from more than the one modelled recipient "stays withheld" (vector bq-flow-group-participants-deferred); an appointment for two (party_size: 2) has the same gap and no rule at all; (ii) there is no state "prepared by the agent, not yet confirmed by the guest", and no way for the second guest to confirm their own form.
Related: reuses the guest pool of UCP Lodging (#780). Input to #922 Phase 3 "intake forms", proposed upstream as a revision of USP 11.4. Checked: #953 and #954 cover lodging guest and booker identity, not forms; PR #915 (declared_fields) has no per-person or sensitive-data rules; discussion #1022 asks for an explicit end-customer consent statement in the booking request (who, which data, which purpose). None defines per-guest intake or an agent draft.
Schema
{
"$id": "https://bookbaltic.com/schemas/usp/intake.json",
"requires": { "protocol": { "min": "2026-08-20" },
"capabilities": { "dev.usp-protocol.services.booking_questions": { "min": "2026-08-20" },
"dev.usp-protocol.services.bookings": { "min": "2026-08-20" } } },
"$defs": {
"BookingQuestionSetExt": { "type": "object", "description": "Added to a USP BookingQuestionSet.", "properties": {
"per_guest": { "type": "boolean", "default": false,
"description": "Each guest of the party answers this set on their own. Valid only with purpose intake. Every item with sensitive true MUST use collection business_surface." },
"agent_draft": { "enum": ["none", "non_sensitive"], "default": "none",
"description": "non_sensitive: an agent may draft in_band items whose sensitive is not true." } } },
"PerGuestSetRule": { "description": "Applied to a USP BookingQuestionSet that carries this extension: with per_guest true, every item with sensitive true uses collection business_surface (collection defaults to in_band, so it must be present).",
"if": { "required": ["com.bookbaltic.services.intake"], "properties": {
"com.bookbaltic.services.intake": { "required": ["per_guest"], "properties": { "per_guest": { "const": true } } } } },
"then": { "properties": { "items": { "items": {
"if": { "required": ["sensitive"], "properties": { "sensitive": { "const": true } } },
"then": { "required": ["collection"], "properties": { "collection": { "const": "business_surface" } } } } } } } },
"Guest": { "type": "object", "required": ["id"], "properties": {
"id": { "type": "string", "description": "Platform-generated, session-scoped, as UCP Lodging guests[].id" },
"role": { "type": "string", "description": "primary or accompanying, as UCP Lodging guest_assignments.role" },
"first_name": { "type": "string" } } },
"GuestQuestionState": { "type": "object", "required": ["guest_id", "set_id", "revision", "status"], "properties": {
"guest_id": { "type": "string", "description": "guests[].id of this booking" },
"set_id": { "type": "string" },
"revision": { "type": "string" },
"status": { "enum": ["pending", "completed", "expired", "waived", "extended", "under_review", "rejected"],
"description": "As USP BookingQuestionState.status" },
"draft_awaiting_guest": { "type": "boolean", "description": "An agent draft exists and waits for this guest's confirmation; status stays pending." },
"continue_url": { "type": "string", "format": "uri", "pattern": "^https://",
"description": "This guest's own session link: unguessable, expiring, no personal data." },
"due_at": { "type": "string", "format": "date-time" } } },
"BookingExt": { "type": "object", "properties": {
"guests": { "type": "array", "items": { "$ref": "#/$defs/Guest" }, "description": "Same shape as guests[] of com.bookbaltic.services.party (UCP Lodging id and first_name, role from guest_assignments.role); omitted when that extension is negotiated, which then carries the pool" },
"guest_question_state": { "type": "array", "items": { "$ref": "#/$defs/GuestQuestionState" },
"description": "One entry per guest for each per_guest set. Never carries answers." } } },
"DraftRequest": { "type": "object", "required": ["set_id", "revision", "answers"], "properties": {
"set_id": { "type": "string" }, "revision": { "type": "string" },
"answers": { "type": "object", "description": "Keyed by question id, as USP BookingQuestionSubmission.answers; only in_band items whose sensitive is not true." } } },
"DraftResponse": { "type": "object", "required": ["status", "draft_awaiting_guest"], "additionalProperties": false, "properties": {
"status": { "const": "pending" },
"draft_awaiting_guest": { "const": true },
"expires_at": { "type": "string", "format": "date-time" } } }
}
}
Rules
- Declare the form as a USP booking question set (
purpose: "intake", usuallycompletion_timing: "before_service", items withsensitive: trueandcollection: business_surfacefor health questions). This extension only addsper_guestandagent_drafton that set, andguest_question_state[]on the booking; the booking usesguests[]ofcom.bookbaltic.services.party(section 2) when that extension is negotiated, otherwise carriesguests[]of the same shape. - Per guest: with
per_guest: truethe booking carries oneguest_question_stateentry per guest, each with that guest's owncontinue_url. Status only, never answers, never a risk level. The USP set-levelbooking_question_stateentry iscompletedonly when every guest entry iscompleted,extendedorwaived; 11.4.6 gating applies to the set-level entry as today. - In a
per_guestset everysensitive: trueitem MUST usecollection: business_surface. - A platform MUST NOT submit one guest's answers for another guest, in line with 11.4.11 platform rule 2. A minor's form is confirmed by a legal guardian.
- Agent draft (optional):
POST {endpoint}/bookings/{booking_id}/booking-questions/{set_id}/guests/{guest_id}/draftwith aDraftRequest, authenticated by the booking-scoped credential (USP 10.1.6). A draft MAY contain onlyin_banditems whosesensitiveis not true. The business rejects any other item with the recoverable errorbooking_question_draft_not_allowedand a question-levelpath(11.4.9 rules). The response echoes no values and no risk level. - Sensitive answers: the agent never drafts them. Health questions stay on the business surface: each guest answers them on their own
continue_url, as this extension requires (USP 11.4.10 only keeps them out of MCP elicitation forms). - Guest confirms: a draft is not a submission. Only the guest's confirmation on
continue_urlmoves that guest's entry tocompleted: the page shows every drafted answer with an edit option, collects the sensitive items, asks explicit consent for health data (an art. 9(2) exception; for a spa explicit consent, GDPR art. 9(2)(a), which the business must be able to show, art. 7(1)) as a separate step, never preselected (11.4.8), and records the wording shown. An unconfirmed draft expires and counts as not filled. - Agent obligations: an agent MUST NOT tell anyone a form is done because a draft was accepted, and MUST NOT store or reuse draft answers after the call. This is stricter than USP 10.1.3, which lets a platform keep personal data while it manages an active booking.
- A platform that did not negotiate the extension sees plain USP: the business applies 11.4.2 and 11.4.13 as today.
First-draft names replaced to follow USP 11.4: form_id → set_id + revision; due: before_confirm | before_visit → completion_timing: before_confirmation | before_service; per → per_guest; agent_fillable → agent_draft; data_classes → USP sensitive per question; actions[] intake_form → guest_question_state[]. In 2026-11-03: agent_draft values contact_only and contact_and_yes_no → non_sensitive (yes/no health answers are sensitive and never drafted); expires_at on the guest entry → due_at, as USP; fhir_questionnaire_url removed (form formats other than the USP item model are out of scope).
Example (HEADSPA)
HEADSPA asks every guest, before the visit, 9 yes/no health questions and one free-text health note, plus contacts, preferences and consents; the guest fills it on the HEADSPA site (one signed link per guest, www.head-spa.lv/anketa/...) and staff read it in its health module. The extension is not yet declared in the HEADSPA profile; the example shows the intended shape.
Service fragment (USP set with this extension):
"booking_question_sets": [{ "id": "headspa-intake", "revision": "v6",
"purpose": "intake", "completion_timing": "before_service", "frequency": "every_booking", "subject": "recipient",
"items": [ "... 9 yes/no health questions and a health note, all sensitive: true, collection: business_surface; contacts, preferences, consents ..." ],
"com.bookbaltic.services.intake": { "per_guest": true, "agent_draft": "non_sensitive" } }]
Draft request for the second guest (answers keyed by question id):
{ "set_id": "headspa-intake", "revision": "v6",
"answers": { "language": "en", "preferences": "medium pressure, quiet room" } }
Response, no values echoed:
{ "status": "pending", "draft_awaiting_guest": true, "expires_at": "2026-11-07T12:00:00+02:00" }
Booking for two, after the primary guest completed the form and the agent drafted the partner's preferences:
"com.bookbaltic.services.intake": {
"guests": [ { "id": "g1", "role": "primary" }, { "id": "g2", "role": "accompanying", "first_name": "Marta" } ],
"guest_question_state": [
{ "guest_id": "g1", "set_id": "headspa-intake", "revision": "v6", "status": "completed" },
{ "guest_id": "g2", "set_id": "headspa-intake", "revision": "v6", "status": "pending", "draft_awaiting_guest": true,
"continue_url": "https://business.example.com/forms/sess_Kp9w...", "due_at": "2026-11-07T12:00:00+02:00" } ] }
HEADSPA links today are per guest and signed, not yet expiring. The set-level booking_question_state stays pending until g2 confirms.
Without the extension: USP 11.4 collects one form for the one recipient; the second guest of a pair has no form of their own (11.4.13 fails closed), and the agent can only hand over the business-surface link.
Honest risks: the merchant cannot verify what the agent platform retains, which is why sensitive answers stay off the agent path; an agent can mis-record a drafted choice, so the confirm page shows each answer and the venue check stays; one-tap confirmation can become blind consent, so consent is a separate explicit step.
Open questions: should per-guest sets move into USP 11.4 once a participants concept exists, sharing UCP Lodging's guests[], or a shared model if #922 defines one?
7. Directory search across merchants
Base UCP and USP already do: UCP defines the Business, which sells and is the Merchant of Record, and the Platform, the consumer-facing surface (UCP glossary, core concepts). RFC UCP #937 ("Hosted Business Directory", a draft, not yet in the specification) proposes dev.ucp.common.directory: a host declares it in its own /.well-known/ucp, serves POST {endpoint}/profiles/list (a snapshot of the profiles it lists, then changes via sync_cursor, statuses active and removed), signals added and removed events, and every listed business confirms the listing with a top-level directories[] member in its own profile. #937 names a "regional downtown business directory" as one kind of host that lists a business next to its primary host. UCP location_search (dev.ucp.common.location.search) searches one business's locations with a free-text query, a distance radius, filters (open hours at an instant, amenities, item availability) and cursor pagination. UCP ask (dev.ucp.common.ask) lets a platform ask a business a free-form question and get a text answer. USP adds business_id (Section 3.4) and an optional Discovery Registry (Section 6). UCP #1001 proposes delegating one business's profile to a provider; merchant-of-record semantics are a stated non-goal there.
Gaps: (i) a #937 directory lists profiles, but an agent asked for "somewhere to relax for two in Riga on Saturday at 16:00 under 70 EUR each" still has to open each profile and query each business; there is no way to ask once across the listed businesses and get only those free at that time, each still decided by that business; (ii) location_search has the right request shape but stays inside one business; (iii) nothing states, per listed business, who takes the money, so an agent cannot tell a marketplace that collects payment from a directory that never touches guest funds; (iv) no rule that a directory publishes its ranking rule and refuses paid placement; (v) no place for facts the brand itself publishes (who it suits, price range, booking languages, where to verify it), passed through without the directory's own judgement.
directory_search adds only that. It **extends dev.ucp.common.directory (RFC #937 draft)**: the listing itself is #937 (profiles/list, directories[]), the search request follows the shape of location_search, and questions to a business go through that business's own ask. Nothing here replaces those parts.
Role. In #937 terms BookBaltic is a regional directory, not a commerce host: unlike a platform that runs its merchants' storefronts and payments, BookBaltic lists businesses that keep their own websites, UCP profiles, USP endpoints and payment (HEADSPA: head-spa.lv). BookBaltic does not serve any business's services[].endpoint; it lists the business and searches across the businesses' own availability.
What the directory is and is not. It is a source of data for agents and portals: facts written by each business, live availability and the business's own booking path. It does not recommend: choosing a place is the brand's job (its own content and visibility), and the agent's (what it knows about the guest, the conversation, the facts, what is public about the brand). The directory answers category and mood requests ("relax for two in Riga") and always hands the guest to the brand; it does not answer for a brand-name request on the brand's behalf. Brands' search or GEO visibility is never used for ordering. The directory is data for agents and portals, not a page for people; the guest always books on the merchant's own site or through its UCP profile.
Host profile and endpoints
The directory's own profile, https://bookbaltic.com/.well-known/ucp, declares services["dev.ucp.common"] (REST, endpoint https://www.bookbaltic.com/ucp), dev.ucp.common.directory (spec: RFC #937; config.role: "regional_directory", serves_business_endpoints: false) and com.bookbaltic.services.directory_search (config.search, config.ranking), plus com.bookbaltic.operator. No schema is given for dev.ucp.common.directory: the UCP repository has no schema for it yet.
POST https://www.bookbaltic.com/ucp/profiles/list- #937 listing:profiles[] {profile, status, updated_at},pagination {cursor, has_next_page},sync_cursoron the last page; withsync_cursorit returns only changes since then,removedincluded.POST https://www.bookbaltic.com/usp/v1/directory/search-DirectorySearchRequest→DirectorySearchResponse.POST /usp/v1/directory/startsis the earlier address of the same operation and accepts the earlier flat fields.GET https://www.bookbaltic.com/usp/v1/directory-DirectoryResponse: the operator, region, ranking rule and every listed business with its facts and verify links.https://www.bookbaltic.com/usp/v1/{business_id}/...- the business's own USP REST paths, passed through unchanged, booking links included.
Every listed business names the directory in its own profile: "directories": ["https://bookbaltic.com/.well-known/ucp"] (HEADSPA does). Per #937 an agent MUST ignore a listing the business has not confirmed this way.
The same entry point and the same rules serve tourism portals that embed a Book button (for example a city or national tourism site): a portal is just another platform calling the search, with no separate ranking or terms.
Schema
{
"$id": "https://bookbaltic.com/schemas/usp/directory_search.json",
"$defs": {
"Sourced": { "type": "object", "required": ["value", "source"], "properties": {
"value": {}, "source": { "type": "string", "description": "Where the brand published this fact (URL, optionally with the field)" } } },
"Ranking": { "type": "object", "required": ["paid_placement", "uses_brand_geo", "method"], "properties": {
"paid_placement": { "const": false },
"uses_brand_geo": { "const": false },
"method": { "type": "string", "description": "Plain-language ranking rule" } } },
"Geo": { "type": "object", "required": ["latitude", "longitude"], "properties": {
"latitude": { "type": "number", "minimum": -90, "maximum": 90 }, "longitude": { "type": "number", "minimum": -180, "maximum": 180 } },
"description": "WGS 84, as UCP types/geo.json" },
"DirectoryInfo": { "type": "object", "required": ["operator", "region", "merchants_count", "ranking"], "properties": {
"operator": { "type": "object", "required": ["name", "legal_entity", "url", "receives_guest_funds"], "properties": {
"name": { "type": "string" }, "legal_entity": { "type": "string" }, "url": { "type": "string", "format": "uri" },
"receives_guest_funds": { "const": false } } },
"region": { "type": "array", "items": { "type": "string" }, "description": "ISO 3166-1 alpha-2 codes, e.g. LV, EE, LT" },
"merchants_count": { "type": "integer", "minimum": 0 },
"ranking": { "$ref": "#/$defs/Ranking" },
"booking": { "type": "string" },
"host_profile": { "type": "string", "format": "uri" },
"profiles_list": { "type": "string", "format": "uri" },
"search": { "type": "string", "format": "uri" } } },
"BrandFacts": { "type": "object", "description": "Written by the brand, passed through as is. A fact the brand has not published is absent.", "properties": {
"description": { "$ref": "#/$defs/Sourced" },
"suitable_for": { "$ref": "#/$defs/Sourced", "description": "value: { party?: [solo|pair|group], moods?: [string] }" },
"price_range": { "$ref": "#/$defs/Sourced" },
"booking_languages": { "$ref": "#/$defs/Sourced", "description": "value: BCP 47 codes" },
"accessibility": { "$ref": "#/$defs/Sourced" },
"signature_service_ids": { "$ref": "#/$defs/Sourced" } } },
"VerifyLink": { "type": "object", "required": ["type", "source", "url"], "properties": {
"type": { "enum": ["website", "map", "reviews", "social"] },
"source": { "type": "string", "description": "brand, google, tripadvisor, instagram ..." },
"url": { "type": "string", "format": "uri", "pattern": "^https://" } },
"description": "A link the brand publishes, for the agent to check. No scores are copied." },
"DirectoryMerchant": { "type": "object",
"required": ["business_id", "name", "location", "categories", "profile_url", "seller_of_record", "payment_handled_by"], "properties": {
"business_id": { "type": "string" },
"name": { "type": "string" },
"location": { "type": "object", "required": ["city", "country"], "properties": {
"address": { "type": "string" }, "city": { "type": "string" }, "country": { "type": "string" },
"geo": { "$ref": "#/$defs/Geo" } } },
"categories": { "type": "array", "items": { "type": "string" } },
"profile_url": { "type": "string", "format": "uri", "pattern": "^https://" },
"seller_of_record": { "type": "string", "description": "Legal name of the business that sells and is the Merchant of Record" },
"payment_handled_by": { "const": "merchant" },
"extensions": { "type": "array", "items": { "type": "string" }, "description": "com.bookbaltic.services.* capabilities the business declares live" },
"facts": { "$ref": "#/$defs/BrandFacts" },
"verify_links": { "type": "array", "items": { "$ref": "#/$defs/VerifyLink" } } } },
"DirectoryResponse": { "type": "object", "required": ["com.bookbaltic.services.directory_search", "merchants"], "properties": {
"com.bookbaltic.services.directory_search": { "$ref": "#/$defs/DirectoryInfo" },
"merchants": { "type": "array", "items": { "$ref": "#/$defs/DirectoryMerchant" } } } },
"DirectorySearchRequest": { "type": "object", "required": ["filters"],
"description": "Shaped after UCP location_search search_request: query, distance, filters and pagination combine with AND; query does not relax them.",
"properties": {
"query": { "type": "string", "maxLength": 200, "description": "Category or mood in the agent's words, e.g. 'relax for two'" },
"distance": { "type": "object", "required": ["center", "max"], "properties": {
"center": { "$ref": "#/$defs/Geo" }, "max": { "type": "number", "minimum": 0, "description": "Meters, as UCP location_distance" } } },
"locality": { "type": "object", "properties": {
"address_locality": { "type": "string", "description": "City" }, "address_country": { "type": "string" },
"district": { "type": "string", "description": "Matched against the business address" } } },
"filters": { "type": "object", "required": ["party_size"], "properties": {
"party_size": { "type": "integer", "minimum": 1, "maximum": 20 },
"available_at": { "type": "string", "format": "date-time", "description": "RFC 3339 instant with Z or offset, like location_search hours.open_at; each business evaluates it in its own timezone with its point check" },
"available_on": { "type": "string", "format": "date", "description": "Local date at the business; its recommended starts for that day" },
"categories": { "type": "array", "items": { "type": "string" } },
"budget_max": { "type": "number", "exclusiveMinimum": 0, "description": "Per guest, in the business's currency (EUR in the Baltics)" } } },
"pagination": { "type": "object", "properties": { "cursor": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 50 } } },
"locale": { "type": "string" } } },
"DirectorySearchResponse": { "type": "object", "required": ["complete", "ranking", "results", "pagination"], "properties": {
"complete": { "const": false },
"ranking": { "$ref": "#/$defs/Ranking" },
"query_terms": { "type": "array", "items": { "type": "string" } },
"results": { "type": "array", "items": { "type": "object",
"required": ["business_id", "name", "profile_url", "seller_of_record", "payment_handled_by", "matched_service_ids", "starts"], "properties": {
"business_id": { "type": "string" },
"name": { "type": "string" },
"profile_url": { "type": "string", "format": "uri" },
"seller_of_record": { "type": "string" },
"payment_handled_by": { "const": "merchant" },
"facts": { "$ref": "#/$defs/BrandFacts" },
"verify_links": { "type": "array", "items": { "$ref": "#/$defs/VerifyLink" } },
"matched_service_ids": { "type": "array", "items": { "type": "string" } },
"starts": { "type": "array", "maxItems": 3, "items": { "type": "object", "required": ["start"], "properties": {
"start": { "type": "string", "format": "date-time" },
"service_ids": { "type": "array", "items": { "type": "string" } },
"services_fit": { "type": "array", "description": "From the business's point check, with its permalink_url" } } } } } } },
"pagination": { "type": "object", "required": ["has_next_page"], "properties": {
"cursor": { "type": "string" }, "has_next_page": { "type": "boolean" }, "total_count": { "type": "integer" } } } } }
}
}
The earlier flat request fields (party_size, date, time, city, district, categories, budget_max at the top level) remain accepted as synonyms.
Rules
- Listing (RFC #937). The directory lists only businesses it has connected with their consent, through
profiles/list; each business confirms withdirectories[]in its own profile. The business's profile, policies and prices are authoritative and the directory MUST NOT change them. A business can leave at any time: its entry becomesremovedin the change feed and its own profile keeps working. - Facts.
factsare written by the brand and passed through as is, each with the address where the brand published it. The directory adds no adjectives, scores or "best for". A fact the brand has not published is absent, not guessed. - Verify links.
verify_linksare links the brand publishes (its website, map, public review pages such as Google or Tripadvisor). The directory copies the link, never a score or a review text; the agent checks them itself. - Search. A request may carry a category or mood (
query,filters.categories), a moment or a date, party size, a radius or a city and district, and a budget per guest; they combine with AND, as inlocation_search. Words ofqueryare matched only against what the brand wrote (name, categories, description, the moods it names, its service names and descriptions). A result appears only if the business has a start that fits every filter: withavailable_at, the business's own point check (5.2) says that moment is free for a matching service; withavailable_on, its recommended starts (5.1) for that local day; with neither, its next recommended starts. At most 3 starts per business, never its full grid. - Ranking. Neutral and published in every answer (
ranking.method): only businesses with a fitting start, then the earliest fitting start, thenbusiness_idalphabetically. No paid placement ("nobody pays for a better spot"), no use of brands' search or GEO visibility, no "recommended" or "top" labels. - Booking. Booking and payment happen with the business: through its own USP endpoint and checkout, its permalink (the
permalink_urlit returns), or its UCP profile. The directory never takes guest funds (receives_guest_funds: false,payment_handled_by: "merchant"); its fee, if any, is invoiced to the business. - Same rules for everyone. Agents and tourism portals use the same entry point, filters and ranking.
- Privacy. The directory does not store guest data from searches; search requests carry no personal data.
- Questions to the brand. A business answers questions through UCP
ask(dev.ucp.common.ask) declared in its own profile; the directory MAY pass a question through to that business and return the answer unchanged, and never answers for the brand. Not implemented by BookBaltic yet; HEADSPA does not declareask.
Deviations from RFC #937 (draft)
- Open listing. #937 says a host MUST authenticate the consumer with signed UCP requests before returning its list, because commerce platforms keep their merchant base private. A regional directory's purpose is to be found, and its businesses opted in to be listed publicly, so BookBaltic returns
profiles/listwithout consumer authentication. Open question for #937: should a directory be able to declare itself public? - Events.
addedandremovedevents are not sent yet; the change feed (sync_cursor) is the source of changes.
Example (BookBaltic)
POST https://www.bookbaltic.com/ucp/profiles/list with {}:
{ "profiles": [{ "profile": "https://head-spa.lv/.well-known/ucp", "status": "active", "updated_at": "2026-11-03T00:00:00Z" }],
"pagination": { "has_next_page": false }, "sync_cursor": "…" }
HEADSPA's profile carries "directories": ["https://bookbaltic.com/.well-known/ucp"].
POST https://www.bookbaltic.com/usp/v1/directory/search with
{ "query": "relax for two", "locality": { "address_locality": "Riga" },
"filters": { "party_size": 2, "available_at": "2026-11-05T12:00:00+02:00" } }
query_terms is ["relax"]; it matches HEADSPA's service "Face & Head Relax"; HEADSPA's point check says 12:00 local time is free, and the result carries that start with HEADSPA's own permalink_url. HEADSPA has not published accessibility details or moods, so those facts are absent.
Without the extension: an agent gets the list of profiles from #937 and then reads each profile and queries each business one by one; it cannot ask once for "free at this time", cannot see that the directory never takes the money, and cannot tell brand-written facts from a directory's own opinion.
Open questions: should the search across a #937 directory become an optional operation of dev.ucp.common.directory itself, reusing the location_search request? Should "payment handled by" and the ranking rule be fields of #937 listings? How does a directory listing combine with profile delegation once #1001 settles? Should mood words be a shared vocabulary rather than free text matched against brand copy?
Security and privacy
- Staff data: only
idand display name (first name or chosen work name). No photos unless the merchant already publishes them inoptions[].image_url, no surnames, schedules, or contact data.eligible_staff_countis a count, never a list. - No occupancy leakage: recommended starts and point checks are designed so that repeated calls cannot rebuild the calendar: capped results, merchant-chosen alternatives, no reason codes, rate limits.
most_chosencarries no counts. - Participants: guest 2 needs at most a first name; email and phone stay on
buyeronly. Per-guest health and intake data follow section 6. - Handoff URLs: HTTPS, merchant origin only, no personal data in query strings, no hold, so a leaked link reserves nothing.
- Seller of record: the business (HEADSPA) is the seller and merchant of record, as USP 2.1.2 requires; payment is charged by the business's own PSP. BookBaltic is the technical operator that implements USP for its businesses (USP 2.1.5), named as the operator in the profile (
com.bookbaltic.operatorwithrole: "technical_operator"andreceives_guest_funds: false), and never receives the funds; its fee is invoiced to the business.
Open questions
- Submit as seven separate extensions or one
com.bookbaltic.services.localbundle with feature flags? - Multi-resource slots (two couches, two therapists) - push as a change to core USP Section 4.1 rather than a vendor extension? Recommendation: no core change now. Base slot = one "pair room" resource with
party_size: 2andcapacity {min: 2}; per-guest couches and staff stay incom.bookbaltic.services.party.guest_assignments(UCP Lodging naming). This keeps USP slot identity (one slot = one combination, Section 4.1) and avoids N x M slot explosion, matches how Zenoti, Book4Time and Google Actions Center model it, and fits #922 Phase 3 ("party-size rules" in vendor namespaces). Revisit core only if a second implementer needs per-guest resources in the base slot. - Staff dropout - new USP webhook
booking.resource_changed, or ourstaff_changeonbooking.rescheduled; andawaiting_buyeras a #944 consultation once #944 lands? - Handoff - when does HEADSPA serve UCP
dev.ucp.shopping.permalinkwithbooking/*field paths (section 3), which replaces the vendor fields? - Discovery hints - keep in USP, or move to UCP catalog / schema.org?
complete: falseand the point check - propose for core availability in #922 Phase 1?- Intake - per-guest booking question sets into USP 11.4 once it has participants; and one intake profile on top of USP 11.4 agreed at industry level (spa associations), not per merchant?
- Day entry without a fixed start and group tickets - bring the day-pass case (no start time, area capacity) to UCP #479 and the USP "Wellness & Spa" roadmap item, since neither covers it today?
References
- USP repository: https://github.com/wix/universal-scheduling-protocol (README,
specification.mdversion2026-08-20,schemas/,site-docs/extensions.md,site-docs/namespace.md,site-docs/roadmap.md) - USP site: https://usp-protocol.dev ; UCP-native mode: https://usp-protocol.dev/deployment-modes/ucp-native/
- USP sections cited: 2.4-2.5 (capabilities, namespace), 3.3, 3.6, 3.8, 3.10 (catalog), 4.1-4.3 (availability, holds), 5.2-5.4 (booking, webhooks), 9.5 (ESP), 10.1.2 and 10.2.2 (abuse, rate limits), 11 (extensions), 11.4 (booking questions)
- UCP repository: https://github.com/Universal-Commerce-Protocol/ucp ; core concepts and namespace governance: https://ucp.dev
- UCP issue #922, Local Services charter: https://github.com/Universal-Commerce-Protocol/ucp/issues/922 ; related #317 (
SearchAvailability,HoldSlot), #303, #479 (Booking Availability, timed entry), discussion #1022 (small merchants: any professional,requires[],capacity_pool) - UCP #896 (buyer contingencies,
otherwise), #944 (post-order buyer decision,decision_deadline), #960 (per-guest add-ons), #897 (Food checkout,requested_fulfillment_time,options[]), #953 and #954 (lodging guest identity) - UCP permalink
dev.ucp.shopping.permalink(#523,source/schemas/shopping/permalink.json,docs/specification/permalink.md) - UCP Lodging (#780):
source/schemas/lodging/types/guest.json,guest_assignment.json,occupancy.json - UCP #1009 / PR #1010, business incentives
- USP Section 11.4 Booking Questions (
schemas/booking_questions.json, CHANGE_LOG 2026-10-31) - RFC UCP #937, Hosted Business Directory (draft,
dev.ucp.common.directory,profiles/list,directories[]): https://github.com/Universal-Commerce-Protocol/ucp/issues/937 ; UCP #1001, profile delegation - UCP location search (
dev.ucp.common.location.search,source/schemas/common/location_search.json) and ask (dev.ucp.common.ask,source/schemas/common/ask.json) in https://github.com/Universal-Commerce-Protocol/ucp - RFC 6570 URI Template; RFC 3339; ISO 8601; draft-ietf-httpapi-ratelimit-headers
- Live implementation: https://head-spa.lv