bookbaltic

com.bookbaltic.services.recommended_starts

Status: Draft. Version 2026-11-01. Part of BookBaltic Scheduling Extensions for USP / UCP Local Services.

Extends: dev.usp-protocol.services.availability

Schema: https://bookbaltic.com/schemas/usp/recommended_starts.json

What it adds: Recommended starts instead of the full grid, a point check "is this time free for N guests", advertised abuse limits


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.

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):

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:

5.3 How an agent reads it

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:

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.