> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mealops.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Discovery & availability

> Find restaurants and dishes, expand chains, and get the exact windows an order can be scheduled inside.

Four tools answer "where can I order, what do they serve, and when?" — relevance-ranked search, cross-restaurant dish search, schedulable time windows, and chain expansion.

## search\_restaurants

Return live restaurants the calling agent can discover and order from (every approved agent sees the same set), relevance-ranked when a `query` is present.

REST equivalent: `GET /api/agent/restaurants` (same parameters; responds with the bare restaurants array — the envelope fields below are MCP-only, except pagination metadata, which REST exposes as `X-Total-Matches` and `X-Next-Offset` response headers, both CORS-exposed. REST boolean query params accept `true`/`false`/`1`/`0`; anything else is ignored).

Input:

```json theme={null}
{
  "query": "optional name/brand/cuisine search, relevance-ranked",
  "fulfillment_type": "pickup | delivery | catering | eat_in | curbside",
  "pos_provider": "deliverect",
  "exclude_pos_provider": ["mock"],
  "address": "1 Main St, Redwood City, CA 94063",
  "postal_code": "94063",
  "city": "Redwood City",
  "state": "CA",
  "latitude": 37.4852,
  "longitude": -122.2364,
  "radius_miles": 10,
  "requested_time": "2026-08-11T19:00:00.000Z",
  "open_now": false,
  "only_open": false,
  "cuisine": "thai",
  "min_rating": 4,
  "max_prep_time_minutes": 30,
  "supports_catering": true,
  "max_minimum_order_cents": 2500,
  "exclude_paused": true,
  "limit": 20,
  "offset": 0,
  "fields": ["name", "distanceMiles", "earliest_orderable_time"],
  "sort": "relevance | distance | name | rating | prep_time",
  "collapse_brands": true
}
```

Location resolution, in priority order:

1. `latitude`/`longitude` — true proximity. No implicit radius; pass `radius_miles` (max 50) to bound it. Preferred whenever the client has geometry (device location, address autocomplete).
2. `postal_code` (ZIP or ZIP+4) — resolved to the ZIP's geographic centroid, then the same distance ranking as coordinates with a default 25-mile radius (override with `radius_miles`). An unknown ZIP (PO-box-only, military) degrades to exact-ZIP string matching. Stores without stored coordinates that exactly match the searched ZIP are still included, distance-unranked.
3. `city` / `state` — hard textual filters (state accepts "CA" or "California"); they also combine with any of the above.
4. `address` — never geocoded. A ZIP found inside it gets centroid treatment; otherwise it falls back to city-fragment string matching.

Search behavior:

* **A location is required for restaurant results.** A `query` with no location returns `brand_matches` summaries instead (see below) — never a list of arbitrary far-away stores.
* `query` matches name, brand, cuisine, description, and address with normalization (case, accents, apostrophes, "&"/"and") and any-order token matching ("leaf salads green" finds "Green Leaf Salads"). Each result carries `match_score` (0-100) and `matched_on` (`name | brand | cuisine | description | address`).
* Default ordering: relevance when `query` is present (distance, then name, as tie-breaks); distance when an origin resolved; name otherwise. `sort` overrides. `sort: "prep_time"` treats 0/unpublished prep as unknown (sorted last).
* **Pagination** — `offset` skips ranked results; the envelope always carries `total_matches` (pre-pagination count) and `next_offset` when more pages exist. An offset past the end returns an empty page without a `no_results_reason` — reasons describe the match set, not the page.
* **Projection** — `fields` returns only the named fields per restaurant (`id` is always included, and `match_score`/`matched_on` always survive when a query ran; unknown names are ignored). Use it for cheap disambiguation calls before pulling full entries.
* `pos_provider` / `exclude_pos_provider` accept a single provider or an array.
* `collapse_brands: true` returns only the best-ranked location per brand (nearest under distance sort, most relevant under relevance sort), annotated with `brand_location_count`. Independents always pass.
* `limit` defaults to 20, max 50.

Availability annotation — `requested_time` (ISO, offset accepted) annotates every result at that instant; it never filters on its own:

