Install
openclaw skills install price-winWhen the user asks for hotel deals, hotel prices, or wants to compare hotel prices across booking sites for travel dates, FOLLOW THIS SKILL. It uses the `terminal` tool EXCLUSIVELY (running `node bin/browse.js ...`) to drive a stealth Patchright daemon. DO NOT use any `browser_*` native tool (browser_navigate / browser_click / browser_type / browser_snapshot / browser_close) — those will be detected and timed out by Booking.com / Agoda. Discovers DOM selectors at runtime so it works across locales without hardcoded selectors.
openclaw skills install price-winThis skill is NOT an async task. There is no background queue. If you do not run the shell commands below using the terminal tool, the user will get nothing. Do not tell the user "I'm waiting for results" before you have actually invoked any tool.
Your VERY FIRST action when this skill is activated must be to call the terminal tool with this exact command (substitute the user's city + dates):
node bin/browse.js opentravel "<city>" <checkInYYYY-MM-DD> <checkOutYYYY-MM-DD> <adults>
That call returns within ~200ms with direct-booking hotels. Then continue with the Booking discovery flow described below.
All commands below assume your shell is already in this skill's folder (the runtime sets CWD before executing terminal commands from a skill context). If you ever see "Cannot find module 'bin/browse.js'", run pwd and cd into the folder that contains this SKILL.md.
RULE 0 — FORBIDDEN TOOLS. Read this twice. This skill drives a long-running Patchright daemon via the terminal tool ONLY. Your runtime exposes several other tools that LOOK convenient but are STRICTLY FORBIDDEN inside this skill:
❌ browser_navigate / browser_open — FORBIDDEN
❌ browser_click — FORBIDDEN
❌ browser_type / browser_fill — FORBIDDEN
❌ browser_snapshot — FORBIDDEN
❌ browser_close — FORBIDDEN
❌ Any other browser_* native tool — FORBIDDEN
Why: those native tools spawn a vanilla Chromium without stealth, so Booking.com and Agoda detect the bot within seconds and the requests just hang until the runtime kills them with "Command timed out after 30/60 seconds". You will burn 5+ minutes on timeouts and the user will get nothing. The Patchright daemon launched via terminal survives bot-detection.
✅ The ONLY allowed way to drive a browser in this skill is:
terminal: node bin/browse.js <subcommand> ...
e.g. node bin/browse.js goto https://..., node bin/browse.js snapshot, node bin/browse.js click <ref>, node bin/browse.js close.
If you catch yourself about to call browser_navigate or any browser_* tool: STOP, then issue the equivalent terminal: node bin/browse.js ... command instead. There are no exceptions, not even for "just a quick test page."
RULE 1 — Detect the user's language from their message, then ALL your replies (including the notice below) MUST be in that language. Vietnamese in → Vietnamese out. English in → English out. Thai in → Thai out. Do not default to one language.
RULE 2 — Send a "first-time discovery may take a while" notice to the user BEFORE running any browser command. Phrase it in the user's language. The intent (translate to user's language):
🔍 Searching hotels for you… The first search on Booking.com in your region takes about 2–4 minutes so I can learn the page layout. Future searches will be much faster (~30 seconds). Hang tight…
🚨 CRITICAL — Sending this notice does NOT end your turn. Immediately in the SAME turn (no waiting for the user to reply), continue with the next terminal: node bin/browse.js ... command per the playbook (Step 2 try-extract, then Step 3 Booking discovery, then Step 3.5 Agoda). The user already triggered the search — they expect you to keep working autonomously until you reply with the full Booking + Agoda + OpenTravel results. Do not finish the turn with finish_reason=stop after just the notice. Do not wait for a confirmation. The notice is a courtesy update, not a question.
RULE 3 — Never expose command names, file paths, error stacks, or developer jargon to the user.
❌ Don't say: "snapshot command timed out", "browse.js failed", "skill opentravel-deal-finder is unavailable". ✅ Say (in user's language): "I'm having trouble loading the page, retrying", "Booking is loading slowly".
RULE 4 — Source priority (always in this order):
RULE 7 — Never call snapshot immediately after type if you expect a dropdown to be open. The snapshot is no-scroll by design, but if the agent calls scroll between type and snapshot, autocomplete will close. Order is: type → wait ~2s → snapshot and inspect the result.
RULE 8 — Use keyboard-press <key> (e.g. Escape) to dismiss popups that block other UI. Use scroll <y> only when you need to surface lazy-loaded content (e.g., paginated search results). Never scroll before a snapshot you depend on for dropdown options.
RULE 9 — Always attempt BOTH Booking and Agoda for every search. Booking succeeding does not replace Agoda; the user wants prices from all sources to compare. After Booking finishes (success or fail), continue to Agoda's flow (Step 3.5). Only fall back to OpenTravel-only when BOTH OTAs failed. If one of them succeeds, present its results merged with OpenTravel even if the other is still failing.
RULE 10 — Send a progress update to the user every ~60 seconds. The first-time discovery loop is long. After every 2-3 tool calls (or whenever you're about to start a new site), send a one-line update:
Đang phân tích Booking.com…
Đang chuyển sang Agoda…
Đã có kết quả Agoda, đang tổng hợp…
Keep updates short. Without them, the user thinks the bot is frozen.
RULE 11 — Daemon commands have a 30-second ceiling in this runtime. If snapshot / click / type / goto ever returns "Command timed out after 30 seconds", do NOT retry the same command unchanged. Instead:
current-url to see where the page actually ended up.RULE 12 — VERIFY URL after clicking the search button. Before assuming Booking returned results, call current-url and confirm the URL contains /searchresults. For Agoda, after switch-to-tab-matching, confirm the URL contains /search? and NOT /activities/ or /hotel/. If verification fails:
RULE 13 — Agoda is mandatory. Always attempt it. Even if Booking just succeeded with great data, after replying with Booking + OpenTravel, immediately start Agoda's flow (Step 3.5). The user wants BOTH OTAs every time so they can compare. The only acceptable reason to skip Agoda is "Booking AND Agoda both failed" (then OpenTravel-only). Skipping Agoda just because Booking already gave results is a bug — don't do that.
RULE 5 — Close the browser silently. After you've sent the hotel results to the user, run the close command but DO NOT show its {"status":"closed"} output as a message. It's an internal cleanup step; the user should never see "Browser closed." / "Đã đóng trình duyệt." / equivalent.
RULE 6 — Detect a real captcha block. The chal_t URL parameter alone is just a tracking token; do NOT bail on it. Only bail if the snapshot text shows visible captcha widgets (search for "captcha", "verify you're human", "Cloudflare", "challenge-form"). When you do detect a true captcha, tell the user (in their language) "Booking is requesting verification, here are the direct-booking options I have:" then list the OpenTravel hotels.
This is one shell command, returns instant JSON:
node bin/browse.js opentravel "<city>" <checkInYYYY-MM-DD> <checkOutYYYY-MM-DD> <adults>
Example:
node bin/browse.js opentravel "Mai Chau" 2026-06-05 2026-06-08 2
Output is JSON {"status":"opentravel-ok","hotels":[...],"indicativeHotels":[...]}. Keep these results — you'll merge them with Booking later.
If both Booking AND Agoda already have cached selectors + URL templates, you can fetch everything (OpenTravel + Booking + Agoda) in parallel with one call:
node bin/browse.js multi-extract "<city>" <checkIn> <checkOut> <adults> <locale>
<locale> is REQUIRED. Derive it from the user's message language:
vi-vnen-us (or en-gb for UK English)th-thid-idja-jpThe same <locale> value MUST be used everywhere in this run (Step 2's try-extract, Step 3 / 3.5's save-selectors, and here). Cache is keyed per-locale, and the locale value gets substituted back into the cached URL template at replay time, so passing the wrong locale will either miss the cache or hit Booking/Agoda in the wrong language.
The daemon spawns an isolated browser context per OTA, navigates each cached URL in parallel, and runs the saved selectors. Result returns ~5-8 seconds total instead of ~15-20 seconds sequential.
"status":"multi-extract" with ota.results[] and opentravel.hotels[] — done, format and reply."missing":["agoda"] or similar — Agoda has no cached URL template yet, so fall through to the discovery flow (Step 3 + 3.5) for the missing sites only, then save their templates so the NEXT search hits the fast path.node bin/browse.js try-extract booking <locale> search-cards
Replace <locale> with the Booking locale matching the user's language (vi-vn for Vietnamese, en-gb for English, th-th for Thai, etc.).
{"status":"cache-hit", ...} — you have results, skip to Step 5.{"status":"cache-miss"} or {"status":"cache-stale"} — continue to Step 3 (discovery).{"error":"No daemon running ..."} — go to Step 3a first.Send the RULE 2 notice to the user NOW, before any browser commands. Then:
3a. Launch the browser daemon. Returns immediately after the daemon is listening on a local port:
node bin/browse.js launch
3b. Navigate to Booking homepage in the user's locale:
node bin/browse.js goto "https://www.booking.com/index.<locale>.html"
(index.vi.html for Vietnamese, index.en-gb.html for English, index.th.html for Thai, etc.)
3c. Take a snapshot. The output is a numbered list of interactive elements:
node bin/browse.js snapshot
Each line looks like [12] input aria-label="Where to?". The number in brackets is a ref you'll use to click/fill the element.
3d. Identify the search input. Look for an input with placeholder/aria-label about destination. Note its ref. Fill the city:
node bin/browse.js fill <ref> "<city>"
3e. Snapshot again. The autocomplete dropdown should have opened — look for list items whose text contains the city name:
node bin/browse.js snapshot
3f. Click the matching autocomplete row (critical step — typing alone doesn't commit the destination):
node bin/browse.js click <suggestion-ref>
3g. SKIP the calendar — submit with default dates. Calendar interaction is brittle (promo hotel links inside the calendar steal clicks). Instead, just click the "Search" button without touching dates. Booking will navigate to /searchresults with today's default dates. The URL that comes back contains the city's stable dest_id parameter — that's the only thing you actually need to discover. Subsequent searches use the URL template (with {checkIn}/{checkOut} placeholders) to request any user-supplied dates without UI interaction.
3h. Find the "Search" button (its text is localized — "Tìm" in Vietnamese, "Search" in English, etc.). Click it:
node bin/browse.js click <search-button-ref>
3i. Verify the URL changed to a results page:
node bin/browse.js current-url
URL should now contain searchresults.
3j. SCROLL FIRST. Booking lazy-loads prices — the first paint shows hotel cards with name+link but the price element is still empty (or absent) until you scroll. Always scroll before trying selectors:
node bin/browse.js scroll 4000
Then snapshot the results page and find the repeating hotel cards. Identify selectors for: card container, name, price, link. Prefer data-testid attributes over class names.
3k. Dry-run your selectors:
node bin/browse.js try-selectors '{"card":"[data-testid=...]","name":"[data-testid=...]","price":"[data-testid=...]","link":"[data-testid=...]"}'
The output shows {stats:{total,withPrice,withName,withLink}, sample:[...]}. Sanity check:
withPrice / total should be ≥ 70%. If withPrice = 0 despite withName ≈ total, the price selector is wrong (very common — Booking rotates price selectors).[data-testid="price-and-discounted-price"] (current standard)[data-testid="availability-rate-text"][data-testid="price-text"]span[aria-label*="price" i]/\d{1,3}[.,]\d{3}/ (number with thousands separator) — read the entry's data-testid attribute and use that as the price selector.withPrice=0, scroll 8000 then re-snapshot — sometimes Booking loads prices via second-paint after a longer scroll.withPrice/total ≥ 70%) proceed to save-selectors.3l. Save the working selectors so future searches skip discovery:
node bin/browse.js save-selectors booking <locale> search-cards '{"card":"...","name":"...","price":"...","link":"..."}'
3m. Full extraction:
node bin/browse.js try-extract booking <locale> search-cards
3n. Close the browser (silent — do NOT show the {"status":"closed"} output to the user):
node bin/browse.js close
Agoda's homepage at https://www.agoda.com/<locale>/ defaults to a non-hotel tab depending on the user's history, so always click the "Khách sạn" / "Hotels" tab first.
3.5a. Try Agoda cache first:
node bin/browse.js try-extract agoda <locale> search-cards
If cache-hit, skip to merge. Otherwise:
3.5b. Goto Agoda homepage:
node bin/browse.js goto "https://www.agoda.com/<locale>/"
3.5c. Snapshot, find the "Khách sạn" / "Hotels" tab (look for data-selenium="allRoomsTab"), click it.
3.5d. Re-snapshot. Find the destination input (data-selenium="textInput", placeholder mentions destination). Use type (not fill) so Agoda's autocomplete fires on real keystrokes:
node bin/browse.js type <input-ref> "<city>"
3.5e. Wait ~2 seconds, then snapshot. Agoda will surface autocomplete options with data-testid="autosuggest-item". Click the FIRST option whose text matches the user's city + country (e.g. "Mai Châu (Hòa Bình), Việt Nam · Thành phố"). Avoid hotel-specific entries unless the user named a specific property.
3.5f. Clicking the suggestion opens the calendar popup. Snapshot — you'll see month navigation buttons (data-selenium="calendar-next-month-button" and calendar-previous-month-button) and day cells with aria-label like "Fri Jun 05 2026". Click the next-month arrow until the requested month is in view, then click the check-in day, then the check-out day.
3.5g. SKIP calendar interaction. Same trick as Booking — clicking inside Agoda's calendar can hit a hotel promo banner instead of a date cell. After the city suggestion click commits the destination, press keyboard-press Escape to close any auto-opened calendar, then click the "TÌM" button directly. Agoda will navigate to a /search?city=<cityId>&checkIn=<today>&checkOut=<tomorrow>... URL. That URL contains the city's stable cityId; the URL template will replace {checkIn}/{checkOut} with the user's dates on future searches.
3.5g-bis. CRITICAL — Agoda may open results in a NEW TAB. Right after clicking TÌM the browser can be in either of two states:
agoda.com/<locale>/search?...)./activities/search?cityId=... (wrong tab — that's the activities/airport-transfer page) AND a second tab opened with /search?...city=<cityId> (the real hotel results).Handle both cases with one command:
node bin/browse.js switch-to-tab-matching "/search?" "/activities/"
That switches focus to the first tab whose URL contains /search? AND does NOT contain /activities/. Verify with current-url — it must contain /search? and city=<id>.
Then clean up the stray activities tab so subsequent searches don't reuse it:
node bin/browse.js close-tabs-matching "/activities/"
Do NOT close tabs by index — close-by-URL is safer because the active results tab is never closed (the command skips the currently focused tab).
3.5h. On the results page, scroll to surface lazy-loaded cards (the first paint usually shows only the first ~3 entries):
node bin/browse.js scroll 5000
Snapshot, then identify the card pattern. Agoda's stable selectors at the time of writing are li[data-selenium="hotel-item"] for cards, [data-selenium="hotel-name"] for the name link, [data-element-name="final-price"] for the price.
3.5i. try-selectors to dry-run, save-selectors agoda <locale> search-cards, then try-extract for the final extraction.
3.5j. Close the browser (silent — RULE 5).
Show the user a brief, friendly message in their language ("Booking is slow right now, here are the direct-booking results I have") and return Step 1's records.
Combine OpenTravel hotels + Booking hotels. Sort by price ascending. Format (translate the field labels into the user's language):
🏨 <name> (<city>, <stars>★)
💰 <price>/night — <Direct booking | Booking.com>
<if hotel has both sources:>
🏷 Direct: <direct price>/night — <directLink>
(saves <X> vs Booking)
<end>
🔗 <link>
Show 10–12 hotels (or all of them if fewer found). Always keep at least one direct-bookable entry visible at the top of the list. Do NOT trim to just 3–5 — users want a real spread of price options to compare.
Translate these into the user's language before sending:
| Internal issue | What to tell the user |
|---|---|
| Snapshot timed out | "The page is loading slowly, let me retry." |
| No autocomplete after fill | "Booking couldn't find this location. Want to try a different name?" |
| Zero cards on result page | "Booking isn't showing results for this area today. Here are the direct-booking options I have:" |
| Network error / browser crash | "I lost the Booking connection. Sending you the direct-booking data I already have:" |
Before kicking off the Booking flow, confirm with the user if they didn't explicitly ask for OTA comparison. Scraping Booking at scale may violate their Terms of Service and can trigger CAPTCHAs.