Skip to content

Output schema

Every read command in roost — search, rates, dates — emits the same envelope shape. --format json (or --json) prints it as-is; plain and tsv render the collection as a table and print the envelope’s scope.note to stderr instead. This page describes the JSON shape, since that’s the one an agent should actually parse.

{
"schemaVersion": "1",
"query": {
"location": "Kyoto, Japan",
"checkIn": "2026-10-02",
"checkOut": "2026-10-05",
"nights": 3,
"guests": { "adults": 2, "children": 0 },
"currency": "USD"
},
"scope": {
"backend": "google",
"brandsFilter": [],
"partial": false,
"note": "lead-in rates from Google Hotels metasearch; not a booking guarantee — confirm on the property's own site"
},
"properties": [ /* … */ ]
}

Every envelope has exactly these top-level keys, plus one collection key that varies by command:

keytypepresent on
schemaVersionstringalways — currently "1"
queryobjectalways — the normalized stay you searched for
scopeobjectalways — see below
propertiesarraysearch
ratesobjectrates
datesarraydates (or plan, see --dry-run )

rates and dates also add a few command-specific keys alongside the collection — covered in their own sections below.

The stay roost actually normalized and sent upstream — not necessarily what you typed. --check-out, if given, wins over --nights; --check-in defaults to today.

fieldtypenotes
locationstringas passed on the command line, unmodified
checkInstringYYYY-MM-DD
checkOutstringYYYY-MM-DD
nightsintegerderived from checkIn/checkOut
guests.adultsintegerdefault 2
guests.childrenintegerdefault 0
currencystringISO 4217, uppercased (default USD)

The contract’s guarantee that an agent can never mistake a narrowed result for the whole corpus — read this before trusting a result set.

fieldtypenotes
backendstring"google" or "serpapi"
brandsFilterarray of stringsthe --brand list you passed, split on commas; [] if none
partialbooleantrue whenever the result is narrower than “everything upstream could show”
notestringwhy, when partial is true; otherwise the standard lead-in-price disclaimer

partial goes true for reasons specific to each command:

  • search: client-side filters (--class, --min-rating, --max-price, --free-cancellation, --amenity, --property-type) were applied on the google backend — they narrow the page Google already returned, they don’t search deeper. Also true when --brand was passed on the google backend, because Google’s brand filter ids are only exposed through serpapi; results still span every chain even though you asked to narrow.
  • rates: always true on the google backend. The property page’s full rate matrix arrives over an RPC roost declines to call (outside the robots-respecting allowlist), so rates answers from the search surface instead. --backend serpapi gets the real per-source list and partial comes back false.
  • dates: not set from filters — see truncated below instead, which covers the sweep’s own narrowing.

Each entry is one hotel or vacation rental. Fields common to both backends:

fieldtypenull when
idstring | nullGoogle didn’t expose an entity id for the card (rare)
namestringnever — cards without a name are dropped before you see them
typestringnever — "hotel" or "vacation_rental"
classinteger | nullGoogle didn’t state a star rating for this property
ratingnumber | nullno guest rating shown
reviewsinteger | nullno review count shown (or rating itself is null)
perNightobject | nullGoogle’s card showed no nightly figure
totalobject | nullGoogle’s card showed no total figure
taxesIncludedbooleannever null — false if the card didn’t say “with taxes + fees”
dealobject | nullno “X% less than usual” badge on the card
amenitiesarray of strings[] if none parsed, capped at 12
urlstring | nullno link found on the card

perNight and total are each a money object or null — never both absent unless Google’s card genuinely showed no price at all (such a card wouldn’t be included; search only keeps cards that yielded a name and at least one price).

deal, when present, is {"percentLessThanUsual": 60, "label": "GREAT DEAL"} (label is "DEAL" when the card doesn’t say “GREAT DEAL”).

Backend-specific fields — present only on the backend that can populate them:

fieldbackendtypenotes
nightsgoogleinteger | nullthe stay length Google’s card actually echoed
nightsMismatchgooglebooleanpresent (true) only if nights disagrees with the nights you requested — roost never silently relabels a mismatched stay as the one you asked for
sourcesserpapiarrayper-OTA rate breakdown; [] on google cards is not applicable — the field is absent there entirely
coordinatesserpapiobject | null{lat, lng} from gps_coordinates
freeCancellationserpapitrue | nulltrue if any source offers free cancellation, otherwise null — never explicitly false

rates is a single object, not a list — roost rates looks up one property.

{
"schemaVersion": "1",
"query": { "…": "" },
"scope": { "backend": "google", "brandsFilter": [], "partial": true,
"note": "google backend: per-source rates are best-effort from the search surface; use --backend serpapi for the full source list" },
"rates": {
"property": { "id": "", "name": "Sakura Cross Hotel" },
"sources": [],
"perNight": { "amount": 92, "currency": "USD" },
"total": { "amount": 304, "currency": "USD" },
"taxesIncluded": true
}
}
fieldtypenotes
property.idstring | nullfrom the matched property’s id
property.namestring | nullfrom the matched property’s name
sourcesarrayalways [] on google; the real per-source list on serpapi — each entry is {source, perNight, total, freeCancellation}
perNightobject | nullmoney object
totalobject | nullmoney object
taxesIncludedbooleannever null

If no property matches the id or name you passed, roost exits 5 (not found) instead of emitting an envelope — see exit codes.

dates sweeps check-in dates and returns one row per sampled date, plus three extra top-level keys alongside dates itself:

