google-maps: Saved Lists (primary) and My Maps (fallback)
Project-agnostic workflow for any pin collection. Default = Saved Lists. Use My Maps only when custom lat/lng pins or your own layer/color structure is required.
Research rule (hard)
- Place data (phone, hours, specials) comes from web research: the agent's built-in web fetch/search tools or a browser MCP. Never from Playwright.
- Playwright is exclusively for Maps UI interaction. Using it for research is slow, fragile, triggers bot detection, and produces false confidence.
- For every piece of place data: name the source and ask the user to verify anything critical.
1. Which tool when?
| Use case | Tool |
|---|---|
| Share a list via messenger, toggle lists on/off in the Maps app | Saved Lists |
| Reviews/photos/hours visible directly in the pin | Saved Lists |
| Custom lat/lng pin (holiday rental, GPS point without a place entry) | My Maps |
| Own layer structure plus color coding per category | My Maps |
| Bulk import of 30+ places from CSV | My Maps |
| Hybrid (keep and share) | Both in parallel |
Toggle UX (the Saved Lists killer feature): the Maps app can switch each list on and off. Four themed lists (restaurants / sights / day trips / emergency) beat one mega list with 36 pins; the user activates only what they need right now.
2. Setup (one time)
Chrome 136+ refuses remote debugging on the default profile. A dedicated profile plus a one-time sign-in is required.
# Windows: starts (or reuses) a CDP Chrome with its own profile on port 9223
./scripts/launch-chrome-cdp.ps1 # optional: -Port 9224 -ProfileDir <path>
# Linux equivalent
google-chrome --remote-debugging-port=9223 --user-data-dir="$HOME/.chrome-cdp-maps" &
# macOS equivalent (there is no google-chrome on the PATH, call the binary directly)
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9223 --user-data-dir="$HOME/.chrome-cdp-maps" &
First run: sign in to the Google account whose Maps you want to manage. The profile persists, so every later launch is already signed in. Verify: http://127.0.0.1:9223/json/version returns 200.
Port convention: this skill defaults to 9223, not 9222, because 9222 is the de-facto default of other CDP tooling (debuggers, QA browsers) and attaching to the wrong Chrome is a confusing failure (consent page instead of Maps, no login, account check timeout). Any free port works via --port; the Windows launcher warns when the port is already serving a different CDP session. Never kill a CDP Chrome you did not start.
UI language: all selectors quote the Google Maps UI in English ("New list", "Save", "Choose icon", the "Note" textarea). Set the account language to English or adapt the quoted strings.
Account safety: a CDP profile can hold several Google accounts. Pass --account you@example.com to the engine (or set MAPS_ACCOUNT) and it hard-fails on a mismatch before writing anything.
3. Saved Lists workflow
3.0 Build engine (mandatory for every multi-place build)
For any build with several lists/places use scripts/maps_lists.py (in this skill folder). Never write an ad-hoc script instead: the engine structurally absorbs every pitfall in section 6 (phantom save panel, first-save sync lag, ordering including auto-toggle repair, notes only after final order, save verify):
python3 ~/.claude/skills/google-maps/scripts/maps_lists.py build data.json # full build + auto verify
python3 ... verify data.json # check only (hard reload, counts, order, notes)
python3 ... fix-order data.json --list N # order repair + notes re-set
data.json: [{"list": "🍜 Kyoto · Restaurants", "emoji": "🍜", "places": [{"q": "<search query>", "anchor": "<phone or street+number>", "header": "<exact Maps header>", "note": "..."}]}] with places in rank order (#1 first); the engine reverses the save order itself. Exit code 0 means everything verified. When the UI breaks: fix the engine and add the finding to section 6, never build a throwaway script next to it. Sections 3a to 3g below document the underlying single-step mechanics (for probes, one-off cases, engine maintenance). A full schema example ships in examples/data.example.json at the repo root.
3a. Connect
from playwright.sync_api import sync_playwright
p = sync_playwright().start()
b = p.chromium.connect_over_cdp("http://localhost:9223") # 9223! 9222 usually belongs to other tooling (section 2)
ctx = b.contexts[0]
target = next((pg for pg in ctx.pages if "google.com/maps" in pg.url), None) or ctx.new_page()
if "google.com/maps" not in target.url:
target.goto("https://www.google.com/maps", wait_until="domcontentloaded") # NEVER networkidle (section 6)
target.bring_to_front()
target.wait_for_timeout(4000)
# Account check, mandatory before any write (the CDP profile can hold SEVERAL accounts):
acct = target.locator('a[aria-label*="Google Account"]').first.get_attribute("aria-label") or ""
assert EXPECTED_ACCOUNT in acct, f"WRONG ACCOUNT: {acct} -> stop, ask the user"
# Open the Saved overview (UI since 2026-07: nav rail; an overlay span eats normal clicks -> force):
def open_saved(t):
t.locator('button[jsaction="navigationrail.saved"]').first.click(force=True)
t.wait_for_timeout(2500)
3b. Create a list (inline-rename pattern)
open_saved(target) # from 3a
# Idempotency is mandatory: does the name already exist? Then reuse, NEVER create a duplicate.
# (The save menu matches lists by text; two same-named lists scatter places uncontrollably.)
if LIST_NAME in target.locator('[role="main"]').first.inner_text():
print("list exists, reusing"); return
target.get_by_role("button", name="New list").click()
# Jumps into the Untitled detail view
target.locator('h1').click() # the H1 becomes an editable INPUT
target.keyboard.press("Control+A")
target.keyboard.type("🍜 Kyoto · Restaurants")
target.keyboard.press("Tab")
target.keyboard.press("Enter")
There is no save button. Tab+Enter persists.
3c. Set the list icon (emoji)
Saved Lists do have an icon picker (an emoji palette). It sits in the list detail view, left of the H1 title. There is no color picker (emojis carry their own color). The picker is NOT inside the "More options" menu; it is a separate button in the title header.
Verified 2026-07-06: the current label is Choose icon (the palette opens directly and auto-closes after picking). Keep Edit icon as a fallback in the selector (costs nothing, catches future UI renames); if no palette is open after the click, click the submenu entry first.
Workflow:
- Open the list (detail view, H1 with the list name visible)
- Left of the H1: a small glyph icon (
span.kSOdnb, default bookmark glyph) - Hover the glyph, then the icon button (
aria-label=Choose iconOREdit icon) becomes clickable - Click it: the dialog
[role="dialog"][aria-label="Emoji characters palette"]opens - Click an emoji: the dialog closes itself and the list icon is set. No save button needed.
# List open in detail view, H1 visible
target.locator('button[aria-label="Choose icon"], button[aria-label="Edit icon"]').first.click()
target.wait_for_timeout(2500)
# Every emoji in the picker carries its own aria-label, search directly:
emoji = target.locator('[role="dialog"][aria-label="Emoji characters palette"] [aria-label="🍜"]').first
emoji.scroll_into_view_if_needed()
emoji.click()
target.wait_for_timeout(2000)
# Dialog closes itself, icon is saved
Picker categories: SMILEYS · PEOPLE · ANIMALS · FOOD & DRINK · TRAVEL & PLACES · ACTIVITIES · OBJECTS · SYMBOLS · FLAGS. All standard Unicode emojis are available (roughly 1700+).
Emoji mapping suggestion (travel lists, project-agnostic):
| Category | Suggestion | Alternatives |
|---|---|---|
| Restaurants | 🍝 | 🍽️ 🍕 🍴 🥘 🍷 |
| Sights | 🏛️ | 📷 🗺️ 🏰 ⛪ 🏞️ |
| Day trips / road trips | 🚗 | 🚙 ⛰️ 🥾 🏖️ 🚄 |
| Emergency / medical | 🏥 | 🚑 ⛑️ 🆘 |
| Hotels / lodging | 🏨 | 🛏️ 🏡 |
| Shopping | 🛍️ | 🛒 |
| Bars / nightlife | 🍸 | 🍻 🎶 |
If free color choice is required, use My Maps (section 4b).
Rename a list, inline via H1 click:
target.locator('h1').click() # H1 becomes an editable INPUT
target.keyboard.press("Control+A")
target.keyboard.type("New list name")
target.keyboard.press("Tab")
target.keyboard.press("Enter")
3d. Add a place
SEARCH = 'input[name="q"][role="combobox"], #searchboxinput' # old ID gone since 2026-07, name=q is stable
main = target.locator('[role="main"]').first
s = target.locator(SEARCH).first
s.click(); s.fill(""); s.type("<place name> <city>"); target.keyboard.press("Enter")
target.wait_for_timeout(4500)
# Anchor validation (phone number or street+number known from research)
body = target.locator('body').inner_text()
if "<phone or street+number>" not in body:
print("WARN anchor mismatch, manual review"); return
# ALWAYS scope the save button to [role=main]: an unscoped :has-text("Saved") hits the nav rail!
# Before saving: aria-label="Save". After saving: aria-label GONE, button text "Saved" or "Saved (N)".
main.locator('button[aria-label="Save"], button:has-text("Saved")').first.click()
target.wait_for_timeout(1800)
# The idempotency anchor lives IN THE MENU (verified 2026-07-06): every list row has aria-checked.
# checked=true -> press Escape and do NOT click. A click would TOGGLE the save = silently remove it!
# :visible is mandatory: the map layer menu (Transit/Traffic/...) sits invisibly in the DOM as
# menuitemradio rows and would match first.
item = target.locator('[role="menuitemradio"]:visible').filter(has_text="🍜 Kyoto · Restaurants").first
if item.get_attribute("aria-checked") == "true":
target.keyboard.press("Escape"); print("already_there")
else:
item.click()
target.wait_for_timeout(1500)
target.keyboard.press("Escape")
Idempotency anchor: the aria-checked of the list row inside the save menu (NOT on the save button; that button loses its aria-label after saving).
3e. Per-place notes: add / read / bulk update
Maps allows one note per saved place (maxlength="4000" chars). Notes are per list (the same place can carry a different note in list A than in list B); recipients of a shared list see the notes too.
IMPORTANT: the note lives as the value of textarea[aria-label="Note"]:
innerText/textContentof the textarea is ALWAYS EMPTY (read-only view)textarea.valueis the single source of truth- Note audit pattern:
document.querySelectorAll('textarea[aria-label="Note"]').forEach(t => console.log(t.value))
First creation via the UI flow (classic, when only 1-2 notes are new):
open_saved(target)
target.locator('text="🍜 Kyoto · Restaurants"').first.click() # the overview row is a button
target.locator('div:has-text("<place name>")').first.click()
target.wait_for_timeout(1500)
note_btn = target.locator('button[aria-label*="note" i], button[aria-label*="Add a note" i]').first
note_btn.click()
target.locator('textarea').fill("Closed Tuesday | reservation recommended | <phone>")
target.get_by_role("button", name="Done").click()
Bulk update via direct DOM access (10+ notes, in the list view, no detail clicks):
In the list view ALL textarea[aria-label="Note"] elements already exist in the DOM (even for places not currently visible; Maps pre-renders them). Therefore:
# 1. Open the list and scroll so all textareas are in the DOM
open_saved(target) # 3a; NOT :has-text("Saved"), see section 6
# ... open list ...
target.mouse.move(250, 500)
for _ in range(15):
target.mouse.wheel(0, 500); target.wait_for_timeout(120)
for _ in range(18):
target.mouse.wheel(0, -500); target.wait_for_timeout(80)
# 2. Bulk extract via JS
notes = target.evaluate("""
() => Array.from(document.querySelectorAll('textarea[aria-label="Note"]')).map(t => ({
value: t.value,
nearbyName: (function(){
let p = t.parentElement;
for (let i = 0; i < 10; i++) {
if (!p) return '';
const h = p.querySelector('.fontHeadlineSmall.rZF81c');
if (h) return h.innerText;
p = p.parentElement;
}
return '';
})(),
}))
""")
# 3. Update one note (looked up by place name)
def update_note(target, place_name, new_text):
handle = target.evaluate_handle("""
(name) => {
const tas = document.querySelectorAll('textarea[aria-label="Note"]');
for (const t of tas) {
let p = t.parentElement;
for (let i = 0; i < 10; i++) {
if (!p) break;
const h = p.querySelector('.fontHeadlineSmall.rZF81c');
if (h && h.innerText === name) return t;
p = p.parentElement;
}
}
return null;
}
""", place_name)
target.evaluate("""
([t, text]) => {
t.focus();
t.value = text;
t.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text }));
t.dispatchEvent(new Event('change', { bubbles: true }));
t.blur(); // persistence trigger: Maps saves on blur
}
""", [handle, new_text])
target.wait_for_timeout(1500) # wait for backend sync
Mandatory save sequence (otherwise nothing persists): value = ... plus dispatchEvent(InputEvent) plus dispatchEvent(Event change) plus blur(). All four steps. value= alone is in-memory only.
Verify after a bulk update: hard reload (target.goto(...)), re-open the list, re-extract the values. Only then is the backend sync confirmed.
Note format suggestion (by occasion):
- Short (default, one line):
<phone> | Closed Tuesday | fair local kitchen | book on weekends - Medium (2-6 sentences, guidebook style, default for shared lists): phone + hours/closing days + one highlight + one practical tip + a safety hint where relevant. Prose, no bullets, no headlines.
Note style convention (hard, check every note):
- NO
**bold**markdown asterisks (they render as literal asterisks in the Maps UI and look machine-generated) - NO address duplication; the bookmark already shows the address. Include it only as a real localization hint (the emergency entrance, the pier at the east end)
- NO repetition of place name + city; the bookmark shows that
- NO source markers like
(H)(W)in Maps notes; those belong in the internal master file only - NO generic filler ("recommended", "authentic", "great atmosphere", "family-friendly"); only concrete usable information
- NO caps-lock inflation; caps only for real safety warnings (NEVER drive into the restricted traffic zone)
- Bullet lists with
-or•only for real enumerations (emergency numbers), otherwise prose
Anti-pattern: "more is better" applies to separate trip documents, NOT to Maps notes. Maps shows the note in a roughly 220px wide bottom sheet on mobile; anything past 6 sentences scrolls and makes the pin unreadable. When in doubt, 2 honest sentences beat 6 padded ones.
3f. Generate a share link
target.get_by_role("button", name="Share").click()
target.locator('button:has-text("Get link")').click()
share_url = target.locator('input[readonly]').input_value()
Caveat: the dropdown after the Share click renders inconsistently between sessions. On a no_dropdown failure, hand it to the user as a manual step (about 3 minutes for 4 lists); see pitfalls.
3g. Delete a list (test/throwaway lists)
Verified 2026-07-06: row menu button[aria-label="More options"], then menu entry "Delete list", then the confirm dialog ("Are you sure you want to delete ...?") and its delete button. Two traps: (a) .S9kvJb is the generic button class (also "New list"!), NOT a delete anchor; (b) Maps permanently keeps an EMPTY [role="dialog"] in the DOM, so find the real confirm dialog by non-empty text, never via .first.
open_saved(target)
target.evaluate("""
(name) => { // click the More-options button of the row with exactly this list name
const vis = el => !!(el.offsetWidth || el.offsetHeight);
const leaf = Array.from(document.querySelectorAll('*')).find(el =>
el.children.length === 0 && (el.textContent||'').trim() === name && vis(el));
let row = leaf;
for (let i = 0; i < 12 && row; i++, row = row.parentElement) {
const more = row.querySelector && row.querySelector('button[aria-label="More options"]');
if (more) { more.click(); return; }
}
}""", "ZZZ test list")
target.wait_for_timeout(1800)
target.locator('[role="menuitem"]:visible').filter(has_text="Delete list").first.click()
target.wait_for_timeout(2000)
target.evaluate("""
() => { // confirm: find the dialog WITH text, click its delete button
const vis = el => !!(el.offsetWidth || el.offsetHeight);
const dlg = Array.from(document.querySelectorAll('[role="dialog"],[role="alertdialog"]'))
.find(d => vis(d) && (d.innerText||'').trim());
const btn = dlg && Array.from(dlg.querySelectorAll('button')).find(b => /delete/i.test(b.innerText||''));
if (btn) btn.click();
}""")
4. My Maps workflow (fallback)
Only when custom lat/lng pins or layer structures are required.
4a. CSV import (4 clicks per layer)
target.goto("https://www.google.com/maps/d/u/0/create")
target.locator('[aria-label="Map title"]').fill("My trip map")
target.locator('button[name="dialog_ok_button"]').click()
# Per layer N:
target.locator(f'#ly{N}-layerview-import-link').click()
file_input = target.frame_locator('iframe[src*="docs.google.com/picker"]').locator('input[type="file"]')
file_input.set_input_files("/path/to/layer.csv")
target.locator('button[name="location_step_ok"]').click()
target.locator('input[type="radio"][value="Name"]').check()
target.locator('button[name="name_step_ok"]').click()
target.locator('#map-action-add-layer').click() # next layer
CSV structure: "Name","Latitude","Longitude","Description".
4b. Layer color (bucket-hover trick)
The color picker is reachable only via the hover bucket icon on the All-items subrow.
ROW_Y = [295, 401, 507, 613] # layers 0,1,2,3
for idx, y in enumerate(ROW_Y):
target.keyboard.press("Escape")
target.mouse.move(120, y); target.wait_for_timeout(600)
target.locator('.un1lmc-pbTTYe-ibnC6b-DyVDA').nth(idx).click(force=True)
target.locator('[aria-label="RGB (165, 39, 20)"]').click()
target.keyboard.press("Escape")
Color aria-labels: red RGB (165, 39, 20) · blue RGB (2, 136, 209) · purple RGB (103, 58, 183) · yellow RGB (251, 192, 45) · green RGB (76, 175, 80).
4c. Layer icon (More-icons library)
In the same style popup below the color palette: #stylepopup-moreicons-button, then the "Choose an icon" dialog. The OK button click at x=675+36, y=821+14 is mandatory (selecting alone does not save).
target.locator('#stylepopup-moreicons-button').click()
target.locator('.QV5wG-LJSvSb[aria-label="Restaurant"]').scroll_into_view_if_needed()
target.locator('.QV5wG-LJSvSb[aria-label="Restaurant"]').click()
target.mouse.click(675+36, 821+14) # OK
The full icon library with 431 aria-labels lives in icon-library.json in this skill folder. Coordinates assume a maximized window on a 1080p-class display; re-probe on other layouts.
5. Place data research
Before creating any pin, always research via web fetch:
- Official website for address, phone, opening hours
- TripAdvisor or Google reviews for a freshness check
- A local guide (established restaurant/travel editors) as a quality indicator
- The phone number doubles as the anchor for Maps place validation
Mandatory fields per note:
- Phone (reservations plus validation)
- Closing-day constraint (
Closed Tuesday; saves the group from standing in front of a locked door) - One-sentence highlight (why this place)
- Special hint where relevant: reservation required / book via app / seasonal closure / discount code
6. Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| Playwright instead of web fetch for research | bot-detection captchas, slow, fragile | research exclusively via web fetch / browser MCP |
| Chrome 136+ blocks remote debugging on the default profile | the switch is ignored, nothing listens on the port | dedicated-profile Chrome via launch-chrome-cdp.ps1 |
| Save button toggles between "Save"/"Saved" | selector matches only one, causing re-saves / skip fails | match both, scoped to [role=main]; idempotency via the menu row's aria-checked (3d) |
| Maps URL slug not unique | a short slug resolves to a same-named place in a different region | take the URL from the search response, never guess it |
| More-icons library does not save | icon visually selected, layer pin unchanged | the OK button click (x=675+36, y=821+14) is mandatory |
| Choose-icon button searched inside "More options" | that menu only has sharing/hide/delete; the picker sits in the title header left of the H1 | target button[aria-label="Choose icon"], button[aria-label="Edit icon"] directly |
| Saved button selector via aria-label="Saved" | Maps has NO such aria-label, only a glyph plus the text "Saved" | main.locator('button:has-text("Saved")'); unscoped it hits the nav rail |
| Share dropdown renders inconsistently | "Send link" sometimes visible, sometimes not | cost-benefit: a 3-minute manual step for the user, not a 30-minute debug |
| Approximate geocoding pin lands wrong | open data has no detail for a small venue | mark [!] VERIFY ON SITE in the note, never save blindly |
| Sidebar pin icon (24x24px) visually ambiguous | a restaurant icon can read as an X | check the map pin at zoom, not the sidebar |
| One mega list with 30+ pins | user sees everything at once, app overloaded | several themed lists (use the toggle feature) |
Reading a note via innerText / textContent |
always returns 0 chars although the note is visible | textarea.value is the single source of truth |
Note update via value= only (no blur) |
looks fine in memory, gone after reload | value= + dispatchEvent('input') + dispatchEvent('change') + blur(), all mandatory |
| Copy-paste bug in bulk note scripts | place B receives place A's note (e.g. repeated textarea.first.fill()) |
iterate with nearbyName === expected as a mandatory match before value= |
| Note persistence unverified | "OK" in the update log although the backend never synced | verify script with hard reload plus re-read |
Hard reload via goto wait_until="networkidle" |
30s timeout (Maps has permanent background requests) | wait_until="domcontentloaded" plus a short sleep |
Notes full of **bold** markdown |
renders as literal asterisks, looks machine-generated | check 1:1 before saving: no asterisks, no markdown |
| Note duplicates address and place name | the bookmark shows those anyway, the note turns redundant | start the note with practical info (phone/hours/hint) |
| Maps capitalizes place headers differently than expected | lookup mismatch on exact-match note updates | probe run before bulk updates: extract all .fontHeadlineSmall.rZF81c texts 1:1 and use them as dict keys |
button[aria-label="Saved"] as entry point (UI until 2026-05) |
timeout; the button no longer exists since 2026-07 | button[jsaction="navigationrail.saved"] + click(force=True) (an overlay span eats normal clicks) |
#searchboxinput (UI until 2026-05) |
timeout; the ID is gone, the new one is dynamic | input[name="q"][role="combobox"] |
| aria-checked on the save button as idempotency anchor | the attribute vanishes after saving, so a re-save silently TOGGLES saves away | anchor = aria-checked of the menuitemradio row IN the save menu; checked means Escape, not click |
Save menu rows matched without :visible |
the map layer menu (Transit/Traffic/...) sits invisibly in the DOM as menuitemradio and matches first | [role="menuitemradio"]:visible |
Confirm dialog via [role=dialog].first |
Maps keeps a permanently EMPTY dialog element in the DOM, the confirmation goes nowhere | scan dialogs for non-empty innerText (3g) |
.S9kvJb as delete anchor |
generic button class (also "New list"), clicks the wrong thing | row "More options" -> "Delete list" -> confirm (3g) |
| List creation without an existence check | a rerun creates a duplicate list and the save menu scatters places | check the Saved overview for the name before "New list" (3b) |
| Attached to a foreign CDP session (often on 9222) | consent page instead of Maps, no login, account check timeout | run on a dedicated port (default 9223); the launcher checks collisions; never kill a foreign CDP Chrome (section 2) |
| Build mechanics rewritten per project | one-off scripts reinvent the solved section 6 bugs (phantom save, order, note loss) | use the engine scripts/maps_lists.py (3.0); on a UI break fix the engine, no parallel script |
Multi-line python -c "..." in PowerShell |
parser errors from quoting/parentheses | always run Python as a script file; python -c only for one-liners |
[role="main"].first after a list view or in split view |
the save click hits the wrong panel: phantom save (looks fine, persists nothing) or timeout | before every place search goto the base URL, then pick the main panel that CONTAINS the save button, never .first |
| First save right after list creation | the new list is missing from the save menu (sync lag), causing menu_fail or phantom save | wait ~8s after creation; retry the menu; verify each save via the menu's aria-checked and re-click if needed |
| Late save into an existing list | lands ON TOP (newest first), breaking the rank order | order repair: toggle every place that belongs above it in reverse rank order (unsave+resave); careful: unsave DELETES the note, re-set notes afterwards |
| Search returns a result list instead of a place page | no save button in main | click the feed link whose aria-label starts with the expected header; then run the anchor check to guard against a same-named place elsewhere |
7. Quick start
- Collect source data in Markdown (name + city + phone + hint)
- Decide: Saved Lists (default) vs. My Maps
- Launch Chrome via
scripts/launch-chrome-cdp.ps1(port 9223, collision check built in) - Research every place via web fetch until its note text is ready
- Write
data.json(places in rank order, #1 first) maps_lists.py build data.json(3.0): lists + icons + saves + order + notes + verify in one run- Generate share links (3f); fall back to a manual step for the user
- Keep the master file (source data plus final notes) as the source of truth for reruns
8. File layout (per project)
<trip-or-project-folder>/
├── places.md source list (name + city + phone + hint)
├── place-notes.md researched note per place (backup if Maps misbehaves)
├── data.json engine input for maps_lists.py (3.0)
├── import-layer-XX.csv My Maps import files (My Maps only)
└── share-links.md share URLs
Saved Lists builds run through the engine (3.0); project-local scripts only for My Maps or special cases, and then idempotent and re-runnable.