* `hours_known` — false when the store has no stored hours (an empty hours list means "no known closed hours", so open flags stay optimistically true; qualify accordingly).
* `open_at_requested_time` — store hours at the requested instant, in the restaurant's own timezone.
* `next_open_at` — when closed: the ISO instant the store next opens (within 8 days); null when open or hours unknown.
* `orderable_at_requested_time` — open AND inside both ends of validation's time gate: far enough out to clear the exact lead-time floor (prep + busy delay, courier floor for delivery when `fulfillment_type: "delivery"` is set, notice, 15-minute pickup minimum) yet within the scheduling horizon, AND not blocked by a paused kitchen (paused blocks only orders due within 120 minutes — the same window order validation enforces).
* `only_open: true` hard-filters to open stores at `requested_time` — or at the current time when sent alone. `open_now: true` is the same filter evaluated at the current time (its orderable flag skips the lead floor — ASAP orders resolve their own time). Combining `open_now` with `requested_time` is contradictory and rejected with an error. The envelope's `evaluated_time` echoes the instant used.
* `earliest_orderable_time` (always present, may be null) — the soonest ISO instant an order could be scheduled for: now + the exact server lead floor (prep + busy delay, courier floor when `fulfillment_type: "delivery"` is set, notice), pushed past a paused kitchen's 120-minute block, then to the next opening when that instant lands in closed hours. Null when the store has hours but no opening in the coming week, or when the resulting instant lies beyond the platform scheduling horizon. Computed against the same "now" for every result in a response.

Declarative filters, all optional: `cuisine` (normalized substring), `min_rating` (unrated stores fail it — a quality bar), `max_prep_time_minutes` (unpublished prep passes), `supports_catering` (literal boolean match), `max_minimum_order_cents` (no known minimum passes), `exclude_paused`.

Output envelope:

```json theme={null}
{
  "restaurants": [],
  "origin": { "latitude": 37.4933, "longitude": -122.1956, "source": "coordinates | postal_code | address" },
  "evaluated_time": "2026-08-11T19:00:00.000Z",
  "total_matches": 14,
  "next_offset": 20,
  "no_results_reason": "location_required | no_restaurants_in_area | no_match_in_area | all_closed_at_requested_time",
  "brand_matches": [
    {
      "brand_id": "brand_pp",
      "name": "Pizza Palace",
      "location_count": 12,
      "cities": ["Redwood City, CA", "San Jose, CA"],
      "cities_truncated": true,
      "match_score": 100
    }
  ]
}
```

