Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay plugin.
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
High
Do not use without reviewing
Documents the on-disk state files the drive-engine skill reads and writes. Per coding-policy: stateful-artifacts.
Each on-disk file has one owner module that owns its schema (only it migrates schema_version):
skip-state.json — owned by skip_state.py. Writer and reader are co-bundled and go through the owner API: the skip action (skip_drive.py) writes via add_skip, the sweep (reconcile_sweep.py) reads via load_active_skips. No skill rewrites the file directly.airport-facts.json — owned by airport_facts_cache.py. The sweep (reconcile_sweep.py) both reads (load_static_facts) and writes (store_static_facts) through the owner API (#211).skip_state.py came from the retired drive-planner (#156), whose bundle was folded into drive-engine once drive-engine was its only importer (#181).
/workspace/state/drive-planner/DRIVE_PLANNER_STATE_DIR environment variableBoth files share this directory (airport_facts_cache.py reuses skip_state.state_dir as the single source of truth). The drive-planner name is deployed state, not a live reference — the store predates the #181 fold and renaming it would strand the skips already on disk. Rename only behind a migration.
skip-state.jsonThe user's "skip this meeting" decisions, with per-skip expiry. Owned by skip_state.py.
{
"schema_version": 1,
"skips": {
"evt_42": "2026-07-01T17:00:00-05:00"
}
}Fields:
schema_version (int, required) — currently 1skips (object, required) — map of meeting_id → ISO-8601 expiry timestamp (tz-aware). The skip is active while its expiry is strictly after now; once expired it is dropped on the next read/prune and the meeting re-enters needs_decision.Writer / reader contract:
skip_drive.py. It resolves the meeting by summary, deletes its drive blocks, and calls add_skip(meeting_id, expires=, now=) with the expiry derived from the latest matched block anchor (meeting end) plus a pad, so the skip lapses once the meeting is past. clear_skip(meeting_id, now=) undoes a skip; prune(now) reclaims disk. All three go through the owner (skip_state.py) API.reconcile_sweep.py) calls load_active_skips(now) and passes the result to scan(skip_state=...). scan.py consumes the returned {meeting_id: expiry} mapping; it never touches the file.Tolerance:
schema_version, or a schema_version below the current floor) raises SkipStateError rather than being silently treated as "no skips" — silently resetting would resurrect every skipped meeting as a nag.schema_version newer than this plugin is refused with SkipStateError on both paths — read (load_active_skips) and write (add_skip / clear_skip / prune). The fix is to upgrade the plugin to accept the new version.
stateful-artifacts' no-prior-state branch. An empty skip map is not inert: it drops every active skip, so the sweep re-plans each meeting the operator declined and pings them about it — the "escalates work" a no-prior-state fallback is forbidden to become, and precisely the lombot #49 nag this file exists to prevent. Raising surfaces at reconcile_sweep.main's fail-closed boundary as a clean no-wake skip: the same whole-cycle skip any sweep error takes. No partial plan, no nag. The cost is explicit — while the file is future-versioned, no drive blocks are planned at all.coding-policy: stateful-artifacts, Cross-Pipeline Schema Bumps).Migration:
schema_version 1 is the initial version; no migration exists yet. A future shape change bumps the version and adds the owner-side upgrade-on-read per coding-policy: stateful-artifacts. A version below the current floor has no migration path (v1 is first) and is refused; a version above is refused on both paths until the plugin is upgraded to accept it (see Tolerance — this artifact fails closed rather than reading a newer file as no-usable-prior-state, #184).airport-facts.jsonThe cross-sweep cache of static airport facts — IATA code, country flag, IANA timezone — keyed by byAir airport_id. Owned by airport_facts_cache.py. Introduced in #211 to stop the sweep re-fetching immutable facts from byAir every ~30-min cycle (~7.6s at 13 airports, the dominant plan-phase cost that froze the calendar).
{
"schema_version": 1,
"airports": {
"3": {"iata": "JFK", "flag": "🇺🇸", "tz": "America/New_York"}
}
}Fields:
schema_version (int, required) — currently 1airports (object, required) — map of airport_id (string key) → {iata, flag, tz}. iata is always a non-empty string (a None-IATA resolution is a transient miss, never cached); flag / tz may be null.Writer / reader contract:
reconcile_sweep.py) both reads (load_static_facts) and writes (store_static_facts) through the owner API. It writes only when a sweep learned a fresh fact (a first-seen airport, or a changed one). No other skill touches the file.delay.index congestion nudge is not cached here — it changes through the day, so the sweep fetches it live and only for near-term departures (reconcile_sweep._near_term_departure_airport_ids).Tolerance — hint, not authority (the deliberate opposite of skip-state.json):
iata, non-object value, non-integer key) are dropped; a well-formed remainder is still returned.Migration: schema_version 1 is the initial version. Because a future version reads as no-usable-prior-state (refetch, non-disruptive), a newer writer's file survives untouched until this reader is upgraded — no fail-closed refusal is needed (coding-policy: stateful-artifacts, Cross-Pipeline Schema Bumps). The refetch is non-disruptive even when byAir is also down: a cache-miss airport that byAir can't resolve makes reconcile_sweep._resolve_one_airport raise AirportUnresolved, failing the whole sweep closed rather than building a partial plan that would orphan-delete live blocks (#211). So the empty-map fallback never escalates work — it costs at most one slow (or skipped) sweep, never a wrong or deleted block.
A drive block has no local record — the calendar event itself IS the state (Epic #59 §4). The sweep re-fetches the near-term window by a direct API call and reads each block back off the event. There is no blocks.json; the local state files are skip-state.json and airport-facts.json above.
Every block the engine writes is owned by block_codec.py — marker template, machine-state keys, the generations it recognizes, and its version/tolerance rules all live there as named constants and its module docstring. Per coding-policy: script-as-black-box, this file does not restate them.
Machine state has moved off the human-visible event description into extendedProperties.private, a machine-only field. The description carried it only because the Composio v3 toolkit exposed no writable extendedProperties; the native Calendar API (nanoclaw#638) does, so the constraint is gone. Blocks deployed before the flip still carry their state in the description, so the move is a live-data migration with a transition window, not a field swap.
Rollout order (per coding-policy: stateful-artifacts, Cross-Pipeline Schema Bumps — dual-accept readers ship before the writer flips):
block_codec.parse_block reads extendedProperties.private FIRST and the description SECOND — a block written either way round-trips. fetch_events carries extendedProperties through its field projection so the reader receives it. Shipped as its own release and materialized in production before the writer flip, so no container ever runs the new writer against an old reader.calendar_apply writes build_extended_properties on create and patch, and the description now carries only the operator-facing route line (origin → destination) — the marker + <!--dengine:--> comment no longer squat there. No recognizer needed changing: meeting_source.exclude_drive_block_events already recognizes a block through parse_block (so it inherited dual-read), and scan.py's marker is the unrelated legacy drive-planner one. A block still carrying description-state from before the flip is read by the fallback and migrated to extendedProperties on its first post-flip shift (build_patch_args replaces the description); one that never shifts ages out of the near-term window.parse_block and the legacy-generation readers that only exist for description-carried state.Extended-properties shape (extendedProperties.private, a flat string→string map; block_codec.build_extended_properties is the source of truth): every key is dengine_-namespaced to stay collision-safe in the shared map, every value is a string. dengine_schema_version (auditable version, spelled out per coding-policy: stateful-artifacts), dengine_leg (leg identity), dengine_kind, dengine_b (baseline seconds), dengine_a (anchor ISO-8601), dengine_we (transfer window end, optional), dengine_o / dengine_d (routed endpoints), dengine_al (comma-joined alert record). A map whose version is missing or not the current one reads as "no unified state here" and the reader falls back to the description, mirroring the description reader's unknown-version handling. UNIFIED_BLOCK_SCHEMA_VERSION (the drive-engine codec's version constant) is unchanged — the state's fields and meaning are identical, only its carrier moves.
Blocks are stamped Tangerine (colorId "6") so they read as visually distinct from meetings and flights (#167). The colour is a write-only presentation attribute, not machine state read back off the event — calendar_apply.py sets it on both create and shift (named constant _DRIVE_BLOCK_COLOR_ID); no reader consults it.
The API fetch / create / patch / delete go through google_calendar_client — the native Calendar REST API, brokered by OneCLI's gateway (nanoclaw#638).
Blocks the retired drive-planner (#156) left on the calendar carry a [drive-planner:meeting=<id>:dir=<dir>] marker and a <!--dp:{...}--> state comment. Nothing writes this shape — the sweep that did is retired and its codec is deleted (#181). Two readers still care, and both read the marker only:
scan.py (_MARKER_RE) buckets the served meeting as has_block, so the engine does not plan a duplicate drive on top of a block that already exists;block_codec.parse_block classifies the event as GEN_LEGACY_DP on the marker plus the presence of the <!--dp:--> comment, so meeting_source.exclude_drive_block_events can keep it in the scan input while dropping the engine's own blocks.The <!--dp:--> payload's keys (v, b, a, o, d, al) are not decoded by anything — block_props.parse_block was their only reader and went with #181. They are inert bytes on deployed events; the comment survives as a recognition signal, not a record.
The engine never converges or deletes these blocks (_MANAGED_LEGACY is empty) — the operator cleans them up. Once none remain on the calendar, both readers above are dead code and can go.
.tessl-plugin
skills
check-travel-bookings
drive-engine
flight-assist
references
nightly-travel-sync
sync-tripit
travel-core