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.
The envelope
Section titled “The envelope”{ "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:
| key | type | present on |
|---|---|---|
schemaVersion | string | always — currently "1" |
query | object | always — the normalized stay you searched for |
scope | object | always — see below |
properties | array | search |
rates | object | rates |
dates | array | dates (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.
| field | type | notes |
|---|---|---|
location | string | as passed on the command line, unmodified |
checkIn | string | YYYY-MM-DD |
checkOut | string | YYYY-MM-DD |
nights | integer | derived from checkIn/checkOut |
guests.adults | integer | default 2 |
guests.children | integer | default 0 |
currency | string | ISO 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.
| field | type | notes |
|---|---|---|
backend | string | "google" or "serpapi" |
brandsFilter | array of strings | the --brand list you passed, split on commas; [] if none |
partial | boolean | true whenever the result is narrower than “everything upstream could show” |
note | string | why, 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 thegooglebackend — they narrow the page Google already returned, they don’t search deeper. Alsotruewhen--brandwas passed on thegooglebackend, because Google’s brand filter ids are only exposed throughserpapi; results still span every chain even though you asked to narrow.rates: alwaystrueon thegooglebackend. The property page’s full rate matrix arrives over an RPC roost declines to call (outside the robots-respecting allowlist), soratesanswers from the search surface instead.--backend serpapigets the real per-source list andpartialcomes backfalse.dates: not set from filters — seetruncatedbelow instead, which covers the sweep’s own narrowing.
The properties shape (search)
Section titled “The properties shape (search)”Each entry is one hotel or vacation rental. Fields common to both backends:
| field | type | null when |
|---|---|---|
id | string | null | Google didn’t expose an entity id for the card (rare) |
name | string | never — cards without a name are dropped before you see them |
type | string | never — "hotel" or "vacation_rental" |
class | integer | null | Google didn’t state a star rating for this property |
rating | number | null | no guest rating shown |
reviews | integer | null | no review count shown (or rating itself is null) |
perNight | object | null | Google’s card showed no nightly figure |
total | object | null | Google’s card showed no total figure |
taxesIncluded | boolean | never null — false if the card didn’t say “with taxes + fees” |
deal | object | null | no “X% less than usual” badge on the card |
amenities | array of strings | [] if none parsed, capped at 12 |
url | string | null | no 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:
| field | backend | type | notes |
|---|---|---|---|
nights | google | integer | null | the stay length Google’s card actually echoed |
nightsMismatch | google | boolean | present (true) only if nights disagrees with the nights you requested — roost never silently relabels a mismatched stay as the one you asked for |
sources | serpapi | array | per-OTA rate breakdown; [] on google cards is not applicable — the field is absent there entirely |
coordinates | serpapi | object | null | {lat, lng} from gps_coordinates |
freeCancellation | serpapi | true | null | true if any source offers free cancellation, otherwise null — never explicitly false |
The rates shape (rates)
Section titled “The rates shape (rates)”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 }}| field | type | notes |
|---|---|---|
property.id | string | null | from the matched property’s id |
property.name | string | null | from the matched property’s name |
sources | array | always [] on google; the real per-source list on serpapi — each entry is {source, perNight, total, freeCancellation} |
perNight | object | null | money object |
total | object | null | money object |
taxesIncluded | boolean | never null |
If no property matches the id or name you passed, roost exits 5 (not found) instead of
emitting an envelope — see exit codes.
The dates shape (dates)
Section titled “The dates shape (dates)”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:
| field | type | notes |
|---|---|---|
date | string | YYYY-MM-DD, the sampled check-in date |
available | boolean | null | false = a search for that date returned nothing; null = the sweep stopped early on a block or rate limit before this date could be checked |
perNight | object | null | the cheapest property’s per-night rate for that date, or null |
total | object | null | the cheapest property’s total for that date, or null |
currency | string | echoes query.currency |
property | string | the 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 |
error | string | present 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:
| field | type | notes |
|---|---|---|
cheapest | object | null | the single row across the whole sweep with the lowest perNight.amount; null if nothing was available anywhere in the window |
requestsIssued | integer | how many upstream requests this sweep actually made |
truncated | boolean | true 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.
The plan shape (--dry-run)
Section titled “The plan shape (--dry-run)”--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"] }}| field | type | notes |
|---|---|---|
window | integer | the --window you passed |
step | integer | the --step you passed |
maxRequests | integer | the --max-requests you passed |
requestsPlanned | integer | the number of dates the sweep would sample — window divided into step-sized increments, capped at maxRequests |
sampledDates | array of strings | the check-in dates that would be probed, already capped to requestsPlanned |
The money object
Section titled “The money object”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.
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:
--selecttakes comma-separated dot paths. Each path becomes its own key in the projected row —perNight.amountabove is a literal key"perNight.amount"in the output, not a nestedperNight: {amount: …}object. A path that doesn’t resolve on a given row is dropped from that row rather than emitted asnull.--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 0disables truncation.rateshas no array collection — itsrateskey is a single object — so--select/--limitfall back to acting on the whole envelope for that command instead.brandsdoesn’t emit an envelope at all: it’s a bare array, so--select/--limitapply to it directly.
See roost search and the command reference for
the full flag list these interact with.