* `origin` is present whenever a search origin resolved; `source` says how.
* `no_results_reason` is present only when `restaurants` is empty, so agents can recover instead of guessing: `location_required` — ask for a location; `no_restaurants_in_area` — the area has no stores at all; `no_match_in_area` — the area has stores but query/fulfillment/declarative filters removed everything (widen the query); `all_closed_at_requested_time` — matches existed but `only_open`/`open_now` removed them all (offer a later time; each removed store's `next_open_at` would have said when).
* `evaluated_time` is present whenever availability was evaluated (`requested_time` or `open_now`).
* `brand_matches` (only on `location_required` with a `query`) answers "do you carry X anywhere": brand-level summaries with cities, deliberately without restaurant objects — without an origin any location list is arbitrary, and the agent should ask "which area?" before ordering. Independents are summarized the same way (`brand_id: null`, `location_count: 1`). Cities cap at 10 (`cities_truncated`), matches at 10.

Restaurant entries carry:

```json theme={null}
{
  "id": "rest_lb_steakhouse",
  "name": "LB Steakhouse",
  "brand_id": "brand_lb",
  "brand": "LB Steakhouse",
  "brand_location_count": 3,
  "location": "Redwood City, CA",
  "address": "1 Main St, Redwood City CA 94063",
  "address1": "1 Main St",
  "city": "Redwood City",
  "state": "CA",
  "postalCode": "94063",
  "latitude": 37.4852,
  "longitude": -122.2364,
  "distanceMiles": 1.8,
  "timezone": "America/Los_Angeles",
  "image_url": null,
  "imageUrl": null,
  "cuisine_type": "steakhouse",
  "cuisine": "steakhouse",
  "description": null,
  "rating": 4.6,
  "delivery_fee": null,
  "minimum_order": null,
  "max_order_dollar_amount": 500,
  "max_order_cents": 50000,
  "max_item_quantity": 50,
  "max_headcount": 40,
  "supports_catering": true,
  "selfDeliversEnabled": false,
  "prepTimeMinutes": 20,
  "minimumLeadTimeMinutes": 0,
  "deliveryMinLeadTimeMinutes": 95,
  "deliveryCourier": "deliverthat",
  "busyState": null,
  "openingHours": [{ "day": "mon", "startTime": "11:00", "endTime": "21:00" }],
  "posProvider": "deliverect",
  "fulfillmentTypesSupported": ["pickup", "catering"],
  "deliverThatCoverage": "supported | unsupported | unknown",
  "defaultApprovalMode": "threshold_review",
  "agentOrderingEnabled": true,
  "posConnectionStatus": "sandbox",
  "permissionStatus": "allowed",
  "agent": { "id": "agent_phantom", "slug": "phantom", "name": "Phantom", "partnerId": null, "partner": null }
}
```

Plus always `earliest_orderable_time`; when `query` was given: `match_score` and `matched_on`; when availability was evaluated: `hours_known`, `open_at_requested_time`, `next_open_at`, `orderable_at_requested_time`. Snake\_case and camelCase duplicates (`image_url`/`imageUrl`, `cuisine_type`/`cuisine`) are deliberate back-compat aliases.

`permissionStatus` is **deprecated**: agent approval is now platform-wide (MealOps approves an agent once, for all restaurants), so the field is always `"allowed"`. It is kept for one release for client compatibility and will be removed — do not branch on it.

A restaurant is omitted entirely when agent ordering is off, the restaurant is not payout-ready, or its POS channel has been deactivated (re-activation restores it automatically).

`deliverThatCoverage` is a cached advisory probe — the checkout quote (or `check_delivery_availability`) is authoritative.

## search\_menu\_items

Cross-restaurant dish search: "who has pad thai near me", "vegan options in 94063". Finds menu items matching a query across every restaurant in a location scope, relevance-ranked, each with its restaurant attached. MCP-only (no REST equivalent).

Input:

```json theme={null}
{
  "query": "pad thai",
  "restaurant_id": "rest_thai_spot",
  "address": "1 Main St, Redwood City, CA 94063",
  "postal_code": "94063",
  "city": "Redwood City",
  "state": "CA",
  "latitude": 37.4852,
  "longitude": -122.2364,
  "radius_miles": 10,
  "fulfillment_type": "pickup",
  "requested_time": "2026-08-11T19:00:00.000Z",
  "max_price_cents": 2000,
  "per_restaurant_limit": 3,
  "limit": 20,
  "offset": 0
}
```

* `query` is required. Location follows the same contract as `search_restaurants`; alternatively `restaurant_id` stands in for a location ("does this store have X") and ignores the location fields — `fulfillment_type` is still honored on that path (a delivery search against a pickup-only store returns nothing).
* **Rate limit**: 60 calls/min per agent, separate from and stricter than the per-agent MCP quota — each call can scan up to 25 menus. Exceeding it throws a tool error naming the limit; retry after the window resets.
* Matching: item **name** gets the full normalized/token ladder; dietary/allergen **tags** ("Vegan", "Gluten Free"), **category**, and **description** are substring-only at reduced weight. Each item carries `match_score` and `matched_on` (`name | tags | category | description`).
* Only currently-available items are returned. `requested_time` additionally annotates `available_at_requested_time` from the item's own availability windows (menu/category hours) in the restaurant's timezone, and puts `open_at_requested_time` on the item's `restaurant` block — an item can be inside its own windows while the store itself is closed; check both.
* `per_restaurant_limit` (default 3, max 20) keeps one giant menu from flooding results; `limit` caps the total (default 20, max 50); `max_price_cents` filters on the item price.
* Scans the nearest **25** stores in scope; `restaurants_scanned` / `restaurants_truncated` report the actual coverage. A store whose menu fails to load is skipped, never fatal.
* Sort: `match_score` desc, then restaurant distance, then price, then name.

Output:

```json theme={null}
{
  "items": [
    {
      "item_id": "item_pad_thai",
      "name": "Pad Thai",
      "description": "Rice noodles, tamarind, peanuts.",
      "category": "Noodles",
      "menu_id": "menu_dinner",
      "menu_name": "Dinner",
      "price_cents": 1595,
      "image_url": null,
      "calories": 720,
      "product_tags": [{ "code": null, "label": "Peanuts", "group": "allergen" }],
      "available_at_requested_time": true,
      "match_score": 100,
      "matched_on": "name",
      "restaurant": {
        "id": "rest_thai_spot",
        "name": "Thai Spot",
        "address": "1 Main St, Redwood City CA 94063",
        "distance_miles": 1.2,
        "rating": 4.5,
        "cuisine": "thai",
        "timezone": "America/Los_Angeles"
      }
    }
  ],
  "origin": { "latitude": 37.4933, "longitude": -122.1956, "source": "postal_code" },
  "evaluated_time": "2026-08-11T19:00:00.000Z",
  "no_results_reason": "location_required | no_restaurants_in_area | no_matching_items",
  "restaurants_scanned": 12,
  "restaurants_truncated": false,
  "total_matches": 34,
  "next_offset": 20
}
```

Pagination works exactly as on `search_restaurants` (`offset` + `total_matches`/`next_offset`). Note `total_matches` counts **returnable** items — matches surviving `per_restaurant_limit` — not every matching item on every menu.

Follow up with `get_menu` on the chosen restaurant for modifier groups before building an order — item results carry no modifier data.

## get\_availability\_calendar

The concrete time windows an agent may schedule an order inside, per restaurant-local day: opening hours intersected with the earliest orderable instant (lead floor + busy state) and the platform scheduling horizon. Continuous windows, not slots — any instant inside them passes time validation (per-item availability may still apply). Replaces guess-and-check `validate_order` loops when picking a fulfillment time.

Input:

```json theme={null}
{
  "restaurant_id": "rest_lb_steakhouse",
  "fulfillment_type": "pickup",
  "days": 3
}
```

`days` defaults to the platform's scheduling horizon and is capped by it (default 7 days; a platform configured with no horizon cap lays out at most 14 days). `fulfillment_type` picks which lead floor applies (delivery adds the courier floor); defaults to pickup. The calendar refreshes opening hours from the POS on the same path as `get_restaurant_detail`, so both surfaces see equally fresh hours.

Output:

```json theme={null}
{
  "restaurant_id": "rest_lb_steakhouse",
  "timezone": "America/Los_Angeles",
  "fulfillment_type": "pickup",
  "hours_known": true,
  "earliest_orderable_time": "2026-08-07T18:30:00.000Z",
  "horizon_days": 7,
  "horizon_end": "2026-08-14T17:00:00.000Z",
  "days": [
    {
      "date": "2026-08-07",
      "windows": [{ "opens_at": "2026-08-07T18:30:00.000Z", "closes_at": "2026-08-08T04:00:00.000Z" }]
    }
  ]
}
```

* The first window opens at `earliest_orderable_time`, never earlier.
* `hours_known: false` (no stored hours) yields one open-ended window from the earliest orderable instant to the horizon.
* Empty `days` with hours known means no opening inside the horizon.
* Overnight service windows (e.g. Fri 22:00–02:00) appear in full under the local date they **open** on.

## list\_brand\_locations

Expand a chain: every location of a brand this agent can order from. The follow-up to `collapse_brands` ("3 other locations") and to `brand_matches` summaries from a location-less name query.

Input — one of:

```json theme={null}
{ "brand_id": "brand_pp" }
```

```json theme={null}
{ "brand_name": "pizza palace" }
```

`brand_id` is exact; `brand_name` is relevance-matched (best 5 brands, independents included as single-location "brands" with `brand_id: null`).

Output:

```json theme={null}
{
  "brands": [
    {
      "brand_id": "brand_pp",
      "name": "Pizza Palace",
      "location_count": 2,
      "locations": [
        {
          "id": "rest_pp_sv",
          "name": "Pizza Palace - Sunnyvale",
          "address": "1325 Sunnyvale Saratoga Rd, Sunnyvale CA 94087",
          "city": "Sunnyvale",
          "state": "CA",
          "latitude": 37.3385,
          "longitude": -122.0322,
          "timezone": "America/Los_Angeles",
          "fulfillmentTypesSupported": ["pickup", "delivery"]
        }
      ]
    }
  ]
}
```

Unknown brands return `{ "brands": [] }`. Location entries are deliberately trimmed — follow up with `get_restaurant_detail` for hours, timing, and busy state.