{
"schemaVersion": "1",
"query": { "…": "" },
"scope": { "backend": "google", "brandsFilter": [], "partial": false, "note": "" },
"dates": [
{ "date": "2026-10-02", "available": true,
"perNight": { "amount": 189, "currency": "USD" },
"total": { "amount": 567, "currency": "USD" },
"currency": "USD", "property": "Sakura Cross Hotel" },
{ "date": "2026-10-03", "available": false,
"perNight": null, "total": null, "currency": "USD" }
],
"cheapest": { "date": "2026-10-02", "available": true,
"perNight": { "amount": 189, "currency": "USD" },
"total": { "amount": 567, "currency": "USD" },
"currency": "USD", "property": "Sakura Cross Hotel" },
"requestsIssued": 8,
"truncated": true
}

Each row in dates:

fieldtypenotes
datestringYYYY-MM-DD, the sampled check-in date
availableboolean | nullfalse = a search for that date returned nothing; null = the sweep stopped early on a block or rate limit before this date could be checked
perNightobject | nullthe cheapest property’s per-night rate for that date, or null
totalobject | nullthe cheapest property’s total for that date, or null
currencystringechoes query.currency
propertystringthe cheapest property’s name for that date — the key itself is absent (not null) on a row where available is false or null, since there’s no property to name
errorstringpresent only on a row where available is null — the exit-code name (BLOCKED or RATE_LIMITED) that cut the sweep short

Top-level keys added alongside dates:

fieldtypenotes
cheapestobject | nullthe single row across the whole sweep with the lowest perNight.amount; null if nothing was available anywhere in the window
requestsIssuedintegerhow many upstream requests this sweep actually made
truncatedbooleantrue if --window/--step implied more sample dates than --max-requests allowed, so the sweep stopped before covering the full window

dates is the only command that issues more than one upstream request per invocation, which is why --max-requests is a hard cap (default 8) and requestsIssued/truncated are always reported — an agent should never have to guess how much network activity a dates call caused.

--dry-run is meaningful only for dates: it prints the request plan and exits 0 without fetching anything. The collection key becomes plan instead of dates, and the extra keys (cheapest, requestsIssued, truncated) are absent — nothing was issued to count.

{
"schemaVersion": "1",
"query": { "…": "" },
"scope": { "backend": "google", "brandsFilter": [], "partial": false,
"note": "dry run: no upstream requests were made" },
"plan": {
"window": 30,
"step": 1,
"maxRequests": 8,
"requestsPlanned": 8,
"sampledDates": ["2026-10-02", "2026-10-03", "2026-10-04", "2026-10-05",
"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"]
}
}
fieldtypenotes
windowintegerthe --window you passed
stepintegerthe --step you passed
maxRequestsintegerthe --max-requests you passed
requestsPlannedintegerthe number of dates the sweep would sample — window divided into step-sized increments, capped at maxRequests
sampledDatesarray of stringsthe check-in dates that would be probed, already capped to requestsPlanned

Every price in roost — perNight, total, and every price nested inside sources — is the same two-field shape:

{ "amount": 214, "currency": "USD" }

amount is a number, currency is an ISO 4217 code. A price is never a bare number and never a pre-formatted string like "$214". perNight and total are always separate fields — Google states them explicitly and separately on the result card, so roost never infers one from the other, and the two are never interchangeable: a per-night rate for a 3-night stay is not the total, and vice versa.

schemaVersion and the append-only guarantee

Section titled “schemaVersion and the append-only guarantee”

schemaVersion is currently "1". Field names inside the envelope are append-only: a future roost release can add new fields, but existing field names and their meaning don’t change or disappear under the same schemaVersion. Code that reads only the fields it recognizes and ignores unknown ones stays correct across roost upgrades. A break severe enough to need a version bump would ship as a new schemaVersion value, not a silent reshape.

This is a different failure mode than a parse break upstream: if Google’s page itself stops matching roost’s parser, that’s exit 21 (SCHEMA_DRIFT) — see exit codes — not a schemaVersion change, because roost’s own output contract hasn’t moved, only its ability to fill it in.

--select and --limit apply to the collection, not the envelope

Section titled “--select and --limit apply to the collection, not the envelope”

Both flags act on the array named by the command’s collection key — properties for search, dates for dates — not on the envelope as a whole. This is deliberate: if they applied to the top-level object, wrapping every result in the scope envelope would silently defeat both projection and bounding, since the envelope itself is a small, fixed-shape object with nothing worth limiting.

Terminal window
roost search "kyoto" --nights 3 --select name,perNight.amount --limit 2 --json
{
"schemaVersion": "1",
"query": { "…": "" },
"scope": { "…": "" },
"properties": [
{ "name": "Sakura Cross Hotel", "perNight.amount": 92 },
{ "name": "Hotel Granvia Kyoto", "perNight.amount": 210 }
]
}

Notes on the mechanics:

  • --select takes comma-separated dot paths. Each path becomes its own key in the projected row — perNight.amount above is a literal key "perNight.amount" in the output, not a nested perNight: {amount: …} object. A path that doesn’t resolve on a given row is dropped from that row rather than emitted as null.
  • --limit (default 50) truncates the collection array to that many entries and prints a note to stderr (note: output truncated to N of M items (use --limit to change)) — it never silently drops rows without saying so. --limit 0 disables truncation.
  • rates has no array collection — its rates key is a single object — so --select/--limit fall back to acting on the whole envelope for that command instead.
  • brands doesn’t emit an envelope at all: it’s a bare array, so --select/--limit apply to it directly.

See roost search and the command reference for the full flag list these interact with.