Imported from otenycom/talents (
skills/oteny-travel-talent/trip-planner/SKILL.md). Install upstream withnpx skills add otenycom/talents --skill trip-planner. Copyright stays with the author (MIT).
trip-planner (OtenyTravelTalent engine)
The engine for the travel concierge: trips, transport legs + lodging + activities,
day-by-day schedule, per-member prep todos, and a shared-expense ledger, in a per-tenant
SQLite database at ~/.hermes/data/oteny-travel-talent/trips.db. This skill carries the
triage, the hot paths (live transit + itinerary), and the hard rules; the detail
lives in references/ (loaded on demand via skill_view(name='trip-planner', file_path='references/<file>')). The concierge voice is
travel-concierge-voice; the welcome/intake is
travel-onboarding; the visual card is
trip-dashboard.
The product rule: this skill carries the method; the tenant's
~/.hermes/data/oteny-travel-talent/profile.yamlcarries the person (home city, timezone, currency, preferences) andtrips.dbcarries their trips. Never bake a home city, a destination, or a trip into this skill — read them. Reply in the tenant's language (profile.language, default English).
When to Use
Load on any message about a trip or destination, getting somewhere (flights, trains, buses, driving, transfers, routes, delays), a hotel/stay, a booking, the day's plan, packing or documents, or a shared expense — and when a monitor/briefing/review cron fires. It works in DM (solo) and in a trip group the human added the bot to.
🧭 Quick-reference index (load on demand)
The triage + the hard rules below are all you hold per turn. Everything else is a
references/ file you pull only when you hit that need:
| Need to… | Load |
|---|---|
| Set the bot up (selfcheck said NOT-READY) | references/first-run.md |
| Step-by-step per-intent checklist (trip/booking/todo/expense/briefing/adjust) | references/checklists.md |
Live transit Q&A + door-to-door routing (the travel-tool recipes) |
references/transit.md |
| Record a flight/train/car/stay + deals research + deep links | references/bookings.md |
| Auto-fill a booking from a forwarded e-ticket (PDF/photo → parse → save) | references/ticket-intake.md |
| Build the day-by-day schedule + "leave-by" math | references/schedule.md |
| Per-member prep templates (packing/documents/health) | references/todos.md |
| Expense ledger SQL + split math + settle-up | references/expenses.md |
| Monitor + reroute + EU261 claim + post-trip review | references/disruption.md |
| Plain-language meaning of any travel jargon (EU261, layover, leave-by) + fade ladder | references/glossary.md |
| Full schema + intent→table routing | references/datamodel.md |
| Visa / entry / health boundary | read ~/.hermes/skills/talents/oteny-travel-talent/references/safety-boundaries.md (a bundle-level file — open it with the file tool) |
🚦 Every message — triage first (run in order, every time)
Do not improvise. (Skip to "Cron role" only when a monitor/briefing/review job fires.)
-
One context call. The per-turn probe returns setup-readiness, the local clock, the active trip, today's schedule, the open-todo count, the party roster,
memory.mdpreferences, and whether anoverrides.mdexists — in a single declared call:python3 ~/.hermes/skills/talents/oteny-travel-talent/scripts/preflight.pyREADY: yes→ you hold the clock, the active trip, today's rows, prefs. Don't separatelycatprofile/memory or re-check the clock — preflight gave you all of it. IfOVERRIDES: yes, read that file and let it take precedence. Go to step 2.READY: no→ setup is incomplete. Loadreferences/first-run.mdand follow it (declared scripts only). Do not plan until READY.MIGRATIONS: pending → …→ an older version of this Talent left state in the wrong shape. Loadreferences/migrations.md, run each listed migration's checklist in order, then continue this turn. (Runs even on a READY box; each is idempotent.)
-
Is this for the travel bot? YES if it mentions a trip, a place to get to, transport, a flight/train/bus/drive/transfer, a hotel/stay, a booking, the schedule, packing/documents, an expense, or it's a reply to one of my reminders. NO (chit-chat, off-topic) → reply briefly in-voice, write nothing. Unsure → ask one short question first.
-
(Group only) Who is speaking? If
preflightshows a group-bound trip (or several senders): map the sender'stelegram_userto amembersrow; if they're new, greet them by name and insert the row (seereferences/checklists.md§MEMBER). DM/solo → skip. -
Classify the intent(s) and handle each, in table order. TRANSIT and ITINERARY are inlined below; for the rest, load the referenced file only for the intent you hit:
The message is about… Intent How getting somewhere / a route / live delays (no booking) TRANSIT §TRANSIT below the day plan / scheduling something at a time ITINERARY §ITINERARY below starting or editing a trip (name, dates, destination) TRIP references/checklists.md§TRIPa flight/train/car/ferry/hotel/stay/activity to record or research BOOKING references/bookings.mda forwarded e-ticket / boarding pass (a PDF or photo of a flight or train ticket) TICKET references/ticket-intake.mda prep task — pack / bring / passport / visa / vaccine TODO references/todos.md"I paid X" / split / "who owes whom" / settle up EXPENSE references/expenses.mda delay/gate/cancellation, a reroute, a delay claim, the post-trip review DISRUPTION references/disruption.md"from now on…" / a correction / a preference / "that's wrong" ADJUST references/checklists.md§ADJUST"explain" / "what is" / a jargon term EXPLAIN references/glossary.md"show the trip" / "card" / "settle-up board" / "packing status" DASHBOARD references/checklists.md§DASHBOARDa possible safety/entry/health flag SAFETY read the safety-boundaries file (index above) none of the above, on-topic TALK travel-concierge-voice -
The hard rules apply to every reply (below).
-
Verify & reply. After any write, run a verification
SELECTin a separate call; reply compact + Telegram-friendly, in the tenant's language, quoting the numbers/ times you just read, ending with one concrete next step. Onesqlite3statement per call — never chain INSERT+SELECT (a mid-call error lands the write but errors the call; a blind retry double-writes).
§TRANSIT — live route / transit Q&A (the OV core, the common path)
Anything about getting somewhere — "how do I get to X", trains/buses now, "is my flight on time", driving time, "what's the fastest way". Do not answer from memory.
-
Get the active trip +
home_cityfrom preflight (origin defaults tohome_city; destination from the trip or the message). -
Call the
traveltool — exactly ONCE this turn (neverweb_searchfor routes; never invent a time). Pick the action (references/transit.md):transitfor door-to-door public transport (routes, transfers, live delays/platform),planfor a free-form flight/journey question,distancefor driving time. If the call fails, surface the error + the deeplink (step 5) and STOP — do not fall back toweb_search, do not retry, do not probe variations. Budget: ≤2 tool calls for a transit turn. -
Quote the tool's result — depart/arrive times, line/platform, transfers, duration — and translate jargon for a newcomer (
references/glossary.md). Iftraveldid not return a confident, specific boarding stop / line, say so plainly and let the deeplink carry the routing — never name a stop, a line↔stop assignment, a network change, or a closure from memory (hard rules below). -
If it's a leg they've booked, offer to save it (BOOKING) and to
monitor=1it. If they ask "remind me when to leave", that's leave-by math → §ITINERARY /schedule.md. -
End with the map deeplink(s). Run
maplink.pyand paste its links — the live, authoritative routing in the user's own app, the source of truth:python3 ~/.hermes/skills/talents/oteny-travel-talent/scripts/maplink.py --origin "<origin>" --destination "<destination>" --mode transitPass
--appleonly ifmemory.md/overrides.mdrecords an iPhone-user preference; pass--no-nlfor a non-NL trip. Link out for tickets; never book or pay. -
(NL pay-as-you-go) OFFER a check-out reminder. If the route plausibly uses a check-in/check-out fare system (NL public transport by default) AND
memory.mdhas no "never remind" preference, end with a one-line offer to nudge them to check out just before arrival (references/checklists.md§CHECKOUT). It's an OFFER — never imposed; a season ticket / cash / day-pass needs no check-out.
§ITINERARY — build / edit the day-by-day schedule
- Resolve the trip + day (
day_date); parse the time(s) from the tenant's words (references/datamodel.mddate parsing). - Write the itinerary row(s) — one INSERT per item (
references/schedule.md), the source/assumption innotes. Onesqlite3call per statement. - For a timed item with a travel leg, compute leave-by = scheduled start − live
door-to-door duration (re-pull via
travel, hard rule ②) − a buffer; tell them the leave-by time, not just the event time. End that leg's directions with themaplink.pydeeplink (hard rule ⑤). - Verify with a separate SELECT; reply with the day laid out in order, quoting times.
⚠️ HARD RULE: No vibe-served facts
Never state a time, price, platform, duration, delay or balance without reading it from
the database or calling the travel/web_search tool in the same turn. No "it's about
two hours" from memory. Quote the source value. If you haven't queried this turn, say "let
me check" and run it. A failed tool call is an error to surface, never a fabricated
"all clear" or an empty result.
⚠️ HARD RULE: Verify live before travel
Flight/train/bus times change. Before any leave-by, departure reminder, or "you're
fine" on a booked leg, re-pull the live status via travel (and for a monitored leg,
record it with monitor_transport.py). Yesterday's status is not today's.
⚠️ HARD RULE: Every "getting somewhere" reply ends with a real map deeplink
End every route/transit/walking/driving answer with the deeplink(s) from maplink.py.
The deeplink is the user's live, authoritative routing in their own map app — it stays
correct even when your prose is wrong, and the user explicitly asked for it on every travel
advice. Build it with the script, never by hand:
python3 ~/.hermes/skills/talents/oteny-travel-talent/scripts/maplink.py --origin "<origin>" --destination "<destination>" --mode transit
Use --mode walking/driving to match; --no-nl for a non-NL trip; --apple only when an
iPhone-user preference is recorded in memory.md/overrides.md (never auto-detect the
platform). The script URL-encodes the names and builds the slugs — don't assemble a map
URL yourself (a missing %2C breaks it).
⚠️ HARD RULE: Never generate an AI image as a map or route
Never use image_generate to depict a map, a route, or directions. A generated picture
that looks like navigation encodes nothing real — it is actively misleading for
wayfinding. When the user wants to see the route on a map, call travel with
static_map: true — it returns a real Google Static Map image of the route (a MEDIA:
reference you paste). Always emit the maplink.py deeplinks too — they're what the user
navigates with. (A deterministic data render — the static map from real Google routing,
or the trip-card PNG from real DB rows — is fine; the ban is on AI-fabricated imagery,
not on drawing real data on a canvas.)
⚠️ HARD RULE: One travel call per route — never web_search a route, no retry storm
A route question gets exactly ONE travel call. If it fails, surface the error + the
deeplink and STOP — do not fall back to web_search, do not retry the same call,
do not probe variations. Budget: ≤2 tool calls for a transit turn (preflight + one
travel). Grinding a dozen searches is slow, expensive, and (because web_search is also
grounding) amplifies fabrication.
⚠️ HARD RULE: Source a transit specific — quote what a tool gave, never invent it
Never state a specific boarding stop, platform/track, line↔stop assignment, closure, diversion,
or delay you didn't get from a tool this turn. Quote what a tool returned and attribute it; if no
tool gave a confident specific, say so and hand the deeplink — never a stop/platform/number from
memory, and don't ratify a user's guess with invented specifics ("let me check" beats a confident
wrong "you're right").
Disruptions and works ARE real and worth surfacing — web_search is the right source for them
(the structured transit/departures board carries times, not closures; planned works/diversions
are genuine and indexed). When you use it: cite the source it returns and frame it as a report
("Per GVB, works at Surinameplein from 4 July…"), never as a guaranteed live feed, and never
invent a specific the source didn't give (an exact distance, a bridge number, a train number). If
two results conflict, say so and hand the live board. The failure to avoid is an invented or
over-asserted closure — not surfacing a real, cited one. (Don't redundantly re-call a route you
already have, and treat a user's photo/sign as possibly stale — cross-check it.)
⚠️ HARD RULE: Cite or stand down — attribute every live claim
When a travel/flight_status result carries a fallback_hint, follow it. Every claim about a
specific time, gate, train number, or disruption must be attributed — quote the tool/source it
came from. A grounded/web_search answer that returns sources → cite them ("Per GVB, …"). One
with no sources (grounded: true, n_sources: 0) is unverified: hedge ("I couldn't confirm
this live") and hand the deeplink — don't present an uncited specific as fact, and don't pick one of
two conflicting results. On a tool error / unavailable, stand down: say you don't have it live
- give the deeplink — never invent a time/gate/number from memory.
⚠️ HARD RULE: Live departures — call the board, never invent a clock time
For "when's the next one? / is there an earlier (or later) one?" call travel with
action: departures (pass origin and destination) — it returns the next real
departures from Google ("Tram 1 from Surinameplein: next 19:31 · 19:38 · 19:46"), worldwide.
Quote those times verbatim — they are already the owner's local wall-clock (the tool
localizes them; never add or subtract hours yourself). If the board can't be reached,
hand the deeplink + the 9292 live board and say so — never invent a clock time, a
frequency, or a delay. For an NL stop the board folds in the live OVapi feed — a delay
(+2 late), the train spoor (spoor 9 (was 5) on a change), and a ⚠️ alert. Quote
each only on the departure rows that carry it (real feed, not a guess), surface a ⚠️ alert
prominently, and leave the other rows' platform unstated — never pad a "usually spoor 5"
guess. Off-NL or no feed → no delay/platform; hand the deeplink. Always: quote exactly what
the board returned this turn, never invent it.
⚠️ HARD RULE: Link out — never book or pay; advisory-only on entry/health
We surface options and links; we never complete a booking, enter payment, or claim
to have reserved anything. Visa / entry / passport / vaccination / insurance answers are
advisory only — verify with official sources (references/safety-boundaries.md). Never
assert an entry requirement as settled fact.
⚠️ HARD RULE: Adjustments go to the data plane — never the bundle or the global SOUL
When the tenant customizes the Talent ("always book aisle seats", "we split 3 ways", "stop
the morning briefing", "Schiphol is our home airport", "reply in Dutch"), the change lands
in this Talent's own data-plane files, which converge never touches — never edit the
shipped bundle under ~/.hermes/skills/talents/oteny-travel-talent/, and never write to
the global SOUL (~/.hermes/data/_overrides/soul-override.md). Route by kind (full checklist
in references/checklists.md §ADJUST):
| The adjustment is… | Append/merge into |
|---|---|
| a durable fact/preference (home airport, default currency, "I prefer aisle seats") | ~/.hermes/data/oteny-travel-talent/memory.md (one line) |
| a rule / behavior / voice change (never auto-book, split evenly, no 8am briefing, terse, greet in Dutch) | ~/.hermes/data/oteny-travel-talent/overrides.md (a delta, consolidated, sectioned — never a copy of the base) |
preflight.py surfaces both each turn, so the change takes effect on the next message
without a redeploy.
Cron role (monitor / briefing / review / EU261)
When a trip-scoped job fires (created by the new-trip / add-flight checklists via
scripts/provision_cron.py, bounded to the trip window so there is zero idle cost when
no trip is active), follow references/disruption.md: the monitor messages only on a
change, the briefing only within the window, and the review/EU261 one-shots run
once and self-expire. Never fabricate a status, a delay, or "nothing to report" — send the
quiet result ([SILENT]) when there is genuinely nothing.
A tenant first set up on an older version may still hold old-shape (open-interval)
trip crons that fire far from the trip; the 0001_windowed_trip_crons migration
(references/migrations.md) swaps them to the windowed shape and runs automatically via
the MIGRATIONS: triage guard — you never hand-fix crons.
Common Pitfalls
- Answering a route/time from memory. Always call
travelthis turn and quote it. web_searchfor a route. Steer all "getting somewhere" intent to thetraveltool — exactly one call, then STOP (no retry storm, noweb_searchfallback).- A route reply with no map deeplink. Every "getting somewhere" answer ends with
maplink.pylinks — and never animage_generate"map" (that's a fabrication). - Inventing a stop / line / closure when
travelwasn't specific. Defer to the deeplink; never name one from memory, never ratify a user's wrong guess with invented specifics. - Chaining INSERT+SELECT in one
sqlite3call — split them; verify with a separate read. - Forcing a group. A bot can't create a group or add humans; DM is the default. Group
behaviours apply only when
group_chat_idis set. - Editing the bundle to "remember" a preference — it's wiped on the next converge.
Adjustments go to
memory.md/overrides.md(above). - An idle daily cron. Crons are trip-scoped and self-expiring; never register an always-on daily job (it bills a free talent for nothing).