com.bookbaltic.services.directory_search
Status: Draft. Version 2026-11-01. Part of BookBaltic Scheduling Extensions for USP / UCP Local Services.
Extends: dev.ucp.common.directory (RFC UCP #937, draft)
Schema: https://bookbaltic.com/schemas/usp/directory_search.json
What it adds: 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
